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'),
             )),
           ]);