run tests on windows, fix executable paths for dart/flutter (#149)
Closes https://github.com/dart-lang/ai/issues/32
I believe this should just work now that we are using headless flutter
Initially also added macos tests but those bots tend to wait queued for a long time, so I have removed them.
diff --git a/.github/workflows/dart_mcp.yaml b/.github/workflows/dart_mcp.yaml
index 42b616a..b8fc372 100644
--- a/.github/workflows/dart_mcp.yaml
+++ b/.github/workflows/dart_mcp.yaml
@@ -21,11 +21,16 @@
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- sdk: [stable, dev]
+ sdk:
+ - stable
+ - dev
+ os:
+ - ubuntu-latest
+ - windows-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c
diff --git a/.github/workflows/dart_mcp_server.yaml b/.github/workflows/dart_mcp_server.yaml
index 763ea4a..4cb26b8 100644
--- a/.github/workflows/dart_mcp_server.yaml
+++ b/.github/workflows/dart_mcp_server.yaml
@@ -23,11 +23,16 @@
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- flutterSdk: [stable, master]
+ flutterSdk:
+ - stable
+ - master
+ os:
+ - ubuntu-latest
+ - windows-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# We need the flutter SDK in order to run the counter app for integration
diff --git a/pkgs/dart_mcp_server/lib/src/utils/sdk.dart b/pkgs/dart_mcp_server/lib/src/utils/sdk.dart
index f3f5523..15273af 100644
--- a/pkgs/dart_mcp_server/lib/src/utils/sdk.dart
+++ b/pkgs/dart_mcp_server/lib/src/utils/sdk.dart
@@ -47,7 +47,9 @@
if (dartSdkPath.parent case final cacheDir
when cacheDir.basename == 'cache' && flutterSdkPath == null) {
if (cacheDir.parent case final binDir when binDir.basename == 'bin') {
- final flutterExecutable = binDir.child('flutter');
+ final flutterExecutable = binDir.child(
+ 'flutter${Platform.isWindows ? '.bat' : ''}',
+ );
if (File(flutterExecutable).existsSync()) {
flutterSdkPath = binDir.parent;
}
@@ -61,7 +63,9 @@
///
/// Throws an [ArgumentError] if [dartSdkPath] is `null`.
String get dartExecutablePath =>
- dartSdkPath?.child('bin').child('dart') ??
+ dartSdkPath
+ ?.child('bin')
+ .child('dart${Platform.isWindows ? '.exe' : ''}') ??
(throw ArgumentError(
'Dart SDK location unknown, try setting the DART_SDK environment '
'variable.',
@@ -71,7 +75,9 @@
///
/// Throws an [ArgumentError] if [flutterSdkPath] is `null`.
String get flutterExecutablePath =>
- flutterSdkPath?.child('bin').child('flutter') ??
+ flutterSdkPath
+ ?.child('bin')
+ .child('flutter${Platform.isWindows ? '.bat' : ''}') ??
(throw ArgumentError(
'Flutter SDK location unknown. To work on flutter projects, you must '
'spawn the server using `dart` from the flutter SDK and not a Dart '
diff --git a/pkgs/dart_mcp_server/test/test_harness.dart b/pkgs/dart_mcp_server/test/test_harness.dart
index a0c0f1c..6e9f577 100644
--- a/pkgs/dart_mcp_server/test/test_harness.dart
+++ b/pkgs/dart_mcp_server/test/test_harness.dart
@@ -4,7 +4,8 @@
import 'dart:async';
import 'dart:convert';
-import 'dart:io';
+import 'dart:io' hide File;
+import 'dart:io' as io show File;
import 'package:async/async.dart';
import 'package:dart_mcp/client.dart';
@@ -256,7 +257,7 @@
await process.shouldExit(0);
} else {
unawaited(process.kill());
- await process.shouldExit(anyOf(0, -9));
+ await process.shouldExit(anyOf(0, Platform.isWindows ? -1 : -9));
}
}
@@ -497,5 +498,6 @@
}
extension RootPath on Root {
- String get path => Uri.parse(uri).path;
+ /// Get the OS specific file path for this root.
+ String get path => io.File.fromUri(Uri.parse(uri)).path;
}
diff --git a/pkgs/dart_mcp_server/test/tools/analyzer_test.dart b/pkgs/dart_mcp_server/test/tools/analyzer_test.dart
index ceffd4b..2cfb10d 100644
--- a/pkgs/dart_mcp_server/test/tools/analyzer_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/analyzer_test.dart
@@ -2,8 +2,6 @@
// 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:io';
-
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp_server/src/mixins/analyzer.dart';
import 'package:dart_mcp_server/src/utils/constants.dart';
@@ -75,14 +73,18 @@
});
test('can look up symbols in a workspace', () async {
- final currentRoot = testHarness.rootForPath(Directory.current.path);
- testHarness.mcpClient.addRoot(currentRoot);
+ final example = d.dir('lib', [
+ d.file('awesome_class.dart', 'class MyAwesomeClass {}'),
+ ]);
+ await example.create();
+ final exampleRoot = testHarness.rootForPath(example.io.path);
+ testHarness.mcpClient.addRoot(exampleRoot);
await pumpEventQueue();
final result = await testHarness.callToolWithRetry(
CallToolRequest(
name: DartAnalyzerSupport.resolveWorkspaceSymbolTool.name,
- arguments: {ParameterNames.query: 'DartAnalyzerSupport'},
+ arguments: {ParameterNames.query: 'MyAwesomeClass'},
),
);
expect(result.isError, isNot(true));
@@ -92,7 +94,7 @@
isA<TextContent>().having(
(t) => t.text,
'text',
- contains('analyzer.dart'),
+ contains('awesome_class.dart'),
),
);
});
diff --git a/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart b/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart
index 1473741..26697d6 100644
--- a/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart
@@ -2,6 +2,8 @@
// 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:io';
+
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp_server/src/mixins/dash_cli.dart';
import 'package:dart_mcp_server/src/utils/constants.dart';
@@ -15,6 +17,8 @@
late TestProcessManager testProcessManager;
late Root exampleFlutterAppRoot;
late Root dartCliAppRoot;
+ final dartExecutableName = 'dart${Platform.isWindows ? '.exe' : ''}';
+ final flutterExecutableName = 'flutter${Platform.isWindows ? '.bat' : ''}';
// TODO: Use setUpAll, currently this fails due to an apparent TestProcess
// issue.
@@ -76,7 +80,7 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith('dart'), 'fix', '--apply'],
+ command: [endsWith(dartExecutableName), 'fix', '--apply'],
workingDirectory: exampleFlutterAppRoot.path,
)),
]);
@@ -98,7 +102,7 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith('dart'), 'format', '.'],
+ command: [endsWith(dartExecutableName), 'format', '.'],
workingDirectory: exampleFlutterAppRoot.path,
)),
]);
@@ -123,7 +127,12 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith('dart'), 'format', 'foo.dart', 'bar.dart'],
+ command: [
+ endsWith(dartExecutableName),
+ 'format',
+ 'foo.dart',
+ 'bar.dart',
+ ],
workingDirectory: exampleFlutterAppRoot.path,
)),
]);
@@ -155,7 +164,7 @@
expect(testProcessManager.commandsRan, [
equalsCommand((
command: [
- endsWith('flutter'),
+ endsWith(flutterExecutableName),
'test',
'foo_test.dart',
'bar_test.dart',
@@ -163,7 +172,7 @@
workingDirectory: exampleFlutterAppRoot.path,
)),
equalsCommand((
- command: [endsWith('dart'), 'test', 'zip_test.dart'],
+ command: [endsWith(dartExecutableName), 'test', 'zip_test.dart'],
workingDirectory: dartCliAppRoot.path,
)),
]);
@@ -186,7 +195,7 @@
expect(testProcessManager.commandsRan, [
equalsCommand((
command: [
- endsWith('dart'),
+ endsWith(dartExecutableName),
'create',
'--template',
'cli',
@@ -213,7 +222,7 @@
expect(testProcessManager.commandsRan, [
equalsCommand((
command: [
- endsWith('flutter'),
+ endsWith(flutterExecutableName),
'create',
'--template',
'app',
@@ -243,7 +252,7 @@
expect(testProcessManager.commandsRan, [
equalsCommand((
command: [
- endsWith('flutter'),
+ endsWith(flutterExecutableName),
'create',
'--template',
'app',
diff --git a/pkgs/dart_mcp_server/test/tools/dtd_test.dart b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
index 349a7fe..ba07aa8 100644
--- a/pkgs/dart_mcp_server/test/tools/dtd_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
@@ -97,7 +97,7 @@
test('can perform a hot reload', () async {
final exampleApp = await Directory.systemTemp.createTemp('dart_app');
addTearDown(() async {
- await exampleApp.delete(recursive: true);
+ await _deleteWithRetry(exampleApp);
});
final mainFile = File.fromUri(
exampleApp.uri.resolve('bin/main.dart'),
@@ -165,7 +165,7 @@
setUp(() async {
appDir = await Directory.systemTemp.createTemp('dart_app');
addTearDown(() async {
- await appDir.delete(recursive: true);
+ await _deleteWithRetry(appDir);
});
final mainFile = File.fromUri(appDir.uri.resolve(appPath));
await mainFile.create(recursive: true);
@@ -306,7 +306,7 @@
setUp(() async {
appDir = await Directory.systemTemp.createTemp('dart_app');
addTearDown(() async {
- await appDir.delete(recursive: true);
+ await _deleteWithRetry(appDir);
});
final mainFile = File.fromUri(appDir.uri.resolve(appPath));
await mainFile.create(recursive: true);
@@ -396,89 +396,95 @@
);
});
- test('can be read and subscribed to as a resource', () async {
- final serverConnection = testHarness.mcpServerConnection;
- final onResourceListChanged =
- serverConnection.resourceListChanged.first;
+ test(
+ 'can be read and subscribed to as a resource',
+ () async {
+ final serverConnection = testHarness.mcpServerConnection;
+ final onResourceListChanged =
+ serverConnection.resourceListChanged.first;
- final stdin = debugSession.appProcess.stdin;
- stdin.writeln('');
- var resources =
- (await serverConnection.listResources(
- ListResourcesRequest(),
- )).resources;
- if (resources.runtimeErrors.isEmpty) {
- await onResourceListChanged;
- resources =
+ final stdin = debugSession.appProcess.stdin;
+ stdin.writeln('');
+ var resources =
(await serverConnection.listResources(
ListResourcesRequest(),
)).resources;
- }
- final resource = resources.runtimeErrors.single;
+ if (resources.runtimeErrors.isEmpty) {
+ await onResourceListChanged;
+ resources =
+ (await serverConnection.listResources(
+ ListResourcesRequest(),
+ )).resources;
+ }
+ final resource = resources.runtimeErrors.single;
- final resourceUpdatedQueue = StreamQueue(
- serverConnection.resourceUpdated,
- );
- await serverConnection.subscribeResource(
- SubscribeRequest(uri: resource.uri),
- );
- var originalContents =
- (await serverConnection.readResource(
- ReadResourceRequest(uri: resource.uri),
- )).contents;
- final errorMatcher = isA<TextResourceContents>().having(
- (c) => c.text,
- 'text',
- contains('error!'),
- );
- // If we haven't seen errors initially, then listen for updates and
- // re-read the resource.
- if (originalContents.isEmpty) {
- await resourceUpdatedQueue.next;
- originalContents =
+ final resourceUpdatedQueue = StreamQueue(
+ serverConnection.resourceUpdated,
+ );
+ await serverConnection.subscribeResource(
+ SubscribeRequest(uri: resource.uri),
+ );
+ var originalContents =
(await serverConnection.readResource(
ReadResourceRequest(uri: resource.uri),
)).contents;
- }
- expect(
- originalContents.length,
- 1,
- reason: 'should have exactly one error, got $originalContents',
- );
- expect(originalContents.single, errorMatcher);
+ final errorMatcher = isA<TextResourceContents>().having(
+ (c) => c.text,
+ 'text',
+ contains('error!'),
+ );
+ // If we haven't seen errors initially, then listen for updates and
+ // re-read the resource.
+ if (originalContents.isEmpty) {
+ await resourceUpdatedQueue.next;
+ originalContents =
+ (await serverConnection.readResource(
+ ReadResourceRequest(uri: resource.uri),
+ )).contents;
+ }
+ expect(
+ originalContents.length,
+ 1,
+ reason: 'should have exactly one error, got $originalContents',
+ );
+ expect(originalContents.single, errorMatcher);
- stdin.writeln('');
- expect(
- await resourceUpdatedQueue.next,
- isA<ResourceUpdatedNotification>().having(
- (n) => n.uri,
- ParameterNames.uri,
- resource.uri,
- ),
- );
+ stdin.writeln('');
+ expect(
+ await resourceUpdatedQueue.next,
+ isA<ResourceUpdatedNotification>().having(
+ (n) => n.uri,
+ ParameterNames.uri,
+ resource.uri,
+ ),
+ );
- // Should now have another error.
- final newContents =
- (await serverConnection.readResource(
- ReadResourceRequest(uri: resource.uri),
- )).contents;
- expect(newContents.length, 2);
- expect(newContents.last, errorMatcher);
+ // Should now have another error.
+ final newContents =
+ (await serverConnection.readResource(
+ ReadResourceRequest(uri: resource.uri),
+ )).contents;
+ expect(newContents.length, 2);
+ expect(newContents.last, errorMatcher);
- // Clear previous errors.
- await testHarness.callToolWithRetry(
- CallToolRequest(
- name: DartToolingDaemonSupport.getRuntimeErrorsTool.name,
- arguments: {'clearRuntimeErrors': true},
- ),
- );
+ // Clear previous errors.
+ await testHarness.callToolWithRetry(
+ CallToolRequest(
+ name: DartToolingDaemonSupport.getRuntimeErrorsTool.name,
+ arguments: {'clearRuntimeErrors': true},
+ ),
+ );
- final finalContents =
- (await serverConnection.readResource(
- ReadResourceRequest(uri: resource.uri),
- )).contents;
- expect(finalContents, isEmpty);
- });
+ final finalContents =
+ (await serverConnection.readResource(
+ ReadResourceRequest(uri: resource.uri),
+ )).contents;
+ expect(finalContents, isEmpty);
+ },
+ onPlatform: {
+ 'windows': const Skip('https://github.com/dart-lang/ai/issues/151'),
+ },
+ );
});
group('getActiveLocationTool', () {
@@ -659,3 +665,18 @@
print('hello');
}
''';
+
+/// Tries to delete [dir] up to 5 times, waiting 200ms between each.
+///
+/// Necessary for windows tests.
+Future<void> _deleteWithRetry(Directory dir) async {
+ var i = 0;
+ while (++i <= 5) {
+ try {
+ await dir.delete(recursive: true);
+ return;
+ } catch (_) {
+ await Future<void>.delayed(const Duration(milliseconds: 200));
+ }
+ }
+}
diff --git a/pkgs/dart_mcp_server/test/tools/pub_test.dart b/pkgs/dart_mcp_server/test/tools/pub_test.dart
index 19ba59b..841a60d 100644
--- a/pkgs/dart_mcp_server/test/tools/pub_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/pub_test.dart
@@ -2,6 +2,9 @@
// 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:io' hide File;
+import 'dart:io' as io show File;
+
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp_server/src/mixins/pub.dart';
import 'package:dart_mcp_server/src/utils/constants.dart';
@@ -19,14 +22,25 @@
late Tool dartPubTool;
late FileSystem fileSystem;
- final fakeAppPath = '/fake_app/';
+ final fakeAppPath = io.File.fromUri(Uri.parse('/fake_app/')).path;
for (final appKind in const ['dart', 'flutter']) {
+ final executableName =
+ '$appKind${Platform.isWindows
+ ? appKind == 'dart'
+ ? '.exe'
+ : '.bat'
+ : ''}';
group('$appKind app', () {
// TODO: Use setUpAll, currently this fails due to an apparent TestProcess
// issue.
setUp(() async {
- fileSystem = MemoryFileSystem();
+ fileSystem = MemoryFileSystem(
+ style:
+ Platform.isWindows
+ ? FileSystemStyle.windows
+ : FileSystemStyle.posix,
+ );
fileSystem.file(p.join(fakeAppPath, 'pubspec.yaml'))
..createSync(recursive: true)
..writeAsStringSync(
@@ -68,8 +82,8 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith(appKind), 'pub', 'add', 'foo'],
- workingDirectory: fakeAppPath,
+ command: [endsWith(executableName), 'pub', 'add', 'foo'],
+ workingDirectory: testRoot.path,
)),
]);
});
@@ -91,8 +105,8 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith(appKind), 'pub', 'remove', 'foo'],
- workingDirectory: fakeAppPath,
+ command: [endsWith(executableName), 'pub', 'remove', 'foo'],
+ workingDirectory: testRoot.path,
)),
]);
});
@@ -113,8 +127,8 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith(appKind), 'pub', 'get'],
- workingDirectory: fakeAppPath,
+ command: [endsWith(executableName), 'pub', 'get'],
+ workingDirectory: testRoot.path,
)),
]);
});
@@ -135,8 +149,8 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith(appKind), 'pub', 'upgrade'],
- workingDirectory: fakeAppPath,
+ command: [endsWith(executableName), 'pub', 'upgrade'],
+ workingDirectory: testRoot.path,
)),
]);
});
@@ -162,7 +176,7 @@
expect(result.isError, isNot(true));
expect(testProcessManager.commandsRan, [
equalsCommand((
- command: [endsWith(appKind), 'pub', 'get'],
+ command: [endsWith(executableName), 'pub', 'get'],
workingDirectory: p.join(fakeAppPath, 'subdir'),
)),
]);