blob: ad4e00f7bbe75caab14146cf5fb67e46ac495ddf [file] [log] [blame]
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp_server/src/mixins/pub_dev_search.dart';
import 'package:http/http.dart';
import 'package:test/test.dart';
import '../test_harness.dart';
void main() {
Future<void> runWithHarness(
Future<void> Function(TestHarness harness, Tool pubDevSearchTool) fn,
) async {
final testHarness = await TestHarness.start(inProcess: true);
final testRoot = testHarness.rootForPath(counterAppPath);
testHarness.mcpClient.addRoot(testRoot);
await pumpEventQueue();
final tools = (await testHarness.mcpServerConnection.listTools()).tools;
await fn(
testHarness,
tools.singleWhere((t) => t.name == PubDevSupport.pubDevTool.name),
);
}
test('searches pub dev, and gathers information about packages', () async {
await runWithClient(() async {
await runWithHarness((testHarness, pubDevSearchTool) async {
final request = CallToolRequest(
name: pubDevSearchTool.name,
arguments: {'query': 'retry', 'latestVersion': '3.1.2'},
);
final result = await testHarness.callToolWithRetry(
request,
maxTries: 1,
);
expect(result.content.length, 10);
expect(
result.content
.map(
(c) =>
(json.decode((c as TextContent).text)
as Map)['packageName'],
)
.toList(),
[
'retry',
'http_client_helper',
'dio_smart_retry',
'en_file_uploader',
'dio_retry_plus',
'futuristic',
'http_file_uploader',
'dio_file_uploader',
'network_checker',
'buxing',
],
);
expect(json.decode((result.content[0] as TextContent).text), {
'packageName': 'retry',
'latestVersion': '3.1.2',
'description':
'Utility for wrapping an asynchronous function in automatic '
'retry logic with exponential back-off, useful when making '
'requests over network.',
'homepage': 'https://github.com/google/dart-neats/tree/master/retry',
'repository': 'https://github.com/google/dart-neats.git',
'libraries': {
'retry': 'https://pub.dev/documentation/retry/latest/retry/',
},
'scores': {
'pubPoints': isA<int>(),
'maxPubPoints': isA<int>(),
'likes': isA<int>(),
'downloadCount': isA<int>(),
},
'topics': ['topic:network', 'topic:http'],
'licenses': [
'license:apache-2.0',
'license:fsf-libre',
'license:osi-approved',
],
'publisher': 'publisher:google.dev',
});
expect(json.decode((result.content[2] as TextContent).text), {
'packageName': 'dio_smart_retry',
'latestVersion': '7.0.1',
'description':
'Retry library for Dio and Dio package made with love. By '
'default, the request will be retried only for '
'appropriate retryable http statuses.',
'homepage': 'https://github.com/rodion-m/dio_smart_retry',
'repository': 'https://github.com/rodion-m/dio_smart_retry',
'documentation':
'https://github.com/rodion-m/dio_smart_retry#contents',
'libraries': {
'dio_smart_retry':
'https://pub.dev/documentation/dio_smart_retry/latest/dio_smart_retry/',
},
'scores': {
'pubPoints': isA<int>(),
'maxPubPoints': isA<int>(),
'likes': isA<int>(),
'downloadCount': isA<int>(),
},
'topics': isEmpty,
'licenses': [
'license:mit',
'license:fsf-libre',
'license:osi-approved',
],
'publisher': 'publisher:rodion-m.ru',
});
});
}, _GoldenResponseClient.new);
});
test('Reports failure on missing response', () async {
await runWithClient(() async {
await runWithHarness((testHarness, pubDevSearchTool) async {
final request = CallToolRequest(
name: pubDevSearchTool.name,
arguments: {'query': 'retry'},
);
final result = await testHarness.callToolWithRetry(
request,
maxTries: 1,
expectError: true,
);
expect(result.isError, isTrue);
expect(
(result.content[0] as TextContent).text,
contains('Failed searching pub.dev: ClientException: No internet'),
);
});
}, () => _FixedResponseClient.withMappedResponses({}));
});
test('Tolerates missing sub-responses', () async {
await runWithClient(
() async {
await runWithHarness((testHarness, pubDevSearchTool) async {
final request = CallToolRequest(
name: pubDevSearchTool.name,
arguments: {'query': 'retry'},
);
final result = await testHarness.callToolWithRetry(
request,
maxTries: 1,
expectError: true,
);
expect(result.content.length, 1);
expect(json.decode((result.content[0] as TextContent).text), {
'packageName': 'retry',
});
});
},
// Serve a single package as search result, but provide no further
// information about it.
() => _FixedResponseClient.withMappedResponses({
'https://pub.dev/api/search?q=retry': jsonEncode({
'packages': [
{'package': 'retry'},
],
}),
}),
);
});
test('No matching packages gets special handling', () async {
await runWithClient(
() async {
await runWithHarness((testHarness, pubDevSearchTool) async {
final request = CallToolRequest(
name: pubDevSearchTool.name,
arguments: {'query': 'retry'},
);
final result = await testHarness.callToolWithRetry(
request,
maxTries: 1,
);
expect(result.isError, isNot(true));
expect(
(result.content[0] as TextContent).text,
contains('No packages matched the query, consider simplifying it'),
);
});
},
// Serve no packages, but provide no further information
// about it.
() => _FixedResponseClient.withMappedResponses({
'https://pub.dev/api/search?q=retry': jsonEncode({
'packages': <Object?>[],
}),
}),
);
});
}
class _FixedResponseClient implements Client {
final String Function(Uri url) handler;
_FixedResponseClient(this.handler);
_FixedResponseClient.withMappedResponses(Map<String, String> responses)
: handler = ((url) =>
responses[url.toString()] ?? (throw ClientException('No internet')));
@override
Future<String> read(Uri url, {Map<String, String>? headers}) async {
return handler(url);
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) {
throw StateError('Unexpected call $invocation');
}
}
class _GoldenResponseClient implements Client {
_GoldenResponseClient();
@override
Future<String> read(Uri url, {Map<String, String>? headers}) async {
return await _goldenResponse(url);
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) {
throw StateError('Unexpected call $invocation');
}
}
Future<String> _goldenResponse(Uri url) async {
final goldenFile = File(
'test_fixtures/pub_dev_responses/${Uri.encodeComponent(url.toString())}',
);
final String contents;
final recreate = Platform.environment['RECREATE_GOLDEN_RESPONSES'];
if (recreate == 'all' || recreate == 'missing' && !goldenFile.existsSync()) {
final client = Client();
final rawContents = await client.read(url);
// For readability we format the json response.
contents = const JsonEncoder.withIndent(
' ',
).convert(jsonDecode(rawContents));
client.close();
goldenFile.createSync(recursive: true);
goldenFile.writeAsStringSync(contents);
return contents;
} else {
try {
return goldenFile.readAsStringSync();
} on IOException catch (e) {
fail(
'Could not read golden response file ${goldenFile.path} for $url: $e. '
'Consider recreating by calling with '
'\$RECREATE_GOLDEN_RESPONSES=missing',
);
}
}
}