diff --git a/doc/mcp.md b/doc/mcp.md index 52e89fda2..aa2a8ebb4 100644 --- a/doc/mcp.md +++ b/doc/mcp.md @@ -137,12 +137,13 @@ Runs tests in a Dart or Flutter project. "platform": "chrome | vm | android | ios", "dart-define": "foo=bar", "dart-define-from-file": "config.json", - "test_randomize_ordering_seed": "random" + "test_randomize_ordering_seed": "random", + "timeout_seconds": 120 } } ``` -All parameters are optional. When `optimization` is not specified, `--no-optimization` is applied by default. +All parameters are optional. When `optimization` is not specified, `--no-optimization` is applied by default. When `timeout_seconds` is not specified, no timeout is applied. ### `packages_get` diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index 57d086a66..eecce3cf2 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -30,6 +30,7 @@ class FlutterTestOptions { required this.reportOn, required this.runSkipped, required this.flavor, + required this.timeout, required this.rest, }); @@ -68,6 +69,12 @@ class FlutterTestOptions { .toList(); final runSkipped = argResults['run-skipped'] as bool; final flavor = argResults['flavor'] as String?; + final timeoutSeconds = int.tryParse( + argResults['timeout'] as String? ?? '', + ); + final timeout = timeoutSeconds != null + ? Duration(seconds: timeoutSeconds) + : null; final rest = argResults.rest; return FlutterTestOptions._( @@ -90,6 +97,7 @@ class FlutterTestOptions { reportOn: reportOn, runSkipped: runSkipped, flavor: flavor, + timeout: timeout, rest: rest, ); } @@ -153,6 +161,9 @@ class FlutterTestOptions { /// The flavor to build for testing. final String? flavor; + /// Maximum time to let tests run before killing the process. + final Duration? timeout; + /// The remaining arguments passed to the test command. final List rest; } @@ -331,6 +342,13 @@ class TestCommand extends Command { 'Build a custom app flavor as defined by platform-specific build ' 'setup. Supports the use of product flavors in Android Gradle ' 'scripts, and the use of custom Xcode schemes.', + ) + ..addOption( + 'timeout', + help: + 'Maximum seconds to let tests run before killing the process. ' + 'Useful when tests hang due to an unbounded pumpAndSettle() call.', + valueHelp: 'seconds', ); } @@ -409,6 +427,8 @@ This command should be run from the root of your Flutter project.'''); '--dart-define-from-file=$value', if (options.platform == null) ...['-j', options.concurrency], '--no-pub', + if (options.timeout != null) + '--timeout=${options.timeout!.inSeconds}s', ...options.rest, ], ); diff --git a/lib/src/mcp/mcp_server.dart b/lib/src/mcp/mcp_server.dart index 544ade81e..006ec1986 100644 --- a/lib/src/mcp/mcp_server.dart +++ b/lib/src/mcp/mcp_server.dart @@ -221,6 +221,13 @@ Only one value can be selected. '(e.g. // coverage:ignore-line). ' 'Only applies to Dart tests (dart: true).', ), + 'timeout_seconds': IntegerSchema( + description: + 'Maximum seconds to wait for the test run before killing ' + 'the Flutter test process. Flutter tests can hang ' + 'indefinitely when pumpAndSettle() is called without a ' + 'timeout argument. When omitted, no timeout is applied.', + ), }, ), ), @@ -384,6 +391,12 @@ Only one value can be selected. if (args['check_ignore'] == true) { cliArgs.add('--check-ignore'); } + if (args['timeout_seconds'] != null) { + cliArgs.addAll([ + '--timeout', + (args['timeout_seconds']! as num).toInt().toString(), + ]); + } return cliArgs; } diff --git a/site/docs/commands/test.md b/site/docs/commands/test.md index 2f42a9cf0..80a90e897 100644 --- a/site/docs/commands/test.md +++ b/site/docs/commands/test.md @@ -46,6 +46,8 @@ very_good test [arguments] --run-skipped Run skipped tests instead of skipping them. --flavor Build a custom app flavor as defined by platform-specific build setup. Supports the use of product flavors in Android Gradle scripts, and the use of custom Xcode schemes. --fail-fast Stop running tests after the first failure. + --timeout= Maximum seconds to let tests run before killing the process. + Useful when tests hang due to an unbounded pumpAndSettle() call. Run "very_good help" to see global options. ``` diff --git a/test/src/commands/test/test_test.dart b/test/src/commands/test/test_test.dart index dbace6f34..5e6e466ea 100644 --- a/test/src/commands/test/test_test.dart +++ b/test/src/commands/test/test_test.dart @@ -50,6 +50,7 @@ const expectedTestUsage = [ ' --report-on= Optional file paths to report coverage information to. This should be paths relative to the current working directory. Can be passed multiple times.\n' ' --run-skipped Run skipped tests instead of skipping them.\n' ' --flavor Build a custom app flavor as defined by platform-specific build setup. Supports the use of product flavors in Android Gradle scripts, and the use of custom Xcode schemes.\n' + ' --timeout= Maximum seconds to let tests run before killing the process. Useful when tests hang due to an unbounded pumpAndSettle() call.\n' '\n' 'Run "very_good help" to see global options.', ]; @@ -133,6 +134,7 @@ void main() { when(() => argResults['report-on']).thenReturn([]); when(() => argResults['run-skipped']).thenReturn(false); when(() => argResults['flavor']).thenReturn(null); + when(() => argResults['timeout']).thenReturn(null); when( () => argResults['collect-coverage-from'], ).thenReturn('imports'); @@ -583,6 +585,21 @@ void main() { ), ).called(1); }); + + test('completes normally --timeout 30', () async { + when(() => argResults['timeout']).thenReturn('30'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => flutterTest( + optimizePerformance: true, + arguments: [...defaultArguments, '--timeout=30s'], + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); }); group('coverage', () { diff --git a/test/src/mcp/mcp_server_test.dart b/test/src/mcp/mcp_server_test.dart index a8630348a..b121ade10 100644 --- a/test/src/mcp/mcp_server_test.dart +++ b/test/src/mcp/mcp_server_test.dart @@ -332,6 +332,7 @@ void main() { 'platform': 'chrome', 'run_skipped': true, 'check_ignore': true, + 'timeout_seconds': 60, }, ), ), @@ -367,6 +368,8 @@ void main() { 'chrome', '--run-skipped', '--check-ignore', + '--timeout', + '60', ]); }); @@ -420,6 +423,23 @@ void main() { contains('"test" failed with exit code'), ); }); + + test('passes --timeout when timeout_seconds is provided', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'test', + arguments: {'timeout_seconds': 120}, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, ['test', '--timeout', '120']); + }); }); group('Tool: packages_get', () {