From c1f4572be8e8ae1aef5654742169ffb4b2da8e58 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 13:06:46 +0800 Subject: [PATCH 1/8] feat(runtime): capture screenshots with Flutter CLI --- lib/src/project_run_command_executor.dart | 5 +- lib/src/runtime/pilot_runtime_adapter.dart | 174 +++++++++++++++++- lib/src/runtime/runtime_adapter_selector.dart | 1 + lib/src/runtime/runtime_contract.dart | 8 +- lib/src/target_app_launcher.dart | 29 ++- lib/src/test_command_executor.dart | 5 +- test/pilot_runtime_adapter_test.dart | 147 +++++++++++++++ test/target_app_launcher_test.dart | 7 + test/test_command_executor_test.dart | 7 + 9 files changed, 377 insertions(+), 6 deletions(-) diff --git a/lib/src/project_run_command_executor.dart b/lib/src/project_run_command_executor.dart index d759fe4..19a7271 100644 --- a/lib/src/project_run_command_executor.dart +++ b/lib/src/project_run_command_executor.dart @@ -199,7 +199,10 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { final TestScenarioRunner runner; try { runner = runnerFactory.create( - runtimeTarget: RuntimeTarget(vmServiceUri: launch.runtimeTargetUri), + runtimeTarget: RuntimeTarget( + vmServiceUri: launch.runtimeTargetUri, + deviceId: targetDevice?.id ?? launch.deviceId, + ), targetDevice: targetDevice, recordingController: scenarioFile.scenario.recording?.enabled == true diff --git a/lib/src/runtime/pilot_runtime_adapter.dart b/lib/src/runtime/pilot_runtime_adapter.dart index 1c07ea1..4ebd226 100644 --- a/lib/src/runtime/pilot_runtime_adapter.dart +++ b/lib/src/runtime/pilot_runtime_adapter.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:pilot_runtime/pilot_runtime.dart'; import '../scenario.dart'; @@ -13,13 +16,20 @@ class PilotRuntimeAdapter implements RuntimeAdapter { const PilotRuntimeAdapter({ required PilotRuntimeClient client, required String projectRoot, + String? targetDeviceId, + FlutterScreenshotCapturer screenshotCapturer = + const FlutterCliScreenshotCapturer(), Future Function()? disposeClient, }) : _client = client, _projectRoot = projectRoot, + _targetDeviceId = targetDeviceId, + _screenshotCapturer = screenshotCapturer, _disposeClient = disposeClient; final PilotRuntimeClient _client; final String _projectRoot; + final String? _targetDeviceId; + final FlutterScreenshotCapturer _screenshotCapturer; final Future Function()? _disposeClient; @override @@ -153,8 +163,28 @@ class PilotRuntimeAdapter implements RuntimeAdapter { } @override - Future captureScreenshot() { - throw _notImplemented(RuntimeOperation.captureScreenshot); + Future captureScreenshot() async { + final String? targetDeviceId = _targetDeviceId; + if (targetDeviceId == null || targetDeviceId.isEmpty) { + throw const RuntimeOperationException( + operation: RuntimeOperation.captureScreenshot, + message: + 'Screenshot capture requires a selected Target Device. Pass --device so Flutter Pilot can call flutter screenshot -d.', + ); + } + try { + final Uint8List bytes = await _screenshotCapturer.captureDeviceScreenshot( + deviceId: targetDeviceId, + ); + return ScreenshotCapture(bytes: bytes, mimeType: 'image/png'); + } on FlutterScreenshotException catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.captureScreenshot, + message: error.message, + cause: error, + rawOutput: error.diagnosticOutput, + ); + } } @override @@ -205,3 +235,143 @@ class PilotRuntimeAdapter implements RuntimeAdapter { return parts.join(' '); } } + +/// Captures one Screenshot from a Flutter Target Device. +/// +/// Implementations return encoded PNG bytes. The Runtime Adapter maps failures +/// into `RuntimeOperationException` so runner and report code can keep one +/// runtime failure shape. +abstract interface class FlutterScreenshotCapturer { + /// Run a Flutter device screenshot for the selected Target Device. + /// + /// Args: + /// `deviceId` is passed to `flutter screenshot -d`. + /// + /// Returns: + /// Encoded PNG bytes from the screenshot file produced by Flutter. + Future captureDeviceScreenshot({required String deviceId}); +} + +/// Flutter CLI-backed Screenshot capture implementation. +/// +/// It shells out to `flutter screenshot -d --out `, reads the +/// PNG bytes, and deletes the temporary directory afterwards. +class FlutterCliScreenshotCapturer implements FlutterScreenshotCapturer { + /// Create a Flutter CLI screenshot capturer. + /// + /// `processRunner` is injectable so tests can verify command construction + /// without starting a real Flutter process. + const FlutterCliScreenshotCapturer({ + this.processRunner = const IoFlutterScreenshotProcessRunner(), + }); + + final FlutterScreenshotProcessRunner processRunner; + + @override + Future captureDeviceScreenshot({required String deviceId}) async { + final Directory tempDirectory = await Directory.systemTemp.createTemp( + 'flutter_pilot_screenshot_', + ); + final File outputFile = File('${tempDirectory.path}/screenshot.png'); + try { + final FlutterScreenshotProcessResult result = await processRunner.run( + 'flutter', + ['screenshot', '-d', deviceId, '--out', outputFile.path], + ); + if (result.exitCode != 0) { + throw FlutterScreenshotException( + message: + 'Flutter screenshot failed for Target Device "$deviceId" with exit code ${result.exitCode}.', + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + ); + } + if (!outputFile.existsSync()) { + throw FlutterScreenshotException( + message: + 'Flutter screenshot did not create an output file for Target Device "$deviceId".', + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + ); + } + return Uint8List.fromList(outputFile.readAsBytesSync()); + } finally { + if (tempDirectory.existsSync()) { + tempDirectory.deleteSync(recursive: true); + } + } + } +} + +/// Process runner used by Flutter CLI screenshot capture. +abstract interface class FlutterScreenshotProcessRunner { + /// Run `executable` with `arguments` and return captured output. + Future run( + String executable, + List arguments, + ); +} + +/// `dart:io` process runner for Flutter screenshot commands. +class IoFlutterScreenshotProcessRunner + implements FlutterScreenshotProcessRunner { + /// Create the default process runner. + const IoFlutterScreenshotProcessRunner(); + + @override + Future run( + String executable, + List arguments, + ) async { + final ProcessResult result = await Process.run(executable, arguments); + return FlutterScreenshotProcessResult( + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + ); + } +} + +/// Captured result from one Flutter screenshot process. +class FlutterScreenshotProcessResult { + /// Create a completed screenshot process result. + const FlutterScreenshotProcessResult({ + required this.exitCode, + this.stdout = '', + this.stderr = '', + }); + + final int exitCode; + final String stdout; + final String stderr; +} + +/// Failure raised when `flutter screenshot` cannot produce a PNG file. +class FlutterScreenshotException implements Exception { + /// Create a screenshot failure with command output for diagnostics. + const FlutterScreenshotException({ + required this.message, + required this.exitCode, + this.stdout = '', + this.stderr = '', + }); + + final String message; + final int exitCode; + final String stdout; + final String stderr; + + /// Combined command output suitable for runtime diagnostic records. + String get diagnosticOutput { + final List parts = [ + if (stdout.isNotEmpty) stdout, + if (stderr.isNotEmpty) stderr, + ]; + return parts.join('\n'); + } + + @override + String toString() => message; +} diff --git a/lib/src/runtime/runtime_adapter_selector.dart b/lib/src/runtime/runtime_adapter_selector.dart index 89d7ace..49c524c 100644 --- a/lib/src/runtime/runtime_adapter_selector.dart +++ b/lib/src/runtime/runtime_adapter_selector.dart @@ -36,6 +36,7 @@ class RuntimeAdapterSelector { return PilotRuntimeAdapter( client: PilotRuntimeClient(vmService), projectRoot: projectRoot ?? Directory.current.path, + targetDeviceId: target.deviceId, disposeClient: vmService.dispose, ); } diff --git a/lib/src/runtime/runtime_contract.dart b/lib/src/runtime/runtime_contract.dart index 116a1e3..7cf8ac5 100644 --- a/lib/src/runtime/runtime_contract.dart +++ b/lib/src/runtime/runtime_contract.dart @@ -8,9 +8,15 @@ import '../scenario.dart'; /// option. The target is bound when creating the real runtime adapter, rather /// than passed to every adapter method. class RuntimeTarget { - const RuntimeTarget({required this.vmServiceUri}); + const RuntimeTarget({required this.vmServiceUri, this.deviceId}); final Uri vmServiceUri; + + /// Target Device id used for Flutter CLI operations that require `-d`. + /// + /// The id comes from an explicit `--device` selection when present, or from + /// Flutter machine output when `flutter run` reports the launched device. + final String? deviceId; } /// Boundary used by the runner to operate on a Flutter Runtime Target. diff --git a/lib/src/target_app_launcher.dart b/lib/src/target_app_launcher.dart index dd36484..de7fc25 100644 --- a/lib/src/target_app_launcher.dart +++ b/lib/src/target_app_launcher.dart @@ -58,6 +58,9 @@ class TargetAppLaunch { final _TargetAppLaunchState _state; final TargetAppProcess _process; + /// Target Device id reported by Flutter for the launched app, when known. + String? get deviceId => _state.deviceId; + /// Broadcast stream of Flutter machine stdout lines after launch succeeds. final Stream stdoutLines; int _nextRequestId = 0; @@ -229,7 +232,8 @@ class TargetAppLauncher { final StreamController stdoutLinesController = StreamController.broadcast(); final _LastLinesBuffer stderrBuffer = _LastLinesBuffer(limit: 40); - final _TargetAppLaunchState state = _TargetAppLaunchState(); + final _TargetAppLaunchState state = _TargetAppLaunchState() + ..deviceId = command.deviceId; final StreamSubscription stderrSub = process.stderr .transform(utf8.decoder) .transform(const LineSplitter()) @@ -248,6 +252,7 @@ class TargetAppLauncher { (String line) { stdoutLinesController.add(line); state.appId ??= _appIdFromMachineLine(line); + state.deviceId ??= _deviceIdFromMachineLine(line); final Uri? runtimeTargetUri = _runtimeTargetUriFromMachineLine( line, ); @@ -336,6 +341,27 @@ class TargetAppLauncher { return null; } + /// Extract the launched Target Device id from one Flutter machine stdout line. + static String? _deviceIdFromMachineLine(String line) { + final Object? decoded; + try { + decoded = jsonDecode(line); + } on FormatException { + return null; + } + for (final Map event in _machineEvents(decoded)) { + final Object? params = event['params']; + if (params is! Map) { + continue; + } + final Object? deviceId = params['deviceId']; + if (deviceId is String && deviceId.isNotEmpty) { + return deviceId; + } + } + return null; + } + /// Extract a daemon command response for [requestId] from stdout. static _MachineCommandResponse? _machineCommandResponseFromLine( String line, @@ -387,6 +413,7 @@ class TargetAppLauncher { class _TargetAppLaunchState { String? appId; + String? deviceId; } class _MachineCommandResponse { diff --git a/lib/src/test_command_executor.dart b/lib/src/test_command_executor.dart index 24ea09b..c90d21b 100644 --- a/lib/src/test_command_executor.dart +++ b/lib/src/test_command_executor.dart @@ -157,7 +157,10 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { final TestScenarioRunner runner; try { runner = runnerFactory.create( - runtimeTarget: RuntimeTarget(vmServiceUri: launch.runtimeTargetUri), + runtimeTarget: RuntimeTarget( + vmServiceUri: launch.runtimeTargetUri, + deviceId: targetDevice?.id ?? launch.deviceId, + ), targetDevice: targetDevice, recordingController: recordingRequired ? ScreenRecorderRecordingController( diff --git a/test/pilot_runtime_adapter_test.dart b/test/pilot_runtime_adapter_test.dart index e379d9c..b4b7f06 100644 --- a/test/pilot_runtime_adapter_test.dart +++ b/test/pilot_runtime_adapter_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:pilot_runtime/pilot_runtime.dart'; import 'package:test/test.dart'; @@ -293,6 +296,150 @@ void main() { ), ); }); + + test( + 'captures Flutter device screenshot bytes for the selected device', + () async { + final _FakeFlutterScreenshotCapturer screenshotCapturer = + _FakeFlutterScreenshotCapturer( + bytes: Uint8List.fromList([137, 80, 78, 71]), + ); + final PilotRuntimeAdapter adapter = PilotRuntimeAdapter( + client: _FakePilotRuntimeClient(), + projectRoot: '/target/app', + targetDeviceId: 'pixel-8', + screenshotCapturer: screenshotCapturer, + ); + + final ScreenshotCapture capture = await adapter.captureScreenshot(); + + expect(screenshotCapturer.deviceIds, ['pixel-8']); + expect(capture.mimeType, 'image/png'); + expect(capture.bytes, [137, 80, 78, 71]); + }, + ); + + test('maps Flutter screenshot failures to capture failures', () async { + final PilotRuntimeAdapter adapter = PilotRuntimeAdapter( + client: _FakePilotRuntimeClient(), + projectRoot: '/target/app', + targetDeviceId: 'pixel-8', + screenshotCapturer: _FakeFlutterScreenshotCapturer( + failure: const FlutterScreenshotException( + message: 'Flutter screenshot failed.', + exitCode: 1, + stderr: 'No device found.', + ), + ), + ); + + await expectLater( + adapter.captureScreenshot(), + throwsA( + isA() + .having( + (RuntimeOperationException error) => error.operation, + 'operation', + RuntimeOperation.captureScreenshot, + ) + .having( + (RuntimeOperationException error) => error.message, + 'message', + contains('Flutter screenshot failed.'), + ) + .having( + (RuntimeOperationException error) => error.rawOutput, + 'rawOutput', + contains('No device found.'), + ), + ), + ); + }); + + test('requires a selected Target Device for Flutter screenshots', () async { + final PilotRuntimeAdapter adapter = PilotRuntimeAdapter( + client: _FakePilotRuntimeClient(), + projectRoot: '/target/app', + ); + + await expectLater( + adapter.captureScreenshot(), + throwsA( + isA() + .having( + (RuntimeOperationException error) => error.operation, + 'operation', + RuntimeOperation.captureScreenshot, + ) + .having( + (RuntimeOperationException error) => error.message, + 'message', + contains('Pass --device'), + ), + ), + ); + }); + + test('runs flutter screenshot for the selected device', () async { + final _FakeFlutterScreenshotProcessRunner processRunner = + _FakeFlutterScreenshotProcessRunner(); + final FlutterCliScreenshotCapturer capturer = FlutterCliScreenshotCapturer( + processRunner: processRunner, + ); + + final Uint8List bytes = await capturer.captureDeviceScreenshot( + deviceId: 'pixel-8', + ); + + expect(processRunner.executable, 'flutter'); + expect(processRunner.arguments.take(3), [ + 'screenshot', + '-d', + 'pixel-8', + ]); + expect(processRunner.arguments, contains('--out')); + expect(bytes, [137, 80, 78, 71]); + }); +} + +class _FakeFlutterScreenshotCapturer implements FlutterScreenshotCapturer { + _FakeFlutterScreenshotCapturer({Uint8List? bytes, this.failure}) + : bytes = bytes ?? Uint8List(0); + + final Uint8List bytes; + final FlutterScreenshotException? failure; + final List deviceIds = []; + + @override + Future captureDeviceScreenshot({required String deviceId}) async { + deviceIds.add(deviceId); + final FlutterScreenshotException? currentFailure = failure; + if (currentFailure != null) { + throw currentFailure; + } + return bytes; + } +} + +class _FakeFlutterScreenshotProcessRunner + implements FlutterScreenshotProcessRunner { + String? executable; + List arguments = []; + + @override + Future run( + String executable, + List arguments, + ) async { + this.executable = executable; + this.arguments = arguments; + final int outputIndex = arguments.indexOf('--out') + 1; + final String outputPath = arguments[outputIndex]; + File(outputPath) + ..parent.createSync(recursive: true) + ..writeAsBytesSync([137, 80, 78, 71]); + return const FlutterScreenshotProcessResult(exitCode: 0); + } } class _FakePilotRuntimeClient implements PilotRuntimeClient { diff --git a/test/target_app_launcher_test.dart b/test/target_app_launcher_test.dart index 9d70bd8..f52f917 100644 --- a/test/target_app_launcher_test.dart +++ b/test/target_app_launcher_test.dart @@ -43,6 +43,12 @@ void main() { const TargetAppLaunchCommand(), ); + process.emitStdout( + jsonEncode({ + 'event': 'app.started', + 'params': {'appId': 'app-1', 'deviceId': 'pixel-8'}, + }), + ); process.emitStdout( jsonEncode({ 'event': 'app.debugPort', @@ -59,6 +65,7 @@ void main() { launch.runtimeTargetUri.toString(), 'ws://127.0.0.1:1234/token=/ws', ); + expect(launch.deviceId, 'pixel-8'); expect(starter.startedArguments, ['run', '--machine']); expect(starter.forwardedStdout, isEmpty); }); diff --git a/test/test_command_executor_test.dart b/test/test_command_executor_test.dart index b095c03..9656cc7 100644 --- a/test/test_command_executor_test.dart +++ b/test/test_command_executor_test.dart @@ -740,6 +740,12 @@ steps: jsonOutput: false, ), ); + process.emitStdout( + jsonEncode({ + 'event': 'app.started', + 'params': {'appId': 'app-1', 'deviceId': 'pixel-8'}, + }), + ); process.emitStdout( jsonEncode({ 'event': 'app.debugPort', @@ -763,6 +769,7 @@ steps: runner.runtimeTarget.vmServiceUri.toString(), 'ws://127.0.0.1:1234/token=/ws', ); + expect(runner.runtimeTarget.deviceId, 'pixel-8'); expect(runner.scenario.name, 'launched'); expect(process.stdinWrites, ['q\n']); }); From c3b0021e1a38b33871f4118ee9bbeb87631e7226 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 13:09:14 +0800 Subject: [PATCH 2/8] docs(runtime): update screenshot capture issue --- issues/0.1.1-pilot-runtime.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/issues/0.1.1-pilot-runtime.md b/issues/0.1.1-pilot-runtime.md index 6608339..335b9ab 100644 --- a/issues/0.1.1-pilot-runtime.md +++ b/issues/0.1.1-pilot-runtime.md @@ -301,7 +301,9 @@ Verification: - 6. Implement byKey and byWidget Finder resolution -## 10. Implement Flutter-layer screenshot capture +## 10. Implement Flutter CLI device screenshot capture + +Status: completed in `c1f4572 feat(runtime): capture screenshots with Flutter CLI`. ## Parent @@ -309,15 +311,29 @@ https://github.com/drown0315/flutter_pilot/issues/110 ## What to build -Implement screenshot capture for `pilot_runtime` using a Flutter-layer or Flutter Inspector screenshot path. The result should map through `PilotRuntimeAdapter` into Flutter Pilot's existing screenshot artifact flow without using device screenshots. +Implement screenshot capture for the `pilot_runtime` adapter path using +`flutter screenshot -d `. The result should map through +`PilotRuntimeAdapter` into Flutter Pilot's existing screenshot artifact flow +as PNG bytes. + +Decision note: this issue originally preferred a Flutter-layer or Inspector +screenshot path and explicitly avoided device screenshots. The implementation +uses the Flutter CLI device screenshot path by product decision for this slice. ## Acceptance criteria -- [ ] `pilot_runtime` exposes screenshot capture returning image bytes and MIME type. -- [ ] `PilotRuntimeAdapter.captureScreenshot()` maps the runtime result to Flutter Pilot `ScreenshotCapture`. -- [ ] The implementation uses a Flutter-layer or Inspector screenshot path, not a device screenshot. -- [ ] Screenshot capture failures map to Runtime Adapter operation failures. -- [ ] Tests cover success bytes/MIME mapping and failure mapping. +- [x] `PilotRuntimeAdapter.captureScreenshot()` returns Flutter Pilot `ScreenshotCapture` with PNG bytes and MIME type. +- [x] The implementation shells out to `flutter screenshot -d --out `. +- [x] The Target Device id is passed from explicit Target Device selection or Flutter machine launch output into the Runtime Target. +- [x] Screenshot capture failures map to Runtime Adapter operation failures. +- [x] Missing Target Device id fails clearly and asks the user to pass `--device`. +- [x] Tests cover command construction, success bytes/MIME mapping, failure mapping, missing device handling, and launch device id propagation. + +Verification: + +- `dart format .` +- `dart analyze` +- `dart test` ## Blocked by From 625de527279096bf5b25b4b833b199d0b442b4b2 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 13:39:57 +0800 Subject: [PATCH 3/8] refactor(cli): rename Project Run executor types --- lib/src/cli.dart | 9 +- lib/src/commands/test_command.dart | 8 +- ...xecutor.dart => project_run_executor.dart} | 18 +- lib/src/test_command_models.dart | 12 +- lib/src/test_command_output.dart | 2 +- lib/src/test_command_support.dart | 2 +- test/test_command_executor_test.dart | 316 +++++++++--------- 7 files changed, 180 insertions(+), 187 deletions(-) rename lib/src/{project_run_command_executor.dart => project_run_executor.dart} (97%) diff --git a/lib/src/cli.dart b/lib/src/cli.dart index 8748946..3019447 100644 --- a/lib/src/cli.dart +++ b/lib/src/cli.dart @@ -25,13 +25,12 @@ class FlutterPilotCli { const FlutterPilotCli({ TestCommandExecutor testCommandExecutor = const DefaultTestCommandExecutor(), - ProjectRunCommandExecutor projectRunCommandExecutor = - const DefaultProjectRunCommandExecutor(), + ProjectRunExecutor projectRunExecutor = const DefaultProjectRunExecutor(), }) : _testCommandExecutor = testCommandExecutor, - _projectRunCommandExecutor = projectRunCommandExecutor; + _projectRunExecutor = projectRunExecutor; final TestCommandExecutor _testCommandExecutor; - final ProjectRunCommandExecutor _projectRunCommandExecutor; + final ProjectRunExecutor _projectRunExecutor; /// Run the CLI with already-tokenized command-line arguments. /// @@ -51,7 +50,7 @@ class FlutterPilotCli { ..addCommand( TestCommand( executor: _testCommandExecutor, - projectRunExecutor: _projectRunCommandExecutor, + projectRunExecutor: _projectRunExecutor, ), ) ..addCommand(ReportCommand()) diff --git a/lib/src/commands/test_command.dart b/lib/src/commands/test_command.dart index 0045061..8e4ee6a 100644 --- a/lib/src/commands/test_command.dart +++ b/lib/src/commands/test_command.dart @@ -18,7 +18,7 @@ import '../test_command_support.dart'; class TestCommand extends Command { TestCommand({ required TestCommandExecutor executor, - required ProjectRunCommandExecutor projectRunExecutor, + required ProjectRunExecutor projectRunExecutor, }) : _executor = executor, _projectRunExecutor = projectRunExecutor { argParser @@ -50,7 +50,7 @@ class TestCommand extends Command { } final TestCommandExecutor _executor; - final ProjectRunCommandExecutor _projectRunExecutor; + final ProjectRunExecutor _projectRunExecutor; @override String get description => 'Launch the Target App Package and run a Scenario.'; @@ -216,7 +216,7 @@ class TestCommand extends Command { _writeValidationErrors(error.errors); return 1; } - final ProjectRunCommandOptions options = ProjectRunCommandOptions( + final ProjectRunOptions options = ProjectRunOptions( discoveryRootPath: discoveryRootPath, scenarios: scenarios, device: device, @@ -237,7 +237,7 @@ class TestCommand extends Command { jsonOutput: options.jsonOutput, stderrHasTerminal: stderr.hasTerminal, ); - final ProjectRunCommandReport report = await _projectRunExecutor.run( + final ProjectRunResult report = await _projectRunExecutor.run( options, onLaunchProgress: launchProgressRenderer?.render, launchHeartbeatEnabled: launchProgressRenderer != null, diff --git a/lib/src/project_run_command_executor.dart b/lib/src/project_run_executor.dart similarity index 97% rename from lib/src/project_run_command_executor.dart rename to lib/src/project_run_executor.dart index 19a7271..54751c7 100644 --- a/lib/src/project_run_command_executor.dart +++ b/lib/src/project_run_executor.dart @@ -20,13 +20,13 @@ import 'test_scenario_runner_factory.dart'; import 'test_target_device_selection.dart'; /// Executes a validated Project Run selected by `test`. -abstract interface class ProjectRunCommandExecutor { +abstract interface class ProjectRunExecutor { /// Run the Project Scenarios described by [options]. /// /// `onLaunchProgress`, when provided, receives the batch-level Target App /// Launch Progress events before any Project Scenario executes. - Future run( - ProjectRunCommandOptions options, { + Future run( + ProjectRunOptions options, { void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, bool launchHeartbeatEnabled = false, void Function(StepProgressEvent event)? onProgress, @@ -34,8 +34,8 @@ abstract interface class ProjectRunCommandExecutor { } /// Default Project Run executor for launch reuse and batch Scenario execution. -class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { - const DefaultProjectRunCommandExecutor({ +class DefaultProjectRunExecutor implements ProjectRunExecutor { + const DefaultProjectRunExecutor({ this.deviceDiscovery = const DefaultTestDeviceDiscovery(), this.launcher = const TargetAppLauncher(), this.runnerFactory = const DefaultTestScenarioRunnerFactory(), @@ -67,8 +67,8 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { final Stream? launchHeartbeatTicks; @override - Future run( - ProjectRunCommandOptions options, { + Future run( + ProjectRunOptions options, { void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, bool launchHeartbeatEnabled = false, void Function(StepProgressEvent event)? onProgress, @@ -127,7 +127,7 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { scenarioResults: scenarioResults, ); projectRunWriter.writeProjectRunReport(projectReport.toJson()); - return ProjectRunCommandReport( + return ProjectRunResult( passed: false, status: ProjectRunStatus.environmentFailed, projectRunReportPath: p.join( @@ -311,7 +311,7 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { scenarioResults: scenarioResults, ); projectRunWriter.writeProjectRunReport(projectReport.toJson()); - return ProjectRunCommandReport( + return ProjectRunResult( passed: projectStatus == ProjectRunStatus.passed, status: projectStatus, projectRunReportPath: p.join( diff --git a/lib/src/test_command_models.dart b/lib/src/test_command_models.dart index 303b1c2..8673921 100644 --- a/lib/src/test_command_models.dart +++ b/lib/src/test_command_models.dart @@ -26,9 +26,9 @@ class TestCommandOptions { } /// Parsed Project Run inputs selected by the `test` command. -class ProjectRunCommandOptions { - /// Creates validated Project Run command inputs. - const ProjectRunCommandOptions({ +class ProjectRunOptions { + /// Creates validated Project Run inputs. + const ProjectRunOptions({ required this.discoveryRootPath, required this.scenarios, required this.device, @@ -49,9 +49,9 @@ class ProjectRunCommandOptions { final bool jsonOutput; } -/// Project Run command result used by mode selection and stdout rendering. -class ProjectRunCommandReport { - const ProjectRunCommandReport({ +/// Project Run result used by mode selection and stdout rendering. +class ProjectRunResult { + const ProjectRunResult({ required this.passed, required this.status, required this.projectRunReportPath, diff --git a/lib/src/test_command_output.dart b/lib/src/test_command_output.dart index 5946056..508979d 100644 --- a/lib/src/test_command_output.dart +++ b/lib/src/test_command_output.dart @@ -46,7 +46,7 @@ class TestCommandOutput { /// /// The summary keeps the batch-level report path first, then prints each /// Scenario's existing report paths in execution order. - static String renderProjectRunSummary(ProjectRunCommandReport report) { + static String renderProjectRunSummary(ProjectRunResult report) { final StringBuffer buffer = StringBuffer() ..writeln('Project Run: ${report.status.name}') ..writeln('Project Run report: ${report.projectRunReportPath}'); diff --git a/lib/src/test_command_support.dart b/lib/src/test_command_support.dart index 3d61db8..d438dd9 100644 --- a/lib/src/test_command_support.dart +++ b/lib/src/test_command_support.dart @@ -5,7 +5,7 @@ /// implementations live in focused files. library; -export 'project_run_command_executor.dart'; +export 'project_run_executor.dart'; export 'test_command_executor.dart'; export 'test_command_models.dart'; export 'test_command_output.dart'; diff --git a/test/test_command_executor_test.dart b/test/test_command_executor_test.dart index 9656cc7..85a7dea 100644 --- a/test/test_command_executor_test.dart +++ b/test/test_command_executor_test.dart @@ -22,11 +22,12 @@ scenario: steps: - capture: {} '''); - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test']); expect(exitCode, 0); @@ -51,11 +52,12 @@ scenario: steps: - capture: {} '''); - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test', 'flows']); expect(exitCode, 0); @@ -71,7 +73,7 @@ steps: ); test('Project Run output renders deterministic all-passed summary', () { - const ProjectRunCommandReport report = ProjectRunCommandReport( + const ProjectRunResult report = ProjectRunResult( passed: true, status: ProjectRunStatus.passed, projectRunReportPath: @@ -115,7 +117,7 @@ HTML report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_login/timeline. }); test('Project Run output renders partially failed summary', () { - const ProjectRunCommandReport report = ProjectRunCommandReport( + const ProjectRunResult report = ProjectRunResult( passed: false, status: ProjectRunStatus.failed, projectRunReportPath: '.runs/project-run/project_run_report.json', @@ -143,7 +145,7 @@ HTML report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_login/timeline. }); test('Project Run output renders environment-level failure summary', () { - const ProjectRunCommandReport report = ProjectRunCommandReport( + const ProjectRunResult report = ProjectRunResult( passed: false, status: ProjectRunStatus.environmentFailed, projectRunReportPath: '.runs/project-run/project_run_report.json', @@ -168,12 +170,13 @@ steps: final FakeTestCommandExecutor executor = FakeTestCommandExecutor( report: _passedReport(), ); - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( testCommandExecutor: executor, - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test', scenarioFile.path]); expect(exitCode, 0); @@ -228,11 +231,12 @@ steps: steps: - capture: {} '''); - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test']); expect(exitCode, 64); @@ -343,11 +347,12 @@ steps: ]; for (final List optionCase in optionCases) { - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test', ...optionCase]); expect(exitCode, 64); @@ -391,11 +396,12 @@ scenario: steps: - capture: {} '''); - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test']); expect(exitCode, 0); @@ -440,11 +446,12 @@ scenario: steps: - capture: {} '''); - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test']); expect(exitCode, 0); @@ -465,11 +472,12 @@ scenario: steps: - capture: {} '''); - final FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: _passedProjectReport(), + ); final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: projectExecutor, + projectRunExecutor: projectExecutor, ).run(['test', '--json']); expect(exitCode, 0); @@ -1696,16 +1704,15 @@ steps: final FakeScenarioRunner secondRunner = FakeScenarioRunner( _passedReportFor('checkout'), ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - firstRunner, - secondRunner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); final List scenarios = [ ProjectScenarioFile( path: 'pilot/login.yaml', @@ -1719,8 +1726,8 @@ steps: ), ]; - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: null, @@ -1746,7 +1753,7 @@ steps: jsonEncode({'id': 0, 'result': true}), ); - final ProjectRunCommandReport report = await reportFuture; + final ProjectRunResult report = await reportFuture; expect(report.passed, isTrue); expect(starter.startCount, 1); @@ -1801,16 +1808,15 @@ steps: ), ], ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + runner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); final List scenarios = [ ProjectScenarioFile( path: 'pilot/login.yaml', @@ -1819,8 +1825,8 @@ steps: ), ]; - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: 'Pixel', @@ -1845,7 +1851,7 @@ steps: jsonEncode({'id': 0, 'result': true}), ); - final ProjectRunCommandReport report = await reportFuture; + final ProjectRunResult report = await reportFuture; expect(report.passed, isTrue); expect(deviceDiscovery.flutterDeviceListCount, 1); @@ -1890,16 +1896,15 @@ steps: RecordingDeviceIdentity(id: 'pixel-8'), ], ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + runner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); final List launchEvents = []; final List scenarios = [ @@ -1924,8 +1929,8 @@ steps: ), ]; - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: null, @@ -1944,7 +1949,7 @@ steps: }), ); - final ProjectRunCommandReport report = await reportFuture; + final ProjectRunResult report = await reportFuture; expect(report.passed, isTrue); expect(deviceDiscovery.flutterDeviceListCount, 1); @@ -2000,16 +2005,15 @@ steps: const RecordingDeviceIdentity(id: 'iphone-15'), ], ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - FakeScenarioRunner(_passedReportFor('recorded')), - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + FakeScenarioRunner(_passedReportFor('recorded')), + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); final List scenarios = [ ProjectScenarioFile( path: 'pilot/recorded.yaml', @@ -2032,8 +2036,8 @@ steps: ), ]; - final ProjectRunCommandReport report = await executor.run( - ProjectRunCommandOptions( + final ProjectRunResult report = await executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: null, @@ -2073,19 +2077,18 @@ steps: 'flutter devices failed', ), ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - FakeScenarioRunner(_passedReportFor('login')), - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - - final ProjectRunCommandReport report = await executor.run( - ProjectRunCommandOptions( + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + FakeScenarioRunner(_passedReportFor('login')), + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + + final ProjectRunResult report = await executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: [ ProjectScenarioFile( @@ -2124,15 +2127,12 @@ steps: final FakeScenarioRunner runner = FakeScenarioRunner( _passedReportFor('login'), ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([runner]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); final List launchEvents = []; final List scenarios = [ @@ -2143,8 +2143,8 @@ steps: ), ]; - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: null, @@ -2161,7 +2161,7 @@ steps: }), ); - final ProjectRunCommandReport report = await reportFuture; + final ProjectRunResult report = await reportFuture; expect(report.passed, isTrue); expect( @@ -2184,19 +2184,16 @@ steps: final HangingScenarioRunner runner = HangingScenarioRunner(); final StreamController interruptController = StreamController(); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - interruptSignals: interruptController.stream, - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([runner]), + interruptSignals: interruptController.stream, + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: [ ProjectScenarioFile( @@ -2257,16 +2254,15 @@ steps: ); final StreamController ticks = StreamController(); DateTime now = DateTime.utc(2026, 7, 1, 9, 30); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - outputDirectory: Directory.current, - clock: () => now, - launchHeartbeatTicks: ticks.stream, - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + runner, + ]), + outputDirectory: Directory.current, + clock: () => now, + launchHeartbeatTicks: ticks.stream, + ); final List launchEvents = []; final List scenarios = [ @@ -2277,8 +2273,8 @@ steps: ), ]; - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: null, @@ -2331,16 +2327,15 @@ steps: final FakeScenarioRunner secondRunner = FakeScenarioRunner( _passedReportFor('checkout'), ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - firstRunner, - secondRunner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); final List scenarios = [ ProjectScenarioFile( path: 'pilot/login.yaml', @@ -2354,8 +2349,8 @@ steps: ), ]; - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: null, @@ -2380,7 +2375,7 @@ steps: jsonEncode({'id': 0, 'result': true}), ); - final ProjectRunCommandReport report = await reportFuture; + final ProjectRunResult report = await reportFuture; expect(report.passed, isFalse); expect(starter.startCount, 1); @@ -2408,16 +2403,15 @@ steps: final FakeScenarioRunner secondRunner = FakeScenarioRunner( _passedReportFor('checkout'), ); - final DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - firstRunner, - secondRunner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); final List scenarios = [ ProjectScenarioFile( path: 'pilot/login.yaml', @@ -2431,8 +2425,8 @@ steps: ), ]; - final Future reportFuture = executor.run( - ProjectRunCommandOptions( + final Future reportFuture = executor.run( + ProjectRunOptions( discoveryRootPath: 'pilot', scenarios: scenarios, device: null, @@ -2451,7 +2445,7 @@ steps: }), ); - final ProjectRunCommandReport report = await reportFuture; + final ProjectRunResult report = await reportFuture; expect(report.passed, isFalse); expect(starter.startCount, 1); @@ -2491,19 +2485,19 @@ class FakeTestCommandExecutor implements TestCommandExecutor { } } -class FakeProjectRunCommandExecutor implements ProjectRunCommandExecutor { - FakeProjectRunCommandExecutor({required this.report}); +class FakeProjectRunExecutor implements ProjectRunExecutor { + FakeProjectRunExecutor({required this.report}); - final ProjectRunCommandReport report; - late ProjectRunCommandOptions options; + final ProjectRunResult report; + late ProjectRunOptions options; bool ran = false; void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress; bool? launchHeartbeatEnabled; void Function(StepProgressEvent event)? onProgress; @override - Future run( - ProjectRunCommandOptions options, { + Future run( + ProjectRunOptions options, { void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, bool launchHeartbeatEnabled = false, void Function(StepProgressEvent event)? onProgress, @@ -2517,8 +2511,8 @@ class FakeProjectRunCommandExecutor implements ProjectRunCommandExecutor { } } -ProjectRunCommandReport _passedProjectReport() { - return const ProjectRunCommandReport( +ProjectRunResult _passedProjectReport() { + return const ProjectRunResult( passed: true, status: ProjectRunStatus.passed, projectRunReportPath: '.runs/project-run/project_run_report.json', From d1644f4779e17251c79b34f1cc822ed5ec2e41fb Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 14:55:23 +0800 Subject: [PATCH 4/8] refactor(cli): share test execution session lifecycle --- lib/src/project_run_executor.dart | 185 ++++------------- lib/src/test_command_executor.dart | 153 +++----------- lib/src/test_command_support.dart | 1 + lib/src/test_execution_session.dart | 282 ++++++++++++++++++++++++++ test/test_command_executor_test.dart | 172 ++++++++++++++++ test/test_execution_session_test.dart | 136 +++++++++++++ 6 files changed, 660 insertions(+), 269 deletions(-) create mode 100644 lib/src/test_execution_session.dart create mode 100644 test/test_execution_session_test.dart diff --git a/lib/src/project_run_executor.dart b/lib/src/project_run_executor.dart index 54751c7..78bad37 100644 --- a/lib/src/project_run_executor.dart +++ b/lib/src/project_run_executor.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:path/path.dart' as p; @@ -9,15 +8,13 @@ import 'project_scenario_discovery.dart'; import 'project_run_report.dart'; import 'recording/screen_recorder_recording_controller.dart'; import 'runtime/runtime_adapter_selector.dart'; -import 'runtime/runtime_contract.dart'; import 'scenario_runner.dart'; import 'target_app_launch_progress_renderer.dart'; import 'target_app_launcher.dart'; -import 'target_device.dart'; import 'test_command_models.dart'; import 'test_device_discovery.dart'; +import 'test_execution_session.dart'; import 'test_scenario_runner_factory.dart'; -import 'test_target_device_selection.dart'; /// Executes a validated Project Run selected by `test`. abstract interface class ProjectRunExecutor { @@ -38,12 +35,13 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { const DefaultProjectRunExecutor({ this.deviceDiscovery = const DefaultTestDeviceDiscovery(), this.launcher = const TargetAppLauncher(), + TestExecutionSessionFactory? sessionFactory, this.runnerFactory = const DefaultTestScenarioRunnerFactory(), this.interruptSignals, this.outputDirectory, this.clock = DateTime.now, this.launchHeartbeatTicks, - }); + }) : _sessionFactory = sessionFactory; /// Discovers Flutter and Recording Devices before app launch when needed. final TestDeviceDiscovery deviceDiscovery; @@ -51,6 +49,9 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { /// Starts the Target App Package and exposes hot restart. final TargetAppLauncher launcher; + /// Starts the shared Test Execution Session for this Project Run. + final TestExecutionSessionFactory? _sessionFactory; + /// Creates Scenario runners for each selected Project Scenario. final TestScenarioRunnerFactory runnerFactory; @@ -81,110 +82,25 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { final List scenarioResults = []; ProjectRunEnvironmentFailure? environmentFailure; - TargetAppLaunch? launch; - TargetDevice? targetDevice; final bool recordingRequired = options.scenarios.any( (ProjectScenarioFile file) => file.scenario.recording?.enabled == true, ); - if (options.device != null || recordingRequired) { - try { - final List flutterDevices = await deviceDiscovery - .listFlutterDevices(); - final List recordingDevices = recordingRequired - ? await deviceDiscovery.listRecordingDevices() - : const []; - targetDevice = TargetDeviceResolver.resolve( - selector: options.device, - recordingRequired: recordingRequired, - flutterDevices: flutterDevices, - recordingDevices: recordingDevices, - ); - } on TargetDeviceResolutionException catch (error) { - environmentFailure = ProjectRunEnvironmentFailure( - phase: ProjectRunEnvironmentFailurePhase.targetDeviceResolution, - message: error.message, - ); - } on DeviceDiscoveryException catch (error) { - environmentFailure = ProjectRunEnvironmentFailure( - phase: ProjectRunEnvironmentFailurePhase.targetDeviceResolution, - message: error.message, - ); - } - } - if (environmentFailure != null) { - stopwatch.stop(); - final ProjectRunReport projectReport = - ProjectRunReport.environmentFailure( - discoveryRootPath: options.discoveryRootPath, - startedAt: startedAt, - durationMs: stopwatch.elapsedMilliseconds, - commandInputs: ProjectRunCommandInputs( - device: options.device, - flavor: options.flavor, - target: options.target, - ), - failure: environmentFailure, - scenarioResults: scenarioResults, - ); - projectRunWriter.writeProjectRunReport(projectReport.toJson()); - return ProjectRunResult( - passed: false, - status: ProjectRunStatus.environmentFailed, - projectRunReportPath: p.join( - projectRunWriter.runDirectory.path, - 'project_run_report.json', - ), - scenarioReports: const [], - ); - } - final TargetAppLaunchChoices launchChoices = TargetAppLaunchChoices( - targetDevice: targetDevice, - selectionReason: targetDeviceSelectionReason( + TestExecutionSession? session; + try { + session = await _effectiveSessionFactory.start( deviceSelector: options.device, + flavor: options.flavor, + target: options.target, recordingRequired: recordingRequired, - ), - flavor: options.flavor, - target: options.target, - ); - final TargetAppLaunchStartedEvent launchStartedEvent = - TargetAppLaunchStartedEvent( - startedAt: startedAt, - choices: launchChoices, - ); - onLaunchProgress?.call(launchStartedEvent); - TargetAppLaunchHeartbeat? launchHeartbeat; - if (onLaunchProgress != null && launchHeartbeatEnabled) { - launchHeartbeat = TargetAppLaunchHeartbeat( - ticks: - launchHeartbeatTicks ?? - Stream.periodic(const Duration(seconds: 10)), - onProgress: onLaunchProgress, - clock: clock, + launchHeartbeatEnabled: launchHeartbeatEnabled, + onLaunchProgress: onLaunchProgress, ); - launchHeartbeat.start(launchStartedEvent); - } - try { - launch = await launcher.launch( - TargetAppLaunchCommand( - flavor: options.flavor, - target: options.target, - deviceId: targetDevice?.id, - ), - ); - onLaunchProgress?.call( - TargetAppLaunchSucceededEvent( - startedAt: startedAt, - finishedAt: clock().toUtc(), - choices: launchChoices, - ), - ); - await launchHeartbeat?.stop(); for (int index = 0; index < options.scenarios.length; index++) { final ProjectScenarioFile scenarioFile = options.scenarios[index]; if (index > 0) { try { - await launch.hotRestart(); - } on TargetAppLaunchException catch (error) { + await session.hotRestart(); + } on TestExecutionLaunchException catch (error) { environmentFailure = ProjectRunEnvironmentFailure( phase: ProjectRunEnvironmentFailurePhase.hotRestart, message: error.message, @@ -199,16 +115,13 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { final TestScenarioRunner runner; try { runner = runnerFactory.create( - runtimeTarget: RuntimeTarget( - vmServiceUri: launch.runtimeTargetUri, - deviceId: targetDevice?.id ?? launch.deviceId, - ), - targetDevice: targetDevice, + runtimeTarget: session.runtimeTarget, + targetDevice: session.targetDevice, recordingController: scenarioFile.scenario.recording?.enabled == true ? ScreenRecorderRecordingController( recorder: screen_recorder.ScreenRecorder.defaultRecorder(), - deviceSelector: targetDevice!.id, + deviceSelector: session.targetDevice!.id, outputDirectory: Directory.current, ) : null, @@ -225,8 +138,9 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { runArtifactWriter: childRun, onProgress: onProgress, ); - final ScenarioRunReport scenarioReport = - await _runScenarioWithInterrupt(runFuture); + final ScenarioRunReport scenarioReport = await session.runWithInterrupt( + runFuture, + ); final ProjectScenarioRunStatus scenarioStatus = scenarioReport.status == ScenarioRunStatus.passed ? ProjectScenarioRunStatus.passed @@ -246,21 +160,16 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { ), ); } - } on TargetAppLaunchException catch (error) { + } on TestExecutionTargetDeviceException catch (error) { environmentFailure = ProjectRunEnvironmentFailure( - phase: ProjectRunEnvironmentFailurePhase.launch, + phase: ProjectRunEnvironmentFailurePhase.targetDeviceResolution, message: error.message, ); - onLaunchProgress?.call( - TargetAppLaunchFailedEvent( - startedAt: startedAt, - failedAt: clock().toUtc(), - message: error.message, - stderrLines: error.stderrLines, - choices: launchChoices, - ), + } on TestExecutionLaunchException catch (error) { + environmentFailure = ProjectRunEnvironmentFailure( + phase: ProjectRunEnvironmentFailurePhase.launch, + message: error.message, ); - await launchHeartbeat?.stop(); } on TestCommandException catch (error) { if (error.exitCode == 130) { rethrow; @@ -271,8 +180,7 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { ); } finally { stopwatch.stop(); - await launchHeartbeat?.stop(); - await launch?.cleanup(); + await session?.close(); } final bool allPassed = environmentFailure == null && @@ -336,33 +244,14 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { ); } - Future _runScenarioWithInterrupt( - Future runFuture, - ) async { - StreamSubscription? interruptSub; - try { - final Completer interruptCompleter = - Completer(); - interruptSub = - (interruptSignals ?? - ProcessSignal.sigint.watch().map((ProcessSignal _) {})) - .listen((_) { - if (!interruptCompleter.isCompleted) { - interruptCompleter.completeError( - const TestCommandException( - message: 'test command interrupted.', - exitCode: 130, - ), - ); - } - }); - return await Future.any(>[ - runFuture, - interruptCompleter.future, - ]); - } finally { - runFuture.ignore(); - await interruptSub?.cancel(); - } + TestExecutionSessionFactory get _effectiveSessionFactory { + return _sessionFactory ?? + DefaultTestExecutionSessionFactory( + deviceDiscovery: deviceDiscovery, + launcher: launcher, + interruptSignals: interruptSignals, + launchHeartbeatTicks: launchHeartbeatTicks, + launchClock: clock, + ); } } diff --git a/lib/src/test_command_executor.dart b/lib/src/test_command_executor.dart index c90d21b..db6b4f4 100644 --- a/lib/src/test_command_executor.dart +++ b/lib/src/test_command_executor.dart @@ -9,11 +9,10 @@ import 'runtime/runtime_contract.dart'; import 'scenario_runner.dart'; import 'target_app_launch_progress_renderer.dart'; import 'target_app_launcher.dart'; -import 'target_device.dart'; import 'test_command_models.dart'; import 'test_device_discovery.dart'; +import 'test_execution_session.dart'; import 'test_scenario_runner_factory.dart'; -import 'test_target_device_selection.dart'; /// Executes a validated `test` command. abstract interface class TestCommandExecutor { @@ -40,14 +39,16 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { const DefaultTestCommandExecutor({ this.deviceDiscovery = const DefaultTestDeviceDiscovery(), this.launcher = const TargetAppLauncher(), + TestExecutionSessionFactory? sessionFactory, this.runnerFactory = const DefaultTestScenarioRunnerFactory(), this.interruptSignals, this.launchHeartbeatTicks, this.launchClock = DateTime.now, - }); + }) : _sessionFactory = sessionFactory; final TestDeviceDiscovery deviceDiscovery; final TargetAppLauncher launcher; + final TestExecutionSessionFactory? _sessionFactory; final TestScenarioRunnerFactory runnerFactory; final Stream? interruptSignals; final Stream? launchHeartbeatTicks; @@ -61,95 +62,23 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { void Function(StepProgressEvent event)? onProgress, }) async { final bool recordingRequired = options.scenario.recording?.enabled == true; - TargetDevice? targetDevice; - if (options.device != null || recordingRequired) { - try { - final List flutterDevices = await deviceDiscovery - .listFlutterDevices(); - final List recordingDevices = recordingRequired - ? await deviceDiscovery.listRecordingDevices() - : const []; - targetDevice = TargetDeviceResolver.resolve( - selector: options.device, - recordingRequired: recordingRequired, - flutterDevices: flutterDevices, - recordingDevices: recordingDevices, - ); - } on TargetDeviceResolutionException catch (error) { - throw TestCommandException(message: error.message, exitCode: 64); - } on DeviceDiscoveryException catch (error) { - throw TestCommandException(message: error.message, exitCode: 1); - } - } - - TargetAppLaunch launch; - final DateTime launchStartedAt = launchClock(); - final TargetAppLaunchChoices launchChoices = TargetAppLaunchChoices( - targetDevice: targetDevice, - selectionReason: targetDeviceSelectionReason( + final TestExecutionSession session; + try { + session = await _effectiveSessionFactory.start( deviceSelector: options.device, + flavor: options.flavor, + target: options.target, recordingRequired: recordingRequired, - ), - flavor: options.flavor, - target: options.target, - ); - onLaunchProgress?.call( - TargetAppLaunchStartedEvent( - startedAt: launchStartedAt, - choices: launchChoices, - ), - ); - TargetAppLaunchHeartbeat? launchHeartbeat; - if (onLaunchProgress != null && launchHeartbeatEnabled) { - launchHeartbeat = TargetAppLaunchHeartbeat( - ticks: - launchHeartbeatTicks ?? - Stream.periodic(const Duration(seconds: 10)), - onProgress: onLaunchProgress, - clock: launchClock, + launchHeartbeatEnabled: launchHeartbeatEnabled, + onLaunchProgress: onLaunchProgress, ); - launchHeartbeat.start( - TargetAppLaunchStartedEvent( - startedAt: launchStartedAt, - choices: launchChoices, - ), - ); - } - try { - launch = await launcher.launch( - TargetAppLaunchCommand( - deviceId: targetDevice?.id, - flavor: options.flavor, - target: options.target, - ), - ); - onLaunchProgress?.call( - TargetAppLaunchSucceededEvent( - startedAt: launchStartedAt, - finishedAt: launchClock(), - choices: launchChoices, - ), - ); - await launchHeartbeat?.stop(); - } on TargetAppLaunchException catch (error) { - onLaunchProgress?.call( - TargetAppLaunchFailedEvent( - startedAt: launchStartedAt, - failedAt: launchClock(), - message: error.message, - stderrLines: error.stderrLines, - choices: launchChoices, - ), - ); - await launchHeartbeat?.stop(); - final String stderrContext = - onLaunchProgress == null && error.stderrLines.isNotEmpty - ? '\n${error.stderrLines.join('\n')}' - : ''; + } on TestExecutionTargetDeviceException catch (error) { + throw TestCommandException(message: error.message, exitCode: 64); + } on TestExecutionLaunchException catch (error) { throw TestCommandException( - message: '${error.message}$stderrContext', + message: error.message, exitCode: 1, - alreadyRendered: onLaunchProgress != null, + alreadyRendered: error.alreadyRendered, ); } @@ -157,15 +86,12 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { final TestScenarioRunner runner; try { runner = runnerFactory.create( - runtimeTarget: RuntimeTarget( - vmServiceUri: launch.runtimeTargetUri, - deviceId: targetDevice?.id ?? launch.deviceId, - ), - targetDevice: targetDevice, + runtimeTarget: session.runtimeTarget, + targetDevice: session.targetDevice, recordingController: recordingRequired ? ScreenRecorderRecordingController( recorder: screen_recorder.ScreenRecorder.defaultRecorder(), - deviceSelector: targetDevice!.id, + deviceSelector: session.targetDevice!.id, outputDirectory: Directory.current, ) : null, @@ -179,37 +105,22 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { printDiagnostics: options.printDiagnostics, onProgress: onProgress, ); - StreamSubscription? interruptSub; - try { - final Completer interruptCompleter = - Completer(); - interruptSub = - (interruptSignals ?? - ProcessSignal.sigint.watch().map( - (ProcessSignal _) {}, - )) - .listen((_) { - if (!interruptCompleter.isCompleted) { - interruptCompleter.completeError( - const TestCommandException( - message: 'test command interrupted.', - exitCode: 130, - ), - ); - } - }); - return await Future.any(>[ - runFuture, - interruptCompleter.future, - ]); - } finally { - runFuture.ignore(); - await interruptSub?.cancel(); - } + return await session.runWithInterrupt(runFuture); } on RuntimeOperationException catch (error) { throw TestCommandException(message: error.message, exitCode: 1); } finally { - await launch.cleanup(); + await session.close(); } } + + TestExecutionSessionFactory get _effectiveSessionFactory { + return _sessionFactory ?? + DefaultTestExecutionSessionFactory( + deviceDiscovery: deviceDiscovery, + launcher: launcher, + interruptSignals: interruptSignals, + launchHeartbeatTicks: launchHeartbeatTicks, + launchClock: launchClock, + ); + } } diff --git a/lib/src/test_command_support.dart b/lib/src/test_command_support.dart index d438dd9..8639477 100644 --- a/lib/src/test_command_support.dart +++ b/lib/src/test_command_support.dart @@ -10,4 +10,5 @@ export 'test_command_executor.dart'; export 'test_command_models.dart'; export 'test_command_output.dart'; export 'test_device_discovery.dart'; +export 'test_execution_session.dart'; export 'test_scenario_runner_factory.dart'; diff --git a/lib/src/test_execution_session.dart b/lib/src/test_execution_session.dart new file mode 100644 index 0000000..ce3a55c --- /dev/null +++ b/lib/src/test_execution_session.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'dart:io'; + +import 'runtime/runtime_contract.dart'; +import 'target_app_launch_progress_renderer.dart'; +import 'target_app_launcher.dart'; +import 'target_device.dart'; +import 'test_command_models.dart'; +import 'test_device_discovery.dart'; +import 'test_target_device_selection.dart'; + +/// Starts the shared execution lifetime used by `flutter_pilot test`. +abstract interface class TestExecutionSessionFactory { + /// Launch the Target App Package and return a session ready for Scenarios. + /// + /// `deviceSelector`, `flavor`, and `target` are command-line inputs. + /// `recordingRequired` forces Target Device resolution before launch so + /// Scenario Recording can use the same device. `onLaunchProgress` receives + /// Target App Launch Progress events when human-readable output is enabled. + Future start({ + required String? deviceSelector, + required String? flavor, + required String? target, + required bool recordingRequired, + required bool launchHeartbeatEnabled, + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + }); +} + +/// Shared Target App lifetime for one `flutter_pilot test` execution. +abstract interface class TestExecutionSession { + /// Runtime Target selected from the launched Target App Package. + RuntimeTarget get runtimeTarget; + + /// Target Device selected before launch, when Flutter Pilot selected one. + TargetDevice? get targetDevice; + + /// Run `operation` and complete with interruption when Ctrl-C is received. + Future runWithInterrupt(Future operation); + + /// Request a hot restart from the launched Target App Package. + Future hotRestart(); + + /// Clean up the launched Target App Package. + Future close(); +} + +/// Failure from the shared Test Execution Session lifecycle. +sealed class TestExecutionSessionException implements Exception { + /// Creates a lifecycle failure with a user-facing message. + const TestExecutionSessionException(this.message); + + /// Human-readable failure reason. + final String message; +} + +/// Failure raised when Target Device resolution cannot prepare launch inputs. +final class TestExecutionTargetDeviceException + extends TestExecutionSessionException { + /// Creates a Target Device lifecycle failure. + const TestExecutionTargetDeviceException(super.message); +} + +/// Failure raised when the Target App Package cannot launch or be controlled. +final class TestExecutionLaunchException extends TestExecutionSessionException { + /// Creates a Target App launch lifecycle failure. + const TestExecutionLaunchException( + super.message, { + this.stderrLines = const [], + this.alreadyRendered = false, + }); + + /// Flutter stderr lines captured during launch. + final List stderrLines; + + /// Whether Target App Launch Progress already rendered the failure. + final bool alreadyRendered; +} + +/// Default Test Execution Session factory for the `test` command. +class DefaultTestExecutionSessionFactory + implements TestExecutionSessionFactory { + /// Creates a factory with injectable launch, discovery, and clock boundaries. + const DefaultTestExecutionSessionFactory({ + this.deviceDiscovery = const DefaultTestDeviceDiscovery(), + this.launcher = const TargetAppLauncher(), + this.interruptSignals, + this.launchHeartbeatTicks, + this.launchClock = DateTime.now, + }); + + /// Discovers Flutter and Recording Devices before launch when needed. + final TestDeviceDiscovery deviceDiscovery; + + /// Starts and controls the Target App Package. + final TargetAppLauncher launcher; + + /// Optional interrupt stream used by tests and Ctrl-C handling. + final Stream? interruptSignals; + + /// Optional heartbeat stream used by launch progress tests. + final Stream? launchHeartbeatTicks; + + /// Clock used for Target App Launch Progress events. + final TargetAppLaunchClock launchClock; + + @override + Future start({ + required String? deviceSelector, + required String? flavor, + required String? target, + required bool recordingRequired, + required bool launchHeartbeatEnabled, + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + }) async { + final TargetDevice? targetDevice = await _resolveTargetDevice( + deviceSelector: deviceSelector, + recordingRequired: recordingRequired, + ); + final DateTime launchStartedAt = launchClock(); + final TargetAppLaunchChoices launchChoices = TargetAppLaunchChoices( + targetDevice: targetDevice, + selectionReason: targetDeviceSelectionReason( + deviceSelector: deviceSelector, + recordingRequired: recordingRequired, + ), + flavor: flavor, + target: target, + ); + final TargetAppLaunchStartedEvent startedEvent = + TargetAppLaunchStartedEvent( + startedAt: launchStartedAt, + choices: launchChoices, + ); + onLaunchProgress?.call(startedEvent); + TargetAppLaunchHeartbeat? launchHeartbeat; + if (onLaunchProgress != null && launchHeartbeatEnabled) { + launchHeartbeat = TargetAppLaunchHeartbeat( + ticks: + launchHeartbeatTicks ?? + Stream.periodic(const Duration(seconds: 10)), + onProgress: onLaunchProgress, + clock: launchClock, + ); + launchHeartbeat.start(startedEvent); + } + try { + final TargetAppLaunch launch = await launcher.launch( + TargetAppLaunchCommand( + deviceId: targetDevice?.id, + flavor: flavor, + target: target, + ), + ); + onLaunchProgress?.call( + TargetAppLaunchSucceededEvent( + startedAt: launchStartedAt, + finishedAt: launchClock(), + choices: launchChoices, + ), + ); + await launchHeartbeat?.stop(); + return _DefaultTestExecutionSession( + launch: launch, + targetDevice: targetDevice, + interruptSignals: interruptSignals, + ); + } on TargetAppLaunchException catch (error) { + onLaunchProgress?.call( + TargetAppLaunchFailedEvent( + startedAt: launchStartedAt, + failedAt: launchClock(), + message: error.message, + stderrLines: error.stderrLines, + choices: launchChoices, + ), + ); + await launchHeartbeat?.stop(); + final String stderrContext = + onLaunchProgress == null && error.stderrLines.isNotEmpty + ? '\n${error.stderrLines.join('\n')}' + : ''; + throw TestExecutionLaunchException( + '${error.message}$stderrContext', + stderrLines: error.stderrLines, + alreadyRendered: onLaunchProgress != null, + ); + } + } + + Future _resolveTargetDevice({ + required String? deviceSelector, + required bool recordingRequired, + }) async { + if (deviceSelector == null && !recordingRequired) { + return null; + } + try { + final List flutterDevices = await deviceDiscovery + .listFlutterDevices(); + final List recordingDevices = recordingRequired + ? await deviceDiscovery.listRecordingDevices() + : const []; + return TargetDeviceResolver.resolve( + selector: deviceSelector, + recordingRequired: recordingRequired, + flutterDevices: flutterDevices, + recordingDevices: recordingDevices, + ); + } on TargetDeviceResolutionException catch (error) { + throw TestExecutionTargetDeviceException(error.message); + } on DeviceDiscoveryException catch (error) { + throw TestExecutionTargetDeviceException(error.message); + } + } +} + +class _DefaultTestExecutionSession implements TestExecutionSession { + _DefaultTestExecutionSession({ + required TargetAppLaunch launch, + required this.targetDevice, + required this.interruptSignals, + }) : _launch = launch, + runtimeTarget = RuntimeTarget( + vmServiceUri: launch.runtimeTargetUri, + deviceId: targetDevice?.id ?? launch.deviceId, + ); + + final TargetAppLaunch _launch; + final Stream? interruptSignals; + + @override + final RuntimeTarget runtimeTarget; + + @override + final TargetDevice? targetDevice; + + @override + Future runWithInterrupt(Future operation) async { + StreamSubscription? interruptSub; + try { + final Completer interruptCompleter = Completer(); + interruptSub = + (interruptSignals ?? + ProcessSignal.sigint.watch().map((ProcessSignal _) {})) + .listen((_) { + if (!interruptCompleter.isCompleted) { + interruptCompleter.completeError( + const TestCommandException( + message: 'test command interrupted.', + exitCode: 130, + ), + ); + } + }); + return await Future.any(>[ + operation, + interruptCompleter.future, + ]); + } finally { + operation.ignore(); + await interruptSub?.cancel(); + } + } + + @override + Future hotRestart() async { + try { + await _launch.hotRestart(); + } on TargetAppLaunchException catch (error) { + throw TestExecutionLaunchException( + error.message, + stderrLines: error.stderrLines, + ); + } + } + + @override + Future close() { + return _launch.cleanup(); + } +} diff --git a/test/test_command_executor_test.dart b/test/test_command_executor_test.dart index 85a7dea..a66e36f 100644 --- a/test/test_command_executor_test.dart +++ b/test/test_command_executor_test.dart @@ -783,6 +783,54 @@ steps: }); }); + test( + 'default executor runs one Scenario inside one Test Execution Session', + () async { + final FakeTestExecutionSession session = FakeTestExecutionSession( + runtimeTarget: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/token=/ws'), + deviceId: 'pixel-8', + ), + ); + final FakeTestExecutionSessionFactory sessionFactory = + FakeTestExecutionSessionFactory(session); + final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + sessionFactory: sessionFactory, + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'session_backed', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + + final ScenarioRunReport report = await executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: 'staging', + target: 'lib/main_staging.dart', + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + ); + + expect(report.status, ScenarioRunStatus.passed); + expect(sessionFactory.startCount, 1); + expect(sessionFactory.flavor, 'staging'); + expect(sessionFactory.target, 'lib/main_staging.dart'); + expect(runner.runtimeTarget.deviceId, 'pixel-8'); + expect(session.runWithInterruptCount, 1); + expect(session.closeCount, 1); + }, + ); + test( 'default executor reports invalid hidden runtime switch values', () async { @@ -1785,6 +1833,70 @@ steps: }, ); + test( + 'default Project Run executor reuses one Test Execution Session', + () async { + await FileTestkit.runZoned(() async { + final FakeTestExecutionSession session = FakeTestExecutionSession( + runtimeTarget: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/token=/ws'), + deviceId: 'pixel-8', + ), + ); + final FakeTestExecutionSessionFactory sessionFactory = + FakeTestExecutionSessionFactory(session); + final FakeScenarioRunner firstRunner = FakeScenarioRunner( + _passedReportFor('login'), + ); + final FakeScenarioRunner secondRunner = FakeScenarioRunner( + _passedReportFor('checkout'), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + sessionFactory: sessionFactory, + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: _scenario('login'), + ), + ProjectScenarioFile( + path: 'pilot/checkout.yaml', + relativePath: 'checkout.yaml', + scenario: _scenario('checkout'), + ), + ]; + + final ProjectRunResult result = await executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: 'staging', + target: 'lib/main_staging.dart', + jsonOutput: false, + ), + ); + + expect(result.status, ProjectRunStatus.passed); + expect(sessionFactory.startCount, 1); + expect(sessionFactory.flavor, 'staging'); + expect(sessionFactory.target, 'lib/main_staging.dart'); + expect(session.hotRestartCount, 1); + expect(session.runWithInterruptCount, 2); + expect(session.closeCount, 1); + expect(firstRunner.runtimeTarget.deviceId, 'pixel-8'); + expect(secondRunner.runtimeTarget.deviceId, 'pixel-8'); + }); + }, + ); + test( 'default Project Run executor resolves explicit Target Device before launch', () async { @@ -2636,6 +2748,66 @@ class FakeDeviceDiscovery implements TestDeviceDiscovery { } } +class FakeTestExecutionSessionFactory implements TestExecutionSessionFactory { + FakeTestExecutionSessionFactory(this.session); + + final FakeTestExecutionSession session; + int startCount = 0; + String? deviceSelector; + String? flavor; + String? target; + bool? recordingRequired; + bool? launchHeartbeatEnabled; + + @override + Future start({ + required String? deviceSelector, + required String? flavor, + required String? target, + required bool recordingRequired, + required bool launchHeartbeatEnabled, + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + }) async { + startCount++; + this.deviceSelector = deviceSelector; + this.flavor = flavor; + this.target = target; + this.recordingRequired = recordingRequired; + this.launchHeartbeatEnabled = launchHeartbeatEnabled; + return session; + } +} + +class FakeTestExecutionSession implements TestExecutionSession { + FakeTestExecutionSession({required this.runtimeTarget, this.targetDevice}); + + @override + final RuntimeTarget runtimeTarget; + + @override + final TargetDevice? targetDevice; + + int runWithInterruptCount = 0; + int closeCount = 0; + int hotRestartCount = 0; + + @override + Future runWithInterrupt(Future operation) { + runWithInterruptCount++; + return operation; + } + + @override + Future hotRestart() async { + hotRestartCount++; + } + + @override + Future close() async { + closeCount++; + } +} + class FakeScenarioRunnerFactory implements TestScenarioRunnerFactory { const FakeScenarioRunnerFactory(this.runner); diff --git a/test/test_execution_session_test.dart b/test/test_execution_session_test.dart new file mode 100644 index 0000000..b300f2b --- /dev/null +++ b/test/test_execution_session_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_pilot/flutter_pilot.dart'; +import 'package:test/test.dart'; + +/// Verifies the shared Test Execution Session lifecycle without launching a +/// real Flutter app. +void main() { + test( + 'starts without Target Device discovery when Flutter default is allowed', + () async { + final FakeDeviceDiscovery discovery = FakeDeviceDiscovery(); + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final TestExecutionSessionFactory factory = + DefaultTestExecutionSessionFactory( + deviceDiscovery: discovery, + launcher: TargetAppLauncher(starter: starter), + ); + + final Future sessionFuture = factory.start( + deviceSelector: null, + flavor: 'staging', + target: 'lib/main_staging.dart', + recordingRequired: false, + launchHeartbeatEnabled: false, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.started', + 'params': {'appId': 'app-1', 'deviceId': 'pixel-8'}, + }), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, + }), + ); + + final TestExecutionSession session = await sessionFuture; + expect(discovery.flutterDeviceListCount, 0); + expect(discovery.recordingDeviceListCount, 0); + expect(starter.startedArguments, [ + 'run', + '--machine', + '--flavor', + 'staging', + '--target', + 'lib/main_staging.dart', + ]); + expect( + session.runtimeTarget.vmServiceUri.toString(), + 'ws://127.0.0.1:1234/token=/ws', + ); + expect(session.runtimeTarget.deviceId, 'pixel-8'); + expect(session.targetDevice, isNull); + + await session.close(); + expect(process.stdinWrites, ['q\n']); + }, + ); +} + +class FakeDeviceDiscovery implements TestDeviceDiscovery { + int flutterDeviceListCount = 0; + int recordingDeviceListCount = 0; + + @override + Future> listFlutterDevices() async { + flutterDeviceListCount++; + return const []; + } + + @override + Future> listRecordingDevices() async { + recordingDeviceListCount++; + return const []; + } +} + +class FakeTargetAppProcessStarter implements TargetAppProcessStarter { + FakeTargetAppProcessStarter(this.process); + + final FakeTargetAppProcess process; + List startedArguments = const []; + + @override + Future start( + String executable, + List arguments, + ) async { + startedArguments = arguments; + return process; + } +} + +class FakeTargetAppProcess implements TargetAppProcess { + final StreamController> _stdoutController = + StreamController>(); + final StreamController> _stderrController = + StreamController>(); + final Completer _exitCodeCompleter = Completer(); + final List stdinWrites = []; + + @override + Stream> get stdout => _stdoutController.stream; + + @override + Stream> get stderr => _stderrController.stream; + + @override + Future get exitCode => _exitCodeCompleter.future; + + void emitStdout(String line) { + _stdoutController.add(utf8.encode('$line\n')); + } + + @override + void writeStdin(String text) { + stdinWrites.add(text); + if (text == 'q\n' && !_exitCodeCompleter.isCompleted) { + _stdoutController.close(); + _stderrController.close(); + _exitCodeCompleter.complete(0); + } + } + + @override + bool kill() { + return true; + } +} From 698aa4e50609552c179a8e6dc88f3f7dcf3670f1 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 15:14:14 +0800 Subject: [PATCH 5/8] test(cli): split test command coverage by module --- test/project_run_executor_test.dart | 842 ++++++ test/support/test_command_fakes.dart | 463 ++++ ...get_app_launch_progress_renderer_test.dart | 334 +++ test/test_command_cli_test.dart | 489 ++++ test/test_command_executor_test.dart | 2293 +---------------- test/test_command_output_test.dart | 145 ++ 6 files changed, 2312 insertions(+), 2254 deletions(-) create mode 100644 test/project_run_executor_test.dart create mode 100644 test/support/test_command_fakes.dart create mode 100644 test/target_app_launch_progress_renderer_test.dart create mode 100644 test/test_command_cli_test.dart create mode 100644 test/test_command_output_test.dart diff --git a/test/project_run_executor_test.dart b/test/project_run_executor_test.dart new file mode 100644 index 0000000..e7ec25a --- /dev/null +++ b/test/project_run_executor_test.dart @@ -0,0 +1,842 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_testkit/file_testkit.dart'; +import 'package:flutter_pilot/flutter_pilot.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'support/test_command_fakes.dart'; + +/// Verifies Project Run execution through `DefaultProjectRunExecutor`. +void main() { + test( + 'default Project Run executor reuses one launch with hot restart', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner firstRunner = FakeScenarioRunner( + passedScenarioRunReportFor('login'), + ); + final FakeScenarioRunner secondRunner = FakeScenarioRunner( + passedScenarioRunReportFor('checkout'), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ProjectScenarioFile( + path: 'pilot/checkout.yaml', + relativePath: 'checkout.yaml', + scenario: scenarioFixture('checkout'), + ), + ]; + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + while (process.stdinWrites.isEmpty) { + await Future.delayed(Duration.zero); + } + expect(secondRunner.onProgress, isNull); + process.emitStdout( + jsonEncode({'id': 0, 'result': true}), + ); + + final ProjectRunResult report = await reportFuture; + + expect(report.passed, isTrue); + expect(starter.startCount, 1); + expect(process.stdinWrites, [ + '[{"id":0,"method":"app.restart","params":{"appId":"app-1","fullRestart":true}}]\n', + 'q\n', + ]); + expect(firstRunner.scenario.name, 'login'); + expect(secondRunner.scenario.name, 'checkout'); + expect( + firstRunner.runArtifactWriter?.runDirectory.path, + p.join( + Directory.current.path, + '.runs', + '2026-07-01_09-30_project-run', + '2026-07-01_09-30_login', + ), + ); + expect( + secondRunner.runArtifactWriter?.runDirectory.path, + p.join( + Directory.current.path, + '.runs', + '2026-07-01_09-30_project-run', + '2026-07-01_09-30_checkout', + ), + ); + }); + }, + ); + + test( + 'default Project Run executor reuses one Test Execution Session', + () async { + await FileTestkit.runZoned(() async { + final FakeTestExecutionSession session = FakeTestExecutionSession( + runtimeTarget: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/token=/ws'), + deviceId: 'pixel-8', + ), + ); + final FakeTestExecutionSessionFactory sessionFactory = + FakeTestExecutionSessionFactory(session); + final FakeScenarioRunner firstRunner = FakeScenarioRunner( + passedScenarioRunReportFor('login'), + ); + final FakeScenarioRunner secondRunner = FakeScenarioRunner( + passedScenarioRunReportFor('checkout'), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + sessionFactory: sessionFactory, + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ProjectScenarioFile( + path: 'pilot/checkout.yaml', + relativePath: 'checkout.yaml', + scenario: scenarioFixture('checkout'), + ), + ]; + + final ProjectRunResult result = await executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: 'staging', + target: 'lib/main_staging.dart', + jsonOutput: false, + ), + ); + + expect(result.status, ProjectRunStatus.passed); + expect(sessionFactory.startCount, 1); + expect(sessionFactory.flavor, 'staging'); + expect(sessionFactory.target, 'lib/main_staging.dart'); + expect(session.hotRestartCount, 1); + expect(session.runWithInterruptCount, 2); + expect(session.closeCount, 1); + expect(firstRunner.runtimeTarget.deviceId, 'pixel-8'); + expect(secondRunner.runtimeTarget.deviceId, 'pixel-8'); + }); + }, + ); + + test( + 'default Project Run executor resolves explicit Target Device before launch', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReportFor('login'), + ); + final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( + flutterDevices: [ + FlutterDevice( + id: 'pixel-8', + name: 'Pixel 8', + targetPlatform: 'android-arm64', + isSupported: true, + emulator: true, + sdk: 'Android 35', + ), + ], + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + runner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ]; + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: 'Pixel', + flavor: 'staging', + target: 'lib/main_staging.dart', + jsonOutput: false, + ), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + while (process.stdinWrites.isEmpty) { + await Future.delayed(Duration.zero); + } + process.emitStdout( + jsonEncode({'id': 0, 'result': true}), + ); + + final ProjectRunResult report = await reportFuture; + + expect(report.passed, isTrue); + expect(deviceDiscovery.flutterDeviceListCount, 1); + expect(starter.startedArguments, [ + 'run', + '--machine', + '--device-id', + 'pixel-8', + '--flavor', + 'staging', + '--target', + 'lib/main_staging.dart', + ]); + expect(runner.targetDevice?.id, 'pixel-8'); + }); + }, + ); + + test( + 'default Project Run executor resolves recordable Target Device before launch', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReportFor('recorded'), + ); + final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( + flutterDevices: [ + FlutterDevice( + id: 'pixel-8', + name: 'Pixel 8', + targetPlatform: 'android-arm64', + isSupported: true, + emulator: true, + sdk: 'Android 35', + ), + ], + recordingDevices: [ + RecordingDeviceIdentity(id: 'pixel-8'), + ], + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + runner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List launchEvents = + []; + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/recorded.yaml', + relativePath: 'recorded.yaml', + scenario: Scenario( + name: 'recorded', + recording: const ScenarioRecording(enabled: true), + steps: const [ + ScenarioStep( + index: 1, + action: CaptureAction( + screenshot: false, + snapshot: false, + widgetTree: false, + logs: false, + ), + ), + ], + ), + ), + ]; + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + onLaunchProgress: launchEvents.add, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + + final ProjectRunResult report = await reportFuture; + + expect(report.passed, isTrue); + expect(deviceDiscovery.flutterDeviceListCount, 1); + expect(deviceDiscovery.recordingDeviceListCount, 1); + expect(starter.startedArguments, [ + 'run', + '--machine', + '--device-id', + 'pixel-8', + ]); + expect(runner.targetDevice?.id, 'pixel-8'); + expect(runner.recordingController, isNotNull); + final TargetAppLaunchStartedEvent startedEvent = launchEvents + .whereType() + .single; + expect( + startedEvent.choices.selectionReason, + isA(), + ); + }); + }, + ); + + test( + 'default Project Run executor rejects ambiguous recording-required selection', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( + flutterDevices: [ + FlutterDevice( + id: 'pixel-8', + name: 'Pixel 8', + targetPlatform: 'android-arm64', + isSupported: true, + emulator: true, + sdk: 'Android 35', + ), + FlutterDevice( + id: 'iphone-15', + name: 'iPhone 15', + targetPlatform: 'ios', + isSupported: true, + emulator: false, + sdk: 'iOS 18', + ), + ], + recordingDevices: [ + const RecordingDeviceIdentity(id: 'pixel-8'), + const RecordingDeviceIdentity(id: 'iphone-15'), + ], + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + FakeScenarioRunner(passedScenarioRunReportFor('recorded')), + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/recorded.yaml', + relativePath: 'recorded.yaml', + scenario: Scenario( + name: 'recorded', + recording: const ScenarioRecording(enabled: true), + steps: const [ + ScenarioStep( + index: 1, + action: CaptureAction( + screenshot: false, + snapshot: false, + widgetTree: false, + logs: false, + ), + ), + ], + ), + ), + ]; + + final ProjectRunResult report = await executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + ); + + expect(report.status, ProjectRunStatus.environmentFailed); + final File projectRunReport = File(report.projectRunReportPath); + final Map reportJson = + jsonDecode(projectRunReport.readAsStringSync()) + as Map; + expect(reportJson['environmentFailure'], { + 'phase': 'targetDeviceResolution', + 'message': + 'Multiple recordable Target Devices are available. Pass --device with one of: pixel-8 (Pixel 8), iphone-15 (iPhone 15).', + }); + expect(deviceDiscovery.flutterDeviceListCount, 1); + expect(deviceDiscovery.recordingDeviceListCount, 1); + expect(starter.startedArguments, isEmpty); + }); + }, + ); + + test( + 'default Project Run executor reports device discovery failure', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( + flutterDeviceListException: const DeviceDiscoveryException( + 'flutter devices failed', + ), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + deviceDiscovery: deviceDiscovery, + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + FakeScenarioRunner(passedScenarioRunReportFor('login')), + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + + final ProjectRunResult report = await executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ], + device: 'pixel', + flavor: null, + target: null, + jsonOutput: false, + ), + ); + + expect(report.status, ProjectRunStatus.environmentFailed); + final File projectRunReport = File(report.projectRunReportPath); + final Map reportJson = + jsonDecode(projectRunReport.readAsStringSync()) + as Map; + expect(reportJson['environmentFailure'], { + 'phase': 'targetDeviceResolution', + 'message': 'flutter devices failed', + }); + expect(starter.startedArguments, isEmpty); + }); + }, + ); + + test('default Project Run executor emits launch progress once', () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReportFor('login'), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([runner]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List launchEvents = + []; + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ]; + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + onLaunchProgress: launchEvents.add, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, + }), + ); + + final ProjectRunResult report = await reportFuture; + + expect(report.passed, isTrue); + expect( + launchEvents.whereType(), + hasLength(1), + ); + expect( + launchEvents.whereType(), + hasLength(1), + ); + }); + }); + + test('default Project Run executor cleans up when interrupted', () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final HangingScenarioRunner runner = HangingScenarioRunner(); + final StreamController interruptController = + StreamController(); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([runner]), + interruptSignals: interruptController.stream, + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ], + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + await Future.delayed(Duration.zero); + interruptController.add(null); + + await expectLater( + reportFuture, + throwsA( + isA() + .having( + (TestCommandException error) => error.message, + 'message', + contains('interrupted'), + ) + .having( + (TestCommandException error) => error.exitCode, + 'exitCode', + 130, + ), + ), + ); + expect(process.stdinWrites, ['q\n']); + await interruptController.close(); + }); + }); + + test( + 'default Project Run executor emits launch heartbeat until success', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReportFor('login'), + ); + final StreamController ticks = StreamController(); + DateTime now = DateTime.utc(2026, 7, 1, 9, 30); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + runner, + ]), + outputDirectory: Directory.current, + clock: () => now, + launchHeartbeatTicks: ticks.stream, + ); + final List launchEvents = + []; + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ]; + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + onLaunchProgress: launchEvents.add, + launchHeartbeatEnabled: true, + ); + while (starter.startedArguments.isEmpty) { + await Future.delayed(Duration.zero); + } + now = DateTime.utc(2026, 7, 1, 9, 30, 10); + ticks.add(null); + await Future.delayed(Duration.zero); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + await reportFuture; + now = DateTime.utc(2026, 7, 1, 9, 30, 20); + ticks.add(null); + await Future.delayed(Duration.zero); + await ticks.close(); + + expect( + launchEvents.whereType(), + hasLength(1), + ); + }); + }, + ); + + test( + 'default Project Run executor continues after a failed Scenario', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner firstRunner = FailingScenarioRunner( + failingScenarioRunReportFor('login'), + ); + final FakeScenarioRunner secondRunner = FakeScenarioRunner( + passedScenarioRunReportFor('checkout'), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ProjectScenarioFile( + path: 'pilot/checkout.yaml', + relativePath: 'checkout.yaml', + scenario: scenarioFixture('checkout'), + ), + ]; + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + while (process.stdinWrites.isEmpty) { + await Future.delayed(Duration.zero); + } + process.emitStdout( + jsonEncode({'id': 0, 'result': true}), + ); + + final ProjectRunResult report = await reportFuture; + + expect(report.passed, isFalse); + expect(starter.startCount, 1); + expect(process.stdinWrites, [ + '[{"id":0,"method":"app.restart","params":{"appId":"app-1","fullRestart":true}}]\n', + 'q\n', + ]); + expect(firstRunner.scenario.name, 'login'); + expect(secondRunner.scenario.name, 'checkout'); + }); + }, + ); + + test('default Project Run executor stops on hot restart failure', () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess() + ..stdinException = StateError('restart failed') + ..stdinExceptionOnce = true; + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner firstRunner = FailingScenarioRunner( + failingScenarioRunReportFor('login'), + ); + final FakeScenarioRunner secondRunner = FakeScenarioRunner( + passedScenarioRunReportFor('checkout'), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + launcher: TargetAppLauncher(starter: starter), + runnerFactory: QueueScenarioRunnerFactory([ + firstRunner, + secondRunner, + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + ); + final List scenarios = [ + ProjectScenarioFile( + path: 'pilot/login.yaml', + relativePath: 'login.yaml', + scenario: scenarioFixture('login'), + ), + ProjectScenarioFile( + path: 'pilot/checkout.yaml', + relativePath: 'checkout.yaml', + scenario: scenarioFixture('checkout'), + ), + ]; + + final Future reportFuture = executor.run( + ProjectRunOptions( + discoveryRootPath: 'pilot', + scenarios: scenarios, + device: null, + flavor: null, + target: null, + jsonOutput: false, + ), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + + final ProjectRunResult report = await reportFuture; + + expect(report.passed, isFalse); + expect(starter.startCount, 1); + expect(process.stdinWrites, ['q\n']); + expect(firstRunner.scenario.name, 'login'); + expect(secondRunner.onProgress, isNull); + }); + }); +} diff --git a/test/support/test_command_fakes.dart b/test/support/test_command_fakes.dart new file mode 100644 index 0000000..d2c920e --- /dev/null +++ b/test/support/test_command_fakes.dart @@ -0,0 +1,463 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_pilot/flutter_pilot.dart'; + +/// Shared fakes and fixtures for `flutter_pilot test` orchestration tests. +class FakeTestCommandExecutor implements TestCommandExecutor { + FakeTestCommandExecutor({required this.report, this.exception}); + + final ScenarioRunReport report; + final TestCommandException? exception; + late TestCommandOptions options; + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress; + bool? launchHeartbeatEnabled; + void Function(StepProgressEvent event)? onProgress; + + @override + Future run( + TestCommandOptions options, { + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + bool launchHeartbeatEnabled = false, + void Function(StepProgressEvent event)? onProgress, + }) async { + this.options = options; + this.onLaunchProgress = onLaunchProgress; + this.launchHeartbeatEnabled = launchHeartbeatEnabled; + this.onProgress = onProgress; + final TestCommandException? exception = this.exception; + if (exception != null) { + throw exception; + } + return report; + } +} + +class FakeProjectRunExecutor implements ProjectRunExecutor { + FakeProjectRunExecutor({required this.report}); + + final ProjectRunResult report; + late ProjectRunOptions options; + bool ran = false; + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress; + bool? launchHeartbeatEnabled; + void Function(StepProgressEvent event)? onProgress; + + @override + Future run( + ProjectRunOptions options, { + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + bool launchHeartbeatEnabled = false, + void Function(StepProgressEvent event)? onProgress, + }) async { + this.options = options; + ran = true; + this.onLaunchProgress = onLaunchProgress; + this.launchHeartbeatEnabled = launchHeartbeatEnabled; + this.onProgress = onProgress; + return report; + } +} + +ProjectRunResult passedProjectRunResult() { + return const ProjectRunResult( + passed: true, + status: ProjectRunStatus.passed, + projectRunReportPath: '.runs/project-run/project_run_report.json', + scenarioReports: [], + ); +} + +ScenarioRunReport passedScenarioRunReport({TargetDevice? targetDevice}) { + return ScenarioRunReport( + scenarioName: 'delegated', + scenarioDescription: null, + totalSteps: 0, + status: ScenarioRunStatus.passed, + startedAt: DateTime.utc(2026, 6, 30), + durationMs: 1, + steps: const [], + runDirectoryPath: '.runs/delegated', + artifacts: const [], + targetDevice: targetDevice, + ); +} + +ScenarioRunReport passedScenarioRunReportFor(String scenarioName) { + return ScenarioRunReport( + scenarioName: scenarioName, + scenarioDescription: null, + totalSteps: 1, + status: ScenarioRunStatus.passed, + startedAt: DateTime.utc(2026, 7, 1, 9, 30), + durationMs: 1, + steps: const [ + StepRunReport( + index: 1, + label: null, + action: 'capture', + status: StepStatus.passed, + durationMs: 1, + ), + ], + runDirectoryPath: '.runs/child', + artifacts: const [ + ArtifactReport(type: ArtifactType.runReport, path: 'run_report.json'), + ArtifactReport(type: ArtifactType.htmlReport, path: 'timeline.html'), + ], + ); +} + +ScenarioRunReport failingScenarioRunReportFor(String scenarioName) { + return ScenarioRunReport( + scenarioName: scenarioName, + scenarioDescription: null, + totalSteps: 1, + status: ScenarioRunStatus.failed, + startedAt: DateTime.utc(2026, 7, 1, 9, 30), + durationMs: 1, + steps: const [ + StepRunReport( + index: 1, + label: null, + action: 'capture', + status: StepStatus.failed, + durationMs: 1, + failureReason: 'failed', + ), + ], + runDirectoryPath: '.runs/child', + artifacts: const [], + ); +} + +Scenario scenarioFixture(String name) { + return Scenario( + name: name, + steps: const [ + ScenarioStep( + index: 1, + action: CaptureAction( + screenshot: true, + snapshot: true, + widgetTree: false, + logs: true, + ), + ), + ], + ); +} + +class FakeDeviceDiscovery implements TestDeviceDiscovery { + FakeDeviceDiscovery({ + this.flutterDevices = const [], + this.recordingDevices = const [], + this.flutterDeviceListException, + this.recordingDeviceListException, + }); + + final List flutterDevices; + final List recordingDevices; + final DeviceDiscoveryException? flutterDeviceListException; + final DeviceDiscoveryException? recordingDeviceListException; + int flutterDeviceListCount = 0; + int recordingDeviceListCount = 0; + + @override + Future> listFlutterDevices() async { + flutterDeviceListCount++; + final DeviceDiscoveryException? exception = flutterDeviceListException; + if (exception != null) { + throw exception; + } + return flutterDevices; + } + + @override + Future> listRecordingDevices() async { + recordingDeviceListCount++; + final DeviceDiscoveryException? exception = recordingDeviceListException; + if (exception != null) { + throw exception; + } + return recordingDevices; + } +} + +class FakeTestExecutionSessionFactory implements TestExecutionSessionFactory { + FakeTestExecutionSessionFactory(this.session); + + final FakeTestExecutionSession session; + int startCount = 0; + String? deviceSelector; + String? flavor; + String? target; + bool? recordingRequired; + bool? launchHeartbeatEnabled; + + @override + Future start({ + required String? deviceSelector, + required String? flavor, + required String? target, + required bool recordingRequired, + required bool launchHeartbeatEnabled, + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + }) async { + startCount++; + this.deviceSelector = deviceSelector; + this.flavor = flavor; + this.target = target; + this.recordingRequired = recordingRequired; + this.launchHeartbeatEnabled = launchHeartbeatEnabled; + return session; + } +} + +class FakeTestExecutionSession implements TestExecutionSession { + FakeTestExecutionSession({required this.runtimeTarget, this.targetDevice}); + + @override + final RuntimeTarget runtimeTarget; + + @override + final TargetDevice? targetDevice; + + int runWithInterruptCount = 0; + int closeCount = 0; + int hotRestartCount = 0; + + @override + Future runWithInterrupt(Future operation) { + runWithInterruptCount++; + return operation; + } + + @override + Future hotRestart() async { + hotRestartCount++; + } + + @override + Future close() async { + closeCount++; + } +} + +class FakeScenarioRunnerFactory implements TestScenarioRunnerFactory { + const FakeScenarioRunnerFactory(this.runner); + + final FakeScenarioRunner runner; + + @override + TestScenarioRunner create({ + required RuntimeTarget runtimeTarget, + required TargetDevice? targetDevice, + required RecordingController? recordingController, + }) { + runner.runtimeTarget = runtimeTarget; + runner.targetDevice = targetDevice; + runner.recordingController = recordingController; + return runner; + } +} + +class ThrowingScenarioRunnerFactory implements TestScenarioRunnerFactory { + const ThrowingScenarioRunnerFactory(this.exception); + + final Exception exception; + + @override + TestScenarioRunner create({ + required RuntimeTarget runtimeTarget, + required TargetDevice? targetDevice, + required RecordingController? recordingController, + }) { + throw exception; + } +} + +class QueueScenarioRunnerFactory implements TestScenarioRunnerFactory { + QueueScenarioRunnerFactory(this.runners); + + final List runners; + int _index = 0; + + @override + TestScenarioRunner create({ + required RuntimeTarget runtimeTarget, + required TargetDevice? targetDevice, + required RecordingController? recordingController, + }) { + final FakeScenarioRunner runner = runners[_index++]; + runner.runtimeTarget = runtimeTarget; + runner.targetDevice = targetDevice; + runner.recordingController = recordingController; + return runner; + } +} + +class FakeScenarioRunner implements TestScenarioRunner { + FakeScenarioRunner(this.report); + + final ScenarioRunReport report; + late RuntimeTarget runtimeTarget; + TargetDevice? targetDevice; + RecordingController? recordingController; + late Scenario scenario; + void Function(StepProgressEvent event)? onProgress; + RunArtifactWriter? runArtifactWriter; + + @override + Future run( + Scenario scenario, { + RunStopPoint? stopPoint, + Set printDiagnostics = const {}, + void Function(StepProgressEvent event)? onProgress, + RunArtifactWriter? runArtifactWriter, + }) async { + this.scenario = scenario; + this.onProgress = onProgress; + this.runArtifactWriter = runArtifactWriter; + onProgress?.call( + StepStartedEvent( + scenarioName: scenario.name, + totalSteps: scenario.steps.length, + step: scenario.steps.first, + action: 'tap', + ), + ); + onProgress?.call( + StepFinishedEvent( + scenarioName: scenario.name, + totalSteps: scenario.steps.length, + report: StepRunReport( + index: scenario.steps.first.index, + label: scenario.steps.first.label, + action: 'tap', + status: StepStatus.passed, + durationMs: 1, + ), + ), + ); + return report; + } +} + +class FailingScenarioRunner extends FakeScenarioRunner { + FailingScenarioRunner(super.report); + + @override + Future run( + Scenario scenario, { + RunStopPoint? stopPoint, + Set printDiagnostics = const {}, + void Function(StepProgressEvent event)? onProgress, + RunArtifactWriter? runArtifactWriter, + }) async { + this.scenario = scenario; + this.runArtifactWriter = runArtifactWriter; + return ScenarioRunReport( + scenarioName: scenario.name, + scenarioDescription: null, + totalSteps: scenario.steps.length, + status: ScenarioRunStatus.failed, + startedAt: DateTime.utc(2026, 7, 1, 9, 30), + durationMs: 1, + steps: const [], + runDirectoryPath: '.runs/child', + artifacts: const [], + ); + } +} + +class HangingScenarioRunner extends FakeScenarioRunner { + HangingScenarioRunner() : super(passedScenarioRunReport()); + + final Completer _completer = + Completer(); + + @override + Future run( + Scenario scenario, { + RunStopPoint? stopPoint, + Set printDiagnostics = const {}, + void Function(StepProgressEvent event)? onProgress, + RunArtifactWriter? runArtifactWriter, + }) { + this.scenario = scenario; + this.onProgress = onProgress; + this.runArtifactWriter = runArtifactWriter; + return _completer.future; + } +} + +class FakeTargetAppProcessStarter implements TargetAppProcessStarter { + FakeTargetAppProcessStarter(this.process); + + final FakeTargetAppProcess process; + List startedArguments = const []; + int startCount = 0; + + @override + Future start( + String executable, + List arguments, + ) async { + startCount++; + startedArguments = arguments; + return process; + } +} + +class FakeTargetAppProcess implements TargetAppProcess { + final StreamController> _stdoutController = + StreamController>(); + final StreamController> _stderrController = + StreamController>(); + final Completer _exitCodeCompleter = Completer(); + final List stdinWrites = []; + Object? stdinException; + bool stdinExceptionOnce = false; + + @override + Stream> get stdout => _stdoutController.stream; + + @override + Stream> get stderr => _stderrController.stream; + + @override + Future get exitCode => _exitCodeCompleter.future; + + void emitStdout(String line) { + _stdoutController.add(utf8.encode('$line\n')); + } + + void emitStderr(String line) { + _stderrController.add(utf8.encode('$line\n')); + } + + void exit(int exitCode) { + _stdoutController.close(); + _stderrController.close(); + _exitCodeCompleter.complete(exitCode); + } + + @override + void writeStdin(String text) { + final Object? exception = stdinException; + if (exception != null) { + if (stdinExceptionOnce) { + stdinException = null; + stdinExceptionOnce = false; + } + throw exception; + } + stdinWrites.add(text); + } + + @override + bool kill() { + return true; + } +} diff --git a/test/target_app_launch_progress_renderer_test.dart b/test/target_app_launch_progress_renderer_test.dart new file mode 100644 index 0000000..0d1108c --- /dev/null +++ b/test/target_app_launch_progress_renderer_test.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_testkit/file_testkit.dart'; +import 'package:flutter_pilot/flutter_pilot.dart'; +import 'package:test/test.dart'; + +/// Verifies Target App Launch Progress rendering independently of execution. +void main() { + test('plain launch progress renders before Step progress', () async { + await FileTestkit.runZoned(() async { + final File output = File('progress.log'); + final IOSink sink = output.openWrite(); + final TargetAppLaunchProgressRenderer launchRenderer = + TargetAppLaunchProgressRenderer( + sink: sink, + clock: () => DateTime.utc(2026, 6, 30, 12, 0, 38), + ); + final StepProgressRenderer stepRenderer = StepProgressRenderer( + sink: sink, + ); + + launchRenderer.render( + TargetAppLaunchStartedEvent(startedAt: DateTime.utc(2026, 6, 30, 12)), + ); + launchRenderer.render( + TargetAppLaunchSucceededEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + finishedAt: DateTime.utc(2026, 6, 30, 12, 0, 38), + ), + ); + stepRenderer.render( + const StepStartedEvent( + scenarioName: 'launch_progress', + totalSteps: 1, + step: ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + action: 'tap', + ), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect(rendered, contains('> Target App Launch')); + expect(rendered, contains('Launching Target App Package... elapsed 38s')); + expect(rendered, contains('Target App launched in 38s')); + expect(rendered, contains('Scenario: launch_progress (1 steps)')); + expect( + rendered.indexOf('Target App launched in 38s'), + lessThan(rendered.indexOf('Scenario: launch_progress (1 steps)')), + ); + }); + }); + + test( + 'plain launch progress shows explicit Target Device selection', + () async { + await FileTestkit.runZoned(() async { + final File output = File('progress.log'); + final IOSink sink = output.openWrite(); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer(sink: sink); + + renderer.render( + TargetAppLaunchStartedEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + choices: const TargetAppLaunchChoices( + targetDevice: TargetDevice( + id: 'pixel-8', + name: 'Pixel 8', + targetPlatform: 'android-arm64', + emulator: true, + sdk: 'Android 35', + ), + selectionReason: TargetDeviceSelectionReason.explicit( + selector: 'Pixel', + ), + ), + ), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect( + rendered, + contains( + 'Target Device: pixel-8 (Pixel 8, android-arm64, Android 35)', + ), + ); + expect(rendered, contains('Selection: --device Pixel')); + }); + }, + ); + + test( + 'plain launch progress shows recording auto-selected Target Device', + () async { + await FileTestkit.runZoned(() async { + final File output = File('progress.log'); + final IOSink sink = output.openWrite(); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer(sink: sink); + + renderer.render( + TargetAppLaunchStartedEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + choices: const TargetAppLaunchChoices( + targetDevice: TargetDevice( + id: 'pixel-8', + name: 'Pixel 8', + targetPlatform: 'android-arm64', + emulator: true, + sdk: 'Android 35', + ), + selectionReason: + TargetDeviceSelectionReason.autoSelectedForRecording(), + ), + ), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect( + rendered, + contains( + 'Target Device: pixel-8 (Pixel 8, android-arm64, Android 35)', + ), + ); + expect(rendered, contains('Selection: auto-selected for recording')); + }); + }, + ); + + test( + 'plain launch progress shows Flutter default without placeholders', + () async { + await FileTestkit.runZoned(() async { + final File output = File('progress.log'); + final IOSink sink = output.openWrite(); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer(sink: sink); + + renderer.render( + TargetAppLaunchStartedEvent(startedAt: DateTime.utc(2026, 6, 30, 12)), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect(rendered, contains('Target Device: Flutter default')); + expect(rendered, isNot(contains('Selection:'))); + expect(rendered, isNot(contains('Flavor:'))); + expect(rendered, isNot(contains('Entrypoint:'))); + }); + }, + ); + + test( + 'plain launch progress shows flavor and entrypoint when provided', + () async { + await FileTestkit.runZoned(() async { + final File output = File('progress.log'); + final IOSink sink = output.openWrite(); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer(sink: sink); + + renderer.render( + TargetAppLaunchStartedEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + choices: const TargetAppLaunchChoices( + flavor: 'staging', + target: 'lib/main_staging.dart', + ), + ), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect(rendered, contains('Flavor: staging')); + expect(rendered, contains('Entrypoint: lib/main_staging.dart')); + }); + }, + ); + + test('plain launch progress shows bounded failure details', () async { + await FileTestkit.runZoned(() async { + final File output = File('progress.log'); + final IOSink sink = output.openWrite(); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer(sink: sink); + + renderer.render( + TargetAppLaunchFailedEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + failedAt: DateTime.utc(2026, 6, 30, 12, 0, 4), + message: 'Flutter exited before Runtime Target URI was available.', + stderrLines: const ['stderr line 6', 'stderr line 45'], + ), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect(rendered, contains('Target App launch failed after 4s')); + expect( + rendered, + contains('Flutter exited before Runtime Target URI was available.'), + ); + expect(rendered, contains('Flutter stderr tail:')); + expect(rendered, contains('stderr line 6')); + expect(rendered, contains('stderr line 45')); + }); + }); + + test('redraws the interactive launch panel in place', () async { + await FileTestkit.runZoned(() async { + final File output = File('launch_refresh.log'); + final IOSink sink = output.openWrite(); + DateTime now = DateTime.utc(2026, 6, 30, 12, 0, 1); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer( + sink: sink, + interactive: true, + clock: () => now, + ); + final TargetAppLaunchStartedEvent started = TargetAppLaunchStartedEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + choices: const TargetAppLaunchChoices(flavor: 'staging'), + ); + + renderer.render(started); + now = DateTime.utc(2026, 6, 30, 12, 0, 2); + renderer.render(started); + renderer.render( + TargetAppLaunchSucceededEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + finishedAt: DateTime.utc(2026, 6, 30, 12, 0, 3), + choices: started.choices, + ), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect(rendered, contains('\u001b[')); + final String plain = TerminalStyle.stripAnsi(rendered); + expect(plain, contains('> Target App Launch')); + expect(plain, contains('⏳ Waiting for Runtime Target... elapsed 2s')); + expect(plain, contains('Flavor: staging')); + expect(plain, contains('Target App launched in 3s')); + expect(plain, isNot(contains('%'))); + }); + }); + + test('interactive launch heartbeat refreshes elapsed time', () async { + await FileTestkit.runZoned(() async { + final File output = File('launch_heartbeat_refresh.log'); + final IOSink sink = output.openWrite(); + DateTime now = DateTime.utc(2026, 6, 30, 12, 0, 1); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer( + sink: sink, + interactive: true, + clock: () => now, + ); + final TargetAppLaunchStartedEvent started = TargetAppLaunchStartedEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + ); + + renderer.render(started); + now = DateTime.utc(2026, 6, 30, 12, 0, 11); + renderer.render( + TargetAppLaunchHeartbeatEvent( + startedAt: started.startedAt, + heartbeatAt: now, + choices: started.choices, + ), + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect(rendered, contains('\u001b[')); + final String plain = TerminalStyle.stripAnsi(rendered); + expect(plain, contains('⏳ Waiting for Runtime Target... elapsed 11s')); + }); + }); + + test( + 'non-interactive launch heartbeat prints every ten seconds until stopped', + () async { + await FileTestkit.runZoned(() async { + final File output = File('launch_heartbeat.log'); + final IOSink sink = output.openWrite(); + DateTime now = DateTime.utc(2026, 6, 30, 12); + final TargetAppLaunchProgressRenderer renderer = + TargetAppLaunchProgressRenderer(sink: sink, clock: () => now); + final StreamController ticks = StreamController(); + final TargetAppLaunchHeartbeat heartbeat = TargetAppLaunchHeartbeat( + ticks: ticks.stream, + onProgress: renderer.render, + clock: () => now, + ); + final TargetAppLaunchStartedEvent started = TargetAppLaunchStartedEvent( + startedAt: DateTime.utc(2026, 6, 30, 12), + ); + + heartbeat.start(started); + now = DateTime.utc(2026, 6, 30, 12, 0, 9); + ticks.add(null); + await Future.delayed(Duration.zero); + now = DateTime.utc(2026, 6, 30, 12, 0, 10); + ticks.add(null); + await Future.delayed(Duration.zero); + await heartbeat.stop(); + now = DateTime.utc(2026, 6, 30, 12, 0, 20); + ticks.add(null); + await Future.delayed(Duration.zero); + await ticks.close(); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect( + rendered + .split('\n') + .where( + (String line) => + line.contains('Launching Target App Package... elapsed'), + ) + .toList(), + ['Launching Target App Package... elapsed 10s'], + ); + }); + }, + ); +} diff --git a/test/test_command_cli_test.dart b/test/test_command_cli_test.dart new file mode 100644 index 0000000..3490fab --- /dev/null +++ b/test/test_command_cli_test.dart @@ -0,0 +1,489 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_testkit/file_testkit.dart'; +import 'package:flutter_pilot/flutter_pilot.dart'; +import 'package:test/test.dart'; + +import 'support/test_command_fakes.dart'; + +/// Verifies `flutter_pilot test` CLI mode selection and output wiring. +void main() { + test('test command delegates no-argument Project Run from pilot', () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/login.yaml').writeAsStringSync(''' +scenario: + name: login +steps: + - capture: {} +'''); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + projectRunExecutor: projectExecutor, + ).run(['test']); + + expect(exitCode, 0); + expect(projectExecutor.options.discoveryRootPath, 'pilot'); + expect( + projectExecutor.options.scenarios.map( + (ProjectScenarioFile file) => file.relativePath, + ), + ['login.yaml'], + ); + }); + }); + + test( + 'test command delegates directory input as a focused Project Run', + () async { + await FileTestkit.runZoned(() async { + Directory('flows').createSync(); + File('flows/checkout.yaml').writeAsStringSync(''' +scenario: + name: checkout +steps: + - capture: {} +'''); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + projectRunExecutor: projectExecutor, + ).run(['test', 'flows']); + + expect(exitCode, 0); + expect(projectExecutor.options.discoveryRootPath, 'flows'); + expect( + projectExecutor.options.scenarios.map( + (ProjectScenarioFile file) => file.relativePath, + ), + ['checkout.yaml'], + ); + }); + }, + ); + + test('test command keeps file input in single Entry Scenario mode', () async { + await FileTestkit.runZoned(() async { + final File scenarioFile = File('scenario.yaml') + ..writeAsStringSync(''' +steps: + - capture: {} +'''); + final FakeTestCommandExecutor executor = FakeTestCommandExecutor( + report: passedScenarioRunReport(), + ); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + testCommandExecutor: executor, + projectRunExecutor: projectExecutor, + ).run(['test', scenarioFile.path]); + + expect(exitCode, 0); + expect(executor.options.scenario.name, 'scenario'); + expect(projectExecutor.ran, isFalse); + }); + }); + + test('test command rejects multiple positional inputs', () async { + await FileTestkit.runZoned(() async { + File('a.yaml').writeAsStringSync(''' +steps: + - capture: {} +'''); + File('b.yaml').writeAsStringSync(''' +steps: + - capture: {} +'''); + + final int exitCode = await FlutterPilotCli().run([ + 'test', + 'a.yaml', + 'b.yaml', + ]); + + expect(exitCode, 64); + }); + }); + + test('test command does not introduce glob-style inputs', () async { + await FileTestkit.runZoned(() async { + File('scenario.yaml').writeAsStringSync(''' +steps: + - capture: {} +'''); + + final int exitCode = await FlutterPilotCli().run([ + 'test', + '*.yaml', + ]); + + expect(exitCode, 64); + }); + }); + + test( + 'test command reports empty Project Run discovery before launch', + () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/library.yaml').writeAsStringSync(''' +steps: + - capture: {} +'''); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + projectRunExecutor: projectExecutor, + ).run(['test']); + + expect(exitCode, 64); + expect(projectExecutor.ran, isFalse); + }); + }, + ); + + test('test command rejects --until in Project Run mode', () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/login.yaml').writeAsStringSync(''' +scenario: + name: login +steps: + - capture: {} +'''); + + final int exitCode = await FlutterPilotCli().run([ + 'test', + '--until', + '1', + ]); + + expect(exitCode, 64); + }); + }); + + test('test command rejects --print in Project Run mode', () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/login.yaml').writeAsStringSync(''' +scenario: + name: login +steps: + - capture: {} +'''); + + final int exitCode = await FlutterPilotCli().run([ + 'test', + '--print', + 'widget-tree', + ]); + + expect(exitCode, 64); + }); + }); + + test('test command delegates validated options to the executor', () async { + await FileTestkit.runZoned(() async { + final File scenarioFile = File('scenario.yaml') + ..writeAsStringSync(''' +scenario: + name: delegated +steps: + - label: submit + tap: + byText: Continue +'''); + final FakeTestCommandExecutor executor = FakeTestCommandExecutor( + report: passedScenarioRunReport(), + ); + + final int exitCode = await FlutterPilotCli(testCommandExecutor: executor) + .run([ + 'test', + scenarioFile.path, + '--device', + 'Pixel', + '--flavor', + 'staging', + '--target', + 'lib/main_staging.dart', + '--until', + 'submit', + '--print', + 'widget-tree', + '--json', + ]); + + expect(exitCode, 0); + expect(executor.options.scenario.name, 'delegated'); + expect(executor.options.device, 'Pixel'); + expect(executor.options.flavor, 'staging'); + expect(executor.options.target, 'lib/main_staging.dart'); + expect(executor.options.stopPoint, isA()); + expect(executor.options.printDiagnostics, { + PrintDiagnostic.widgetTree, + }); + expect(executor.options.jsonOutput, isTrue); + expect(executor.onProgress, isNull); + }); + }); + + test('test command rejects empty Project Run launch options', () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/login.yaml').writeAsStringSync(''' +scenario: + name: login +steps: + - capture: {} +'''); + final List> optionCases = >[ + ['--device', ' '], + ['--flavor', ' '], + ['--target', ' '], + ]; + + for (final List optionCase in optionCases) { + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + projectRunExecutor: projectExecutor, + ).run(['test', ...optionCase]); + + expect(exitCode, 64); + expect(projectExecutor.ran, isFalse); + } + }); + }); + + test( + 'test command enables Step progress for human-readable output', + () async { + await FileTestkit.runZoned(() async { + final File scenarioFile = File('scenario.yaml') + ..writeAsStringSync(''' +scenario: + name: progress +steps: + - tap: + byText: Continue +'''); + final FakeTestCommandExecutor executor = FakeTestCommandExecutor( + report: passedScenarioRunReport(), + ); + + final int exitCode = await FlutterPilotCli( + testCommandExecutor: executor, + ).run(['test', scenarioFile.path]); + + expect(exitCode, 0); + expect(executor.onProgress, isNotNull); + }); + }, + ); + + test('test command enables Step progress for Project Run output', () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/login.yaml').writeAsStringSync(''' +scenario: + name: project_step_progress +steps: + - capture: {} +'''); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + projectRunExecutor: projectExecutor, + ).run(['test']); + + expect(exitCode, 0); + expect(projectExecutor.onProgress, isNotNull); + }); + }); + + test( + 'test command enables Target App Launch Progress for human-readable output', + () async { + await FileTestkit.runZoned(() async { + final File scenarioFile = File('scenario.yaml') + ..writeAsStringSync(''' +scenario: + name: launch_progress +steps: + - tap: + byText: Continue +'''); + final FakeTestCommandExecutor executor = FakeTestCommandExecutor( + report: passedScenarioRunReport(), + ); + + final int exitCode = await FlutterPilotCli( + testCommandExecutor: executor, + ).run(['test', scenarioFile.path]); + + expect(exitCode, 0); + expect(executor.onLaunchProgress, isNotNull); + }); + }, + ); + + test( + 'test command enables Target App Launch Progress for Project Run output', + () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/login.yaml').writeAsStringSync(''' +scenario: + name: project_launch_progress +steps: + - capture: {} +'''); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + projectRunExecutor: projectExecutor, + ).run(['test']); + + expect(exitCode, 0); + expect(projectExecutor.onLaunchProgress, isNotNull); + expect(projectExecutor.launchHeartbeatEnabled, isTrue); + }); + }, + ); + + test( + 'test command keeps Project Run --json scoped to progress suppression', + () async { + await FileTestkit.runZoned(() async { + Directory('pilot').createSync(); + File('pilot/login.yaml').writeAsStringSync(''' +scenario: + name: project_json +steps: + - capture: {} +'''); + final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( + report: passedProjectRunResult(), + ); + + final int exitCode = await FlutterPilotCli( + projectRunExecutor: projectExecutor, + ).run(['test', '--json']); + + expect(exitCode, 0); + expect(projectExecutor.options.jsonOutput, isTrue); + expect(projectExecutor.onProgress, isNull); + expect(projectExecutor.onLaunchProgress, isNull); + }); + }, + ); + + test( + 'test command suppresses Target App Launch Progress for JSON output', + () async { + await FileTestkit.runZoned(() async { + final File scenarioFile = File('scenario.yaml') + ..writeAsStringSync(''' +scenario: + name: launch_progress_json +steps: + - tap: + byText: Continue +'''); + final FakeTestCommandExecutor executor = FakeTestCommandExecutor( + report: passedScenarioRunReport(), + ); + + final int exitCode = await FlutterPilotCli( + testCommandExecutor: executor, + ).run(['test', scenarioFile.path, '--json']); + + expect(exitCode, 0); + expect(executor.onLaunchProgress, isNull); + }); + }, + ); + + test( + 'test command suppresses JSON launch failure progress but preserves failure', + () async { + await FileTestkit.runZoned(() async { + final File scenarioFile = File('scenario.yaml') + ..writeAsStringSync(''' +scenario: + name: launch_progress_failure_json +steps: + - tap: + byText: Continue +'''); + final FakeTestCommandExecutor executor = FakeTestCommandExecutor( + report: passedScenarioRunReport(), + exception: const TestCommandException( + message: 'Flutter exited before Runtime Target URI was available.', + exitCode: 1, + ), + ); + + final int exitCode = await FlutterPilotCli( + testCommandExecutor: executor, + ).run(['test', scenarioFile.path, '--json']); + + expect(exitCode, 1); + expect(executor.onLaunchProgress, isNull); + }); + }, + ); + + test('test command does not duplicate rendered launch failure', () async { + await FileTestkit.runZoned(() async { + final File output = File('launch_failure.log'); + final IOSink sink = output.openWrite(); + final File scenarioFile = File('scenario.yaml') + ..writeAsStringSync(''' +scenario: + name: launch_failure +steps: + - tap: + byText: Continue +'''); + final FakeTestCommandExecutor executor = FakeTestCommandExecutor( + report: passedScenarioRunReport(), + exception: const TestCommandException( + message: 'Flutter exited before Runtime Target URI was available.', + exitCode: 1, + alreadyRendered: true, + ), + ); + + final int exitCode = await runZoned( + () => FlutterPilotCli( + testCommandExecutor: executor, + ).run(['test', scenarioFile.path]), + zoneValues: {#stderr: sink}, + ); + await sink.close(); + + final String rendered = output.readAsStringSync(); + expect(exitCode, 1); + expect(rendered, isNot(contains('Runtime Target URI'))); + }); + }); +} diff --git a/test/test_command_executor_test.dart b/test/test_command_executor_test.dart index a66e36f..a7ca908 100644 --- a/test/test_command_executor_test.dart +++ b/test/test_command_executor_test.dart @@ -1,727 +1,23 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; -import 'package:path/path.dart' as p; import 'package:test/test.dart'; -/// Verifies `test` command orchestration through injectable dependencies. -/// -/// These tests avoid launching a real Flutter app while still exercising the -/// command path that parses a Scenario, builds launch options, runs through an -/// executor, prints report paths, and performs cleanup. -void main() { - test('test command delegates no-argument Project Run from pilot', () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/login.yaml').writeAsStringSync(''' -scenario: - name: login -steps: - - capture: {} -'''); - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - projectRunExecutor: projectExecutor, - ).run(['test']); - - expect(exitCode, 0); - expect(projectExecutor.options.discoveryRootPath, 'pilot'); - expect( - projectExecutor.options.scenarios.map( - (ProjectScenarioFile file) => file.relativePath, - ), - ['login.yaml'], - ); - }); - }); - - test( - 'test command delegates directory input as a focused Project Run', - () async { - await FileTestkit.runZoned(() async { - Directory('flows').createSync(); - File('flows/checkout.yaml').writeAsStringSync(''' -scenario: - name: checkout -steps: - - capture: {} -'''); - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - projectRunExecutor: projectExecutor, - ).run(['test', 'flows']); - - expect(exitCode, 0); - expect(projectExecutor.options.discoveryRootPath, 'flows'); - expect( - projectExecutor.options.scenarios.map( - (ProjectScenarioFile file) => file.relativePath, - ), - ['checkout.yaml'], - ); - }); - }, - ); - - test('Project Run output renders deterministic all-passed summary', () { - const ProjectRunResult report = ProjectRunResult( - passed: true, - status: ProjectRunStatus.passed, - projectRunReportPath: - '.runs/2026-07-01_09-30_project-run/project_run_report.json', - scenarioReports: [ - ProjectRunScenarioOutputReport( - scenarioPath: 'checkout.yaml', - status: ProjectScenarioRunStatus.passed, - runReportPath: - '.runs/2026-07-01_09-30_project-run/' - '2026-07-01_09-30_checkout/run_report.json', - htmlReportPath: - '.runs/2026-07-01_09-30_project-run/' - '2026-07-01_09-30_checkout/timeline.html', - ), - ProjectRunScenarioOutputReport( - scenarioPath: 'login.yaml', - status: ProjectScenarioRunStatus.passed, - runReportPath: - '.runs/2026-07-01_09-30_project-run/' - '2026-07-01_09-30_login/run_report.json', - htmlReportPath: - '.runs/2026-07-01_09-30_project-run/' - '2026-07-01_09-30_login/timeline.html', - ), - ], - ); - - final String rendered = TestCommandOutput.renderProjectRunSummary(report); - - expect(rendered, ''' -Project Run: passed -Project Run report: .runs/2026-07-01_09-30_project-run/project_run_report.json -Scenario: checkout.yaml (passed) -Run report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_checkout/run_report.json -HTML report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_checkout/timeline.html -Scenario: login.yaml (passed) -Run report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_login/run_report.json -HTML report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_login/timeline.html -'''); - }); - - test('Project Run output renders partially failed summary', () { - const ProjectRunResult report = ProjectRunResult( - passed: false, - status: ProjectRunStatus.failed, - projectRunReportPath: '.runs/project-run/project_run_report.json', - scenarioReports: [ - ProjectRunScenarioOutputReport( - scenarioPath: 'login.yaml', - status: ProjectScenarioRunStatus.failed, - runReportPath: '.runs/project-run/login/run_report.json', - htmlReportPath: '.runs/project-run/login/timeline.html', - ), - ProjectRunScenarioOutputReport( - scenarioPath: 'checkout.yaml', - status: ProjectScenarioRunStatus.passed, - runReportPath: '.runs/project-run/checkout/run_report.json', - htmlReportPath: '.runs/project-run/checkout/timeline.html', - ), - ], - ); - - final String rendered = TestCommandOutput.renderProjectRunSummary(report); - - expect(rendered, contains('Project Run: failed')); - expect(rendered, contains('Scenario: login.yaml (failed)')); - expect(rendered, contains('Scenario: checkout.yaml (passed)')); - }); - - test('Project Run output renders environment-level failure summary', () { - const ProjectRunResult report = ProjectRunResult( - passed: false, - status: ProjectRunStatus.environmentFailed, - projectRunReportPath: '.runs/project-run/project_run_report.json', - scenarioReports: [], - ); - - final String rendered = TestCommandOutput.renderProjectRunSummary(report); - - expect(rendered, ''' -Project Run: environmentFailed -Project Run report: .runs/project-run/project_run_report.json -'''); - }); - - test('test command keeps file input in single Entry Scenario mode', () async { - await FileTestkit.runZoned(() async { - final File scenarioFile = File('scenario.yaml') - ..writeAsStringSync(''' -steps: - - capture: {} -'''); - final FakeTestCommandExecutor executor = FakeTestCommandExecutor( - report: _passedReport(), - ); - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - testCommandExecutor: executor, - projectRunExecutor: projectExecutor, - ).run(['test', scenarioFile.path]); - - expect(exitCode, 0); - expect(executor.options.scenario.name, 'scenario'); - expect(projectExecutor.ran, isFalse); - }); - }); - - test('test command rejects multiple positional inputs', () async { - await FileTestkit.runZoned(() async { - File('a.yaml').writeAsStringSync(''' -steps: - - capture: {} -'''); - File('b.yaml').writeAsStringSync(''' -steps: - - capture: {} -'''); - - final int exitCode = await FlutterPilotCli().run([ - 'test', - 'a.yaml', - 'b.yaml', - ]); - - expect(exitCode, 64); - }); - }); - - test('test command does not introduce glob-style inputs', () async { - await FileTestkit.runZoned(() async { - File('scenario.yaml').writeAsStringSync(''' -steps: - - capture: {} -'''); - - final int exitCode = await FlutterPilotCli().run([ - 'test', - '*.yaml', - ]); - - expect(exitCode, 64); - }); - }); - - test( - 'test command reports empty Project Run discovery before launch', - () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/library.yaml').writeAsStringSync(''' -steps: - - capture: {} -'''); - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - projectRunExecutor: projectExecutor, - ).run(['test']); - - expect(exitCode, 64); - expect(projectExecutor.ran, isFalse); - }); - }, - ); - - test('test command rejects --until in Project Run mode', () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/login.yaml').writeAsStringSync(''' -scenario: - name: login -steps: - - capture: {} -'''); - - final int exitCode = await FlutterPilotCli().run([ - 'test', - '--until', - '1', - ]); - - expect(exitCode, 64); - }); - }); - - test('test command rejects --print in Project Run mode', () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/login.yaml').writeAsStringSync(''' -scenario: - name: login -steps: - - capture: {} -'''); - - final int exitCode = await FlutterPilotCli().run([ - 'test', - '--print', - 'widget-tree', - ]); - - expect(exitCode, 64); - }); - }); - - test('test command delegates validated options to the executor', () async { - await FileTestkit.runZoned(() async { - final File scenarioFile = File('scenario.yaml') - ..writeAsStringSync(''' -scenario: - name: delegated -steps: - - label: submit - tap: - byText: Continue -'''); - final FakeTestCommandExecutor executor = FakeTestCommandExecutor( - report: _passedReport(), - ); - - final int exitCode = await FlutterPilotCli(testCommandExecutor: executor) - .run([ - 'test', - scenarioFile.path, - '--device', - 'Pixel', - '--flavor', - 'staging', - '--target', - 'lib/main_staging.dart', - '--until', - 'submit', - '--print', - 'widget-tree', - '--json', - ]); - - expect(exitCode, 0); - expect(executor.options.scenario.name, 'delegated'); - expect(executor.options.device, 'Pixel'); - expect(executor.options.flavor, 'staging'); - expect(executor.options.target, 'lib/main_staging.dart'); - expect(executor.options.stopPoint, isA()); - expect(executor.options.printDiagnostics, { - PrintDiagnostic.widgetTree, - }); - expect(executor.options.jsonOutput, isTrue); - expect(executor.onProgress, isNull); - }); - }); - - test('test command rejects empty Project Run launch options', () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/login.yaml').writeAsStringSync(''' -scenario: - name: login -steps: - - capture: {} -'''); - final List> optionCases = >[ - ['--device', ' '], - ['--flavor', ' '], - ['--target', ' '], - ]; - - for (final List optionCase in optionCases) { - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - projectRunExecutor: projectExecutor, - ).run(['test', ...optionCase]); - - expect(exitCode, 64); - expect(projectExecutor.ran, isFalse); - } - }); - }); - - test( - 'test command enables Step progress for human-readable output', - () async { - await FileTestkit.runZoned(() async { - final File scenarioFile = File('scenario.yaml') - ..writeAsStringSync(''' -scenario: - name: progress -steps: - - tap: - byText: Continue -'''); - final FakeTestCommandExecutor executor = FakeTestCommandExecutor( - report: _passedReport(), - ); - - final int exitCode = await FlutterPilotCli( - testCommandExecutor: executor, - ).run(['test', scenarioFile.path]); - - expect(exitCode, 0); - expect(executor.onProgress, isNotNull); - }); - }, - ); - - test('test command enables Step progress for Project Run output', () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/login.yaml').writeAsStringSync(''' -scenario: - name: project_step_progress -steps: - - capture: {} -'''); - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - projectRunExecutor: projectExecutor, - ).run(['test']); - - expect(exitCode, 0); - expect(projectExecutor.onProgress, isNotNull); - }); - }); - - test( - 'test command enables Target App Launch Progress for human-readable output', - () async { - await FileTestkit.runZoned(() async { - final File scenarioFile = File('scenario.yaml') - ..writeAsStringSync(''' -scenario: - name: launch_progress -steps: - - tap: - byText: Continue -'''); - final FakeTestCommandExecutor executor = FakeTestCommandExecutor( - report: _passedReport(), - ); - - final int exitCode = await FlutterPilotCli( - testCommandExecutor: executor, - ).run(['test', scenarioFile.path]); - - expect(exitCode, 0); - expect(executor.onLaunchProgress, isNotNull); - }); - }, - ); - - test( - 'test command enables Target App Launch Progress for Project Run output', - () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/login.yaml').writeAsStringSync(''' -scenario: - name: project_launch_progress -steps: - - capture: {} -'''); - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - projectRunExecutor: projectExecutor, - ).run(['test']); - - expect(exitCode, 0); - expect(projectExecutor.onLaunchProgress, isNotNull); - expect(projectExecutor.launchHeartbeatEnabled, isTrue); - }); - }, - ); - - test( - 'test command keeps Project Run --json scoped to progress suppression', - () async { - await FileTestkit.runZoned(() async { - Directory('pilot').createSync(); - File('pilot/login.yaml').writeAsStringSync(''' -scenario: - name: project_json -steps: - - capture: {} -'''); - final FakeProjectRunExecutor projectExecutor = FakeProjectRunExecutor( - report: _passedProjectReport(), - ); - - final int exitCode = await FlutterPilotCli( - projectRunExecutor: projectExecutor, - ).run(['test', '--json']); - - expect(exitCode, 0); - expect(projectExecutor.options.jsonOutput, isTrue); - expect(projectExecutor.onProgress, isNull); - expect(projectExecutor.onLaunchProgress, isNull); - }); - }, - ); - - test( - 'test command suppresses Target App Launch Progress for JSON output', - () async { - await FileTestkit.runZoned(() async { - final File scenarioFile = File('scenario.yaml') - ..writeAsStringSync(''' -scenario: - name: launch_progress_json -steps: - - tap: - byText: Continue -'''); - final FakeTestCommandExecutor executor = FakeTestCommandExecutor( - report: _passedReport(), - ); - - final int exitCode = await FlutterPilotCli( - testCommandExecutor: executor, - ).run(['test', scenarioFile.path, '--json']); - - expect(exitCode, 0); - expect(executor.onLaunchProgress, isNull); - }); - }, - ); - - test( - 'test command suppresses JSON launch failure progress but preserves failure', - () async { - await FileTestkit.runZoned(() async { - final File scenarioFile = File('scenario.yaml') - ..writeAsStringSync(''' -scenario: - name: launch_progress_failure_json -steps: - - tap: - byText: Continue -'''); - final FakeTestCommandExecutor executor = FakeTestCommandExecutor( - report: _passedReport(), - exception: const TestCommandException( - message: 'Flutter exited before Runtime Target URI was available.', - exitCode: 1, - ), - ); - - final int exitCode = await FlutterPilotCli( - testCommandExecutor: executor, - ).run(['test', scenarioFile.path, '--json']); - - expect(exitCode, 1); - expect(executor.onLaunchProgress, isNull); - }); - }, - ); - - test('test command does not duplicate rendered launch failure', () async { - await FileTestkit.runZoned(() async { - final File output = File('launch_failure.log'); - final IOSink sink = output.openWrite(); - final File scenarioFile = File('scenario.yaml') - ..writeAsStringSync(''' -scenario: - name: launch_failure -steps: - - tap: - byText: Continue -'''); - final FakeTestCommandExecutor executor = FakeTestCommandExecutor( - report: _passedReport(), - exception: const TestCommandException( - message: 'Flutter exited before Runtime Target URI was available.', - exitCode: 1, - alreadyRendered: true, - ), - ); - - final int exitCode = await runZoned( - () => FlutterPilotCli( - testCommandExecutor: executor, - ).run(['test', scenarioFile.path]), - zoneValues: {#stderr: sink}, - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect(exitCode, 1); - expect(rendered, isNot(contains('Runtime Target URI'))); - }); - }); - - test('plain launch progress renders before Step progress', () async { - await FileTestkit.runZoned(() async { - final File output = File('progress.log'); - final IOSink sink = output.openWrite(); - final TargetAppLaunchProgressRenderer launchRenderer = - TargetAppLaunchProgressRenderer( - sink: sink, - clock: () => DateTime.utc(2026, 6, 30, 12, 0, 38), - ); - final StepProgressRenderer stepRenderer = StepProgressRenderer( - sink: sink, - ); - - launchRenderer.render( - TargetAppLaunchStartedEvent(startedAt: DateTime.utc(2026, 6, 30, 12)), - ); - launchRenderer.render( - TargetAppLaunchSucceededEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - finishedAt: DateTime.utc(2026, 6, 30, 12, 0, 38), - ), - ); - stepRenderer.render( - const StepStartedEvent( - scenarioName: 'launch_progress', - totalSteps: 1, - step: ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - action: 'tap', - ), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect(rendered, contains('> Target App Launch')); - expect(rendered, contains('Launching Target App Package... elapsed 38s')); - expect(rendered, contains('Target App launched in 38s')); - expect(rendered, contains('Scenario: launch_progress (1 steps)')); - expect( - rendered.indexOf('Target App launched in 38s'), - lessThan(rendered.indexOf('Scenario: launch_progress (1 steps)')), - ); - }); - }); - - test( - 'plain launch progress shows explicit Target Device selection', - () async { - await FileTestkit.runZoned(() async { - final File output = File('progress.log'); - final IOSink sink = output.openWrite(); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer(sink: sink); - - renderer.render( - TargetAppLaunchStartedEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - choices: const TargetAppLaunchChoices( - targetDevice: TargetDevice( - id: 'pixel-8', - name: 'Pixel 8', - targetPlatform: 'android-arm64', - emulator: true, - sdk: 'Android 35', - ), - selectionReason: TargetDeviceSelectionReason.explicit( - selector: 'Pixel', - ), - ), - ), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect( - rendered, - contains( - 'Target Device: pixel-8 (Pixel 8, android-arm64, Android 35)', - ), - ); - expect(rendered, contains('Selection: --device Pixel')); - }); - }, - ); - - test( - 'test command chooses interactive progress only for terminals', - () async { - await FileTestkit.runZoned(() async { - final IOSink sink = File('progress.log').openWrite(); - try { - final StepProgressRenderer? jsonRenderer = - TestCommandOutput.stepProgressRenderer( - sink: sink, - jsonOutput: true, - stderrHasTerminal: true, - ); - final StepProgressRenderer? terminalRenderer = - TestCommandOutput.stepProgressRenderer( - sink: sink, - jsonOutput: false, - stderrHasTerminal: true, - ); - final StepProgressRenderer? redirectedRenderer = - TestCommandOutput.stepProgressRenderer( - sink: sink, - jsonOutput: false, - stderrHasTerminal: false, - ); - final TargetAppLaunchProgressRenderer? terminalLaunchRenderer = - TestCommandOutput.targetAppLaunchProgressRenderer( - sink: sink, - jsonOutput: false, - stderrHasTerminal: true, - ); - final TargetAppLaunchProgressRenderer? redirectedLaunchRenderer = - TestCommandOutput.targetAppLaunchProgressRenderer( - sink: sink, - jsonOutput: false, - stderrHasTerminal: false, - ); - - expect(jsonRenderer, isNull); - expect(terminalRenderer?.interactive, isTrue); - expect(redirectedRenderer?.interactive, isFalse); - expect(terminalLaunchRenderer?.interactive, isTrue); - expect(redirectedLaunchRenderer?.interactive, isFalse); - } finally { - await sink.close(); - } - }); - }, - ); +import 'support/test_command_fakes.dart'; +/// Verifies single Entry Scenario execution through `DefaultTestCommandExecutor`. +void main() { test('default executor launches app, runs scenario, and cleans up', () async { await FileTestkit.runZoned(() async { final FakeTargetAppProcess process = FakeTargetAppProcess(); final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( process, ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( deviceDiscovery: FakeDeviceDiscovery(), launcher: TargetAppLauncher(starter: starter), @@ -794,7 +90,9 @@ steps: ); final FakeTestExecutionSessionFactory sessionFactory = FakeTestExecutionSessionFactory(session); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( sessionFactory: sessionFactory, runnerFactory: FakeScenarioRunnerFactory(runner), @@ -901,7 +199,9 @@ steps: final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( process, ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( deviceDiscovery: FakeDeviceDiscovery(), launcher: TargetAppLauncher(starter: starter), @@ -957,7 +257,9 @@ steps: final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( process, ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( deviceDiscovery: FakeDeviceDiscovery(), launcher: TargetAppLauncher(starter: starter), @@ -1018,7 +320,9 @@ steps: final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( process, ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( deviceDiscovery: FakeDeviceDiscovery( flutterDevices: [ @@ -1093,7 +397,9 @@ steps: final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( process, ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( deviceDiscovery: FakeDeviceDiscovery( flutterDevices: [ @@ -1164,61 +470,24 @@ steps: ); test( - 'plain launch progress shows recording auto-selected Target Device', + 'default executor reports recording auto-selected launch choices', () async { await FileTestkit.runZoned(() async { - final File output = File('progress.log'); - final IOSink sink = output.openWrite(); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer(sink: sink); - - renderer.render( - TargetAppLaunchStartedEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - choices: const TargetAppLaunchChoices( - targetDevice: TargetDevice( + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery( + flutterDevices: [ + FlutterDevice( id: 'pixel-8', name: 'Pixel 8', targetPlatform: 'android-arm64', - emulator: true, - sdk: 'Android 35', - ), - selectionReason: - TargetDeviceSelectionReason.autoSelectedForRecording(), - ), - ), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect( - rendered, - contains( - 'Target Device: pixel-8 (Pixel 8, android-arm64, Android 35)', - ), - ); - expect(rendered, contains('Selection: auto-selected for recording')); - }); - }, - ); - - test( - 'default executor reports recording auto-selected launch choices', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); - final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - deviceDiscovery: FakeDeviceDiscovery( - flutterDevices: [ - FlutterDevice( - id: 'pixel-8', - name: 'Pixel 8', - targetPlatform: 'android-arm64', - isSupported: true, + isSupported: true, emulator: true, sdk: 'Android 35', ), @@ -1280,205 +549,6 @@ steps: }, ); - test( - 'plain launch progress shows Flutter default without placeholders', - () async { - await FileTestkit.runZoned(() async { - final File output = File('progress.log'); - final IOSink sink = output.openWrite(); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer(sink: sink); - - renderer.render( - TargetAppLaunchStartedEvent(startedAt: DateTime.utc(2026, 6, 30, 12)), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect(rendered, contains('Target Device: Flutter default')); - expect(rendered, isNot(contains('Selection:'))); - expect(rendered, isNot(contains('Flavor:'))); - expect(rendered, isNot(contains('Entrypoint:'))); - }); - }, - ); - - test( - 'plain launch progress shows flavor and entrypoint when provided', - () async { - await FileTestkit.runZoned(() async { - final File output = File('progress.log'); - final IOSink sink = output.openWrite(); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer(sink: sink); - - renderer.render( - TargetAppLaunchStartedEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - choices: const TargetAppLaunchChoices( - flavor: 'staging', - target: 'lib/main_staging.dart', - ), - ), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect(rendered, contains('Flavor: staging')); - expect(rendered, contains('Entrypoint: lib/main_staging.dart')); - }); - }, - ); - - test('plain launch progress shows bounded failure details', () async { - await FileTestkit.runZoned(() async { - final File output = File('progress.log'); - final IOSink sink = output.openWrite(); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer(sink: sink); - - renderer.render( - TargetAppLaunchFailedEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - failedAt: DateTime.utc(2026, 6, 30, 12, 0, 4), - message: 'Flutter exited before Runtime Target URI was available.', - stderrLines: const ['stderr line 6', 'stderr line 45'], - ), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect(rendered, contains('Target App launch failed after 4s')); - expect( - rendered, - contains('Flutter exited before Runtime Target URI was available.'), - ); - expect(rendered, contains('Flutter stderr tail:')); - expect(rendered, contains('stderr line 6')); - expect(rendered, contains('stderr line 45')); - }); - }); - - test('redraws the interactive launch panel in place', () async { - await FileTestkit.runZoned(() async { - final File output = File('launch_refresh.log'); - final IOSink sink = output.openWrite(); - DateTime now = DateTime.utc(2026, 6, 30, 12, 0, 1); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer( - sink: sink, - interactive: true, - clock: () => now, - ); - final TargetAppLaunchStartedEvent started = TargetAppLaunchStartedEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - choices: const TargetAppLaunchChoices(flavor: 'staging'), - ); - - renderer.render(started); - now = DateTime.utc(2026, 6, 30, 12, 0, 2); - renderer.render(started); - renderer.render( - TargetAppLaunchSucceededEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - finishedAt: DateTime.utc(2026, 6, 30, 12, 0, 3), - choices: started.choices, - ), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect(rendered, contains('\u001b[')); - final String plain = TerminalStyle.stripAnsi(rendered); - expect(plain, contains('> Target App Launch')); - expect(plain, contains('⏳ Waiting for Runtime Target... elapsed 2s')); - expect(plain, contains('Flavor: staging')); - expect(plain, contains('Target App launched in 3s')); - expect(plain, isNot(contains('%'))); - }); - }); - - test('interactive launch heartbeat refreshes elapsed time', () async { - await FileTestkit.runZoned(() async { - final File output = File('launch_heartbeat_refresh.log'); - final IOSink sink = output.openWrite(); - DateTime now = DateTime.utc(2026, 6, 30, 12, 0, 1); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer( - sink: sink, - interactive: true, - clock: () => now, - ); - final TargetAppLaunchStartedEvent started = TargetAppLaunchStartedEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - ); - - renderer.render(started); - now = DateTime.utc(2026, 6, 30, 12, 0, 11); - renderer.render( - TargetAppLaunchHeartbeatEvent( - startedAt: started.startedAt, - heartbeatAt: now, - choices: started.choices, - ), - ); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect(rendered, contains('\u001b[')); - final String plain = TerminalStyle.stripAnsi(rendered); - expect(plain, contains('⏳ Waiting for Runtime Target... elapsed 11s')); - }); - }); - - test( - 'non-interactive launch heartbeat prints every ten seconds until stopped', - () async { - await FileTestkit.runZoned(() async { - final File output = File('launch_heartbeat.log'); - final IOSink sink = output.openWrite(); - DateTime now = DateTime.utc(2026, 6, 30, 12); - final TargetAppLaunchProgressRenderer renderer = - TargetAppLaunchProgressRenderer(sink: sink, clock: () => now); - final StreamController ticks = StreamController(); - final TargetAppLaunchHeartbeat heartbeat = TargetAppLaunchHeartbeat( - ticks: ticks.stream, - onProgress: renderer.render, - clock: () => now, - ); - final TargetAppLaunchStartedEvent started = TargetAppLaunchStartedEvent( - startedAt: DateTime.utc(2026, 6, 30, 12), - ); - - heartbeat.start(started); - now = DateTime.utc(2026, 6, 30, 12, 0, 9); - ticks.add(null); - await Future.delayed(Duration.zero); - now = DateTime.utc(2026, 6, 30, 12, 0, 10); - ticks.add(null); - await Future.delayed(Duration.zero); - await heartbeat.stop(); - now = DateTime.utc(2026, 6, 30, 12, 0, 20); - ticks.add(null); - await Future.delayed(Duration.zero); - await ticks.close(); - await sink.close(); - - final String rendered = output.readAsStringSync(); - expect( - rendered - .split('\n') - .where( - (String line) => - line.contains('Launching Target App Package... elapsed'), - ) - .toList(), - ['Launching Target App Package... elapsed 10s'], - ); - }); - }, - ); - test( 'default executor reports flavor and entrypoint launch choices', () async { @@ -1487,7 +557,9 @@ steps: final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( process, ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( deviceDiscovery: FakeDeviceDiscovery(), launcher: TargetAppLauncher(starter: starter), @@ -1551,7 +623,7 @@ steps: deviceDiscovery: FakeDeviceDiscovery(), launcher: TargetAppLauncher(starter: starter), runnerFactory: FakeScenarioRunnerFactory( - FakeScenarioRunner(_passedReport()), + FakeScenarioRunner(passedScenarioRunReport()), ), ); final Scenario scenario = Scenario( @@ -1613,7 +685,7 @@ steps: deviceDiscovery: FakeDeviceDiscovery(), launcher: TargetAppLauncher(starter: starter), runnerFactory: FakeScenarioRunnerFactory( - FakeScenarioRunner(_passedReport()), + FakeScenarioRunner(passedScenarioRunReport()), ), launchHeartbeatTicks: ticks.stream, launchClock: () => now, @@ -1737,1291 +809,4 @@ steps: await interruptController.close(); }); }); - - test( - 'default Project Run executor reuses one launch with hot restart', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner firstRunner = FakeScenarioRunner( - _passedReportFor('login'), - ); - final FakeScenarioRunner secondRunner = FakeScenarioRunner( - _passedReportFor('checkout'), - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - firstRunner, - secondRunner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ProjectScenarioFile( - path: 'pilot/checkout.yaml', - relativePath: 'checkout.yaml', - scenario: _scenario('checkout'), - ), - ]; - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - while (process.stdinWrites.isEmpty) { - await Future.delayed(Duration.zero); - } - expect(secondRunner.onProgress, isNull); - process.emitStdout( - jsonEncode({'id': 0, 'result': true}), - ); - - final ProjectRunResult report = await reportFuture; - - expect(report.passed, isTrue); - expect(starter.startCount, 1); - expect(process.stdinWrites, [ - '[{"id":0,"method":"app.restart","params":{"appId":"app-1","fullRestart":true}}]\n', - 'q\n', - ]); - expect(firstRunner.scenario.name, 'login'); - expect(secondRunner.scenario.name, 'checkout'); - expect( - firstRunner.runArtifactWriter?.runDirectory.path, - p.join( - Directory.current.path, - '.runs', - '2026-07-01_09-30_project-run', - '2026-07-01_09-30_login', - ), - ); - expect( - secondRunner.runArtifactWriter?.runDirectory.path, - p.join( - Directory.current.path, - '.runs', - '2026-07-01_09-30_project-run', - '2026-07-01_09-30_checkout', - ), - ); - }); - }, - ); - - test( - 'default Project Run executor reuses one Test Execution Session', - () async { - await FileTestkit.runZoned(() async { - final FakeTestExecutionSession session = FakeTestExecutionSession( - runtimeTarget: RuntimeTarget( - vmServiceUri: Uri.parse('ws://127.0.0.1:1234/token=/ws'), - deviceId: 'pixel-8', - ), - ); - final FakeTestExecutionSessionFactory sessionFactory = - FakeTestExecutionSessionFactory(session); - final FakeScenarioRunner firstRunner = FakeScenarioRunner( - _passedReportFor('login'), - ); - final FakeScenarioRunner secondRunner = FakeScenarioRunner( - _passedReportFor('checkout'), - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - sessionFactory: sessionFactory, - runnerFactory: QueueScenarioRunnerFactory([ - firstRunner, - secondRunner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ProjectScenarioFile( - path: 'pilot/checkout.yaml', - relativePath: 'checkout.yaml', - scenario: _scenario('checkout'), - ), - ]; - - final ProjectRunResult result = await executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: 'staging', - target: 'lib/main_staging.dart', - jsonOutput: false, - ), - ); - - expect(result.status, ProjectRunStatus.passed); - expect(sessionFactory.startCount, 1); - expect(sessionFactory.flavor, 'staging'); - expect(sessionFactory.target, 'lib/main_staging.dart'); - expect(session.hotRestartCount, 1); - expect(session.runWithInterruptCount, 2); - expect(session.closeCount, 1); - expect(firstRunner.runtimeTarget.deviceId, 'pixel-8'); - expect(secondRunner.runtimeTarget.deviceId, 'pixel-8'); - }); - }, - ); - - test( - 'default Project Run executor resolves explicit Target Device before launch', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner runner = FakeScenarioRunner( - _passedReportFor('login'), - ); - final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( - flutterDevices: [ - FlutterDevice( - id: 'pixel-8', - name: 'Pixel 8', - targetPlatform: 'android-arm64', - isSupported: true, - emulator: true, - sdk: 'Android 35', - ), - ], - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ]; - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: 'Pixel', - flavor: 'staging', - target: 'lib/main_staging.dart', - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - while (process.stdinWrites.isEmpty) { - await Future.delayed(Duration.zero); - } - process.emitStdout( - jsonEncode({'id': 0, 'result': true}), - ); - - final ProjectRunResult report = await reportFuture; - - expect(report.passed, isTrue); - expect(deviceDiscovery.flutterDeviceListCount, 1); - expect(starter.startedArguments, [ - 'run', - '--machine', - '--device-id', - 'pixel-8', - '--flavor', - 'staging', - '--target', - 'lib/main_staging.dart', - ]); - expect(runner.targetDevice?.id, 'pixel-8'); - }); - }, - ); - - test( - 'default Project Run executor resolves recordable Target Device before launch', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner runner = FakeScenarioRunner( - _passedReportFor('recorded'), - ); - final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( - flutterDevices: [ - FlutterDevice( - id: 'pixel-8', - name: 'Pixel 8', - targetPlatform: 'android-arm64', - isSupported: true, - emulator: true, - sdk: 'Android 35', - ), - ], - recordingDevices: [ - RecordingDeviceIdentity(id: 'pixel-8'), - ], - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List launchEvents = - []; - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/recorded.yaml', - relativePath: 'recorded.yaml', - scenario: Scenario( - name: 'recorded', - recording: const ScenarioRecording(enabled: true), - steps: const [ - ScenarioStep( - index: 1, - action: CaptureAction( - screenshot: false, - snapshot: false, - widgetTree: false, - logs: false, - ), - ), - ], - ), - ), - ]; - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - onLaunchProgress: launchEvents.add, - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - - final ProjectRunResult report = await reportFuture; - - expect(report.passed, isTrue); - expect(deviceDiscovery.flutterDeviceListCount, 1); - expect(deviceDiscovery.recordingDeviceListCount, 1); - expect(starter.startedArguments, [ - 'run', - '--machine', - '--device-id', - 'pixel-8', - ]); - expect(runner.targetDevice?.id, 'pixel-8'); - expect(runner.recordingController, isNotNull); - final TargetAppLaunchStartedEvent startedEvent = launchEvents - .whereType() - .single; - expect( - startedEvent.choices.selectionReason, - isA(), - ); - }); - }, - ); - - test( - 'default Project Run executor rejects ambiguous recording-required selection', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( - flutterDevices: [ - FlutterDevice( - id: 'pixel-8', - name: 'Pixel 8', - targetPlatform: 'android-arm64', - isSupported: true, - emulator: true, - sdk: 'Android 35', - ), - FlutterDevice( - id: 'iphone-15', - name: 'iPhone 15', - targetPlatform: 'ios', - isSupported: true, - emulator: false, - sdk: 'iOS 18', - ), - ], - recordingDevices: [ - const RecordingDeviceIdentity(id: 'pixel-8'), - const RecordingDeviceIdentity(id: 'iphone-15'), - ], - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - FakeScenarioRunner(_passedReportFor('recorded')), - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/recorded.yaml', - relativePath: 'recorded.yaml', - scenario: Scenario( - name: 'recorded', - recording: const ScenarioRecording(enabled: true), - steps: const [ - ScenarioStep( - index: 1, - action: CaptureAction( - screenshot: false, - snapshot: false, - widgetTree: false, - logs: false, - ), - ), - ], - ), - ), - ]; - - final ProjectRunResult report = await executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - ); - - expect(report.status, ProjectRunStatus.environmentFailed); - final File projectRunReport = File(report.projectRunReportPath); - final Map reportJson = - jsonDecode(projectRunReport.readAsStringSync()) - as Map; - expect(reportJson['environmentFailure'], { - 'phase': 'targetDeviceResolution', - 'message': - 'Multiple recordable Target Devices are available. Pass --device with one of: pixel-8 (Pixel 8), iphone-15 (iPhone 15).', - }); - expect(deviceDiscovery.flutterDeviceListCount, 1); - expect(deviceDiscovery.recordingDeviceListCount, 1); - expect(starter.startedArguments, isEmpty); - }); - }, - ); - - test( - 'default Project Run executor reports device discovery failure', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( - flutterDeviceListException: const DeviceDiscoveryException( - 'flutter devices failed', - ), - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - deviceDiscovery: deviceDiscovery, - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - FakeScenarioRunner(_passedReportFor('login')), - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - - final ProjectRunResult report = await executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ], - device: 'pixel', - flavor: null, - target: null, - jsonOutput: false, - ), - ); - - expect(report.status, ProjectRunStatus.environmentFailed); - final File projectRunReport = File(report.projectRunReportPath); - final Map reportJson = - jsonDecode(projectRunReport.readAsStringSync()) - as Map; - expect(reportJson['environmentFailure'], { - 'phase': 'targetDeviceResolution', - 'message': 'flutter devices failed', - }); - expect(starter.startedArguments, isEmpty); - }); - }, - ); - - test('default Project Run executor emits launch progress once', () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner runner = FakeScenarioRunner( - _passedReportFor('login'), - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([runner]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List launchEvents = - []; - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ]; - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - onLaunchProgress: launchEvents.add, - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, - }), - ); - - final ProjectRunResult report = await reportFuture; - - expect(report.passed, isTrue); - expect( - launchEvents.whereType(), - hasLength(1), - ); - expect( - launchEvents.whereType(), - hasLength(1), - ); - }); - }); - - test('default Project Run executor cleans up when interrupted', () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final HangingScenarioRunner runner = HangingScenarioRunner(); - final StreamController interruptController = - StreamController(); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([runner]), - interruptSignals: interruptController.stream, - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ], - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - await Future.delayed(Duration.zero); - interruptController.add(null); - - await expectLater( - reportFuture, - throwsA( - isA() - .having( - (TestCommandException error) => error.message, - 'message', - contains('interrupted'), - ) - .having( - (TestCommandException error) => error.exitCode, - 'exitCode', - 130, - ), - ), - ); - expect(process.stdinWrites, ['q\n']); - await interruptController.close(); - }); - }); - - test( - 'default Project Run executor emits launch heartbeat until success', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner runner = FakeScenarioRunner( - _passedReportFor('login'), - ); - final StreamController ticks = StreamController(); - DateTime now = DateTime.utc(2026, 7, 1, 9, 30); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - runner, - ]), - outputDirectory: Directory.current, - clock: () => now, - launchHeartbeatTicks: ticks.stream, - ); - final List launchEvents = - []; - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ]; - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - onLaunchProgress: launchEvents.add, - launchHeartbeatEnabled: true, - ); - while (starter.startedArguments.isEmpty) { - await Future.delayed(Duration.zero); - } - now = DateTime.utc(2026, 7, 1, 9, 30, 10); - ticks.add(null); - await Future.delayed(Duration.zero); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - await reportFuture; - now = DateTime.utc(2026, 7, 1, 9, 30, 20); - ticks.add(null); - await Future.delayed(Duration.zero); - await ticks.close(); - - expect( - launchEvents.whereType(), - hasLength(1), - ); - }); - }, - ); - - test( - 'default Project Run executor continues after a failed Scenario', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner firstRunner = FailingScenarioRunner( - _failingReportFor('login'), - ); - final FakeScenarioRunner secondRunner = FakeScenarioRunner( - _passedReportFor('checkout'), - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - firstRunner, - secondRunner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ProjectScenarioFile( - path: 'pilot/checkout.yaml', - relativePath: 'checkout.yaml', - scenario: _scenario('checkout'), - ), - ]; - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - while (process.stdinWrites.isEmpty) { - await Future.delayed(Duration.zero); - } - process.emitStdout( - jsonEncode({'id': 0, 'result': true}), - ); - - final ProjectRunResult report = await reportFuture; - - expect(report.passed, isFalse); - expect(starter.startCount, 1); - expect(process.stdinWrites, [ - '[{"id":0,"method":"app.restart","params":{"appId":"app-1","fullRestart":true}}]\n', - 'q\n', - ]); - expect(firstRunner.scenario.name, 'login'); - expect(secondRunner.scenario.name, 'checkout'); - }); - }, - ); - - test('default Project Run executor stops on hot restart failure', () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess() - ..stdinException = StateError('restart failed') - ..stdinExceptionOnce = true; - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner firstRunner = FailingScenarioRunner( - _failingReportFor('login'), - ); - final FakeScenarioRunner secondRunner = FakeScenarioRunner( - _passedReportFor('checkout'), - ); - final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( - launcher: TargetAppLauncher(starter: starter), - runnerFactory: QueueScenarioRunnerFactory([ - firstRunner, - secondRunner, - ]), - outputDirectory: Directory.current, - clock: () => DateTime.utc(2026, 7, 1, 9, 30), - ); - final List scenarios = [ - ProjectScenarioFile( - path: 'pilot/login.yaml', - relativePath: 'login.yaml', - scenario: _scenario('login'), - ), - ProjectScenarioFile( - path: 'pilot/checkout.yaml', - relativePath: 'checkout.yaml', - scenario: _scenario('checkout'), - ), - ]; - - final Future reportFuture = executor.run( - ProjectRunOptions( - discoveryRootPath: 'pilot', - scenarios: scenarios, - device: null, - flavor: null, - target: null, - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - - final ProjectRunResult report = await reportFuture; - - expect(report.passed, isFalse); - expect(starter.startCount, 1); - expect(process.stdinWrites, ['q\n']); - expect(firstRunner.scenario.name, 'login'); - expect(secondRunner.onProgress, isNull); - }); - }); -} - -class FakeTestCommandExecutor implements TestCommandExecutor { - FakeTestCommandExecutor({required this.report, this.exception}); - - final ScenarioRunReport report; - final TestCommandException? exception; - late TestCommandOptions options; - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress; - bool? launchHeartbeatEnabled; - void Function(StepProgressEvent event)? onProgress; - - @override - Future run( - TestCommandOptions options, { - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, - bool launchHeartbeatEnabled = false, - void Function(StepProgressEvent event)? onProgress, - }) async { - this.options = options; - this.onLaunchProgress = onLaunchProgress; - this.launchHeartbeatEnabled = launchHeartbeatEnabled; - this.onProgress = onProgress; - final TestCommandException? exception = this.exception; - if (exception != null) { - throw exception; - } - return report; - } -} - -class FakeProjectRunExecutor implements ProjectRunExecutor { - FakeProjectRunExecutor({required this.report}); - - final ProjectRunResult report; - late ProjectRunOptions options; - bool ran = false; - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress; - bool? launchHeartbeatEnabled; - void Function(StepProgressEvent event)? onProgress; - - @override - Future run( - ProjectRunOptions options, { - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, - bool launchHeartbeatEnabled = false, - void Function(StepProgressEvent event)? onProgress, - }) async { - this.options = options; - ran = true; - this.onLaunchProgress = onLaunchProgress; - this.launchHeartbeatEnabled = launchHeartbeatEnabled; - this.onProgress = onProgress; - return report; - } -} - -ProjectRunResult _passedProjectReport() { - return const ProjectRunResult( - passed: true, - status: ProjectRunStatus.passed, - projectRunReportPath: '.runs/project-run/project_run_report.json', - scenarioReports: [], - ); -} - -ScenarioRunReport _passedReport({TargetDevice? targetDevice}) { - return ScenarioRunReport( - scenarioName: 'delegated', - scenarioDescription: null, - totalSteps: 0, - status: ScenarioRunStatus.passed, - startedAt: DateTime.utc(2026, 6, 30), - durationMs: 1, - steps: const [], - runDirectoryPath: '.runs/delegated', - artifacts: const [], - targetDevice: targetDevice, - ); -} - -ScenarioRunReport _passedReportFor(String scenarioName) { - return ScenarioRunReport( - scenarioName: scenarioName, - scenarioDescription: null, - totalSteps: 1, - status: ScenarioRunStatus.passed, - startedAt: DateTime.utc(2026, 7, 1, 9, 30), - durationMs: 1, - steps: const [ - StepRunReport( - index: 1, - label: null, - action: 'capture', - status: StepStatus.passed, - durationMs: 1, - ), - ], - runDirectoryPath: '.runs/child', - artifacts: const [ - ArtifactReport(type: ArtifactType.runReport, path: 'run_report.json'), - ArtifactReport(type: ArtifactType.htmlReport, path: 'timeline.html'), - ], - ); -} - -ScenarioRunReport _failingReportFor(String scenarioName) { - return ScenarioRunReport( - scenarioName: scenarioName, - scenarioDescription: null, - totalSteps: 1, - status: ScenarioRunStatus.failed, - startedAt: DateTime.utc(2026, 7, 1, 9, 30), - durationMs: 1, - steps: const [ - StepRunReport( - index: 1, - label: null, - action: 'capture', - status: StepStatus.failed, - durationMs: 1, - failureReason: 'failed', - ), - ], - runDirectoryPath: '.runs/child', - artifacts: const [], - ); -} - -Scenario _scenario(String name) { - return Scenario( - name: name, - steps: const [ - ScenarioStep( - index: 1, - action: CaptureAction( - screenshot: true, - snapshot: true, - widgetTree: false, - logs: true, - ), - ), - ], - ); -} - -class FakeDeviceDiscovery implements TestDeviceDiscovery { - FakeDeviceDiscovery({ - this.flutterDevices = const [], - this.recordingDevices = const [], - this.flutterDeviceListException, - this.recordingDeviceListException, - }); - - final List flutterDevices; - final List recordingDevices; - final DeviceDiscoveryException? flutterDeviceListException; - final DeviceDiscoveryException? recordingDeviceListException; - int flutterDeviceListCount = 0; - int recordingDeviceListCount = 0; - - @override - Future> listFlutterDevices() async { - flutterDeviceListCount++; - final DeviceDiscoveryException? exception = flutterDeviceListException; - if (exception != null) { - throw exception; - } - return flutterDevices; - } - - @override - Future> listRecordingDevices() async { - recordingDeviceListCount++; - final DeviceDiscoveryException? exception = recordingDeviceListException; - if (exception != null) { - throw exception; - } - return recordingDevices; - } -} - -class FakeTestExecutionSessionFactory implements TestExecutionSessionFactory { - FakeTestExecutionSessionFactory(this.session); - - final FakeTestExecutionSession session; - int startCount = 0; - String? deviceSelector; - String? flavor; - String? target; - bool? recordingRequired; - bool? launchHeartbeatEnabled; - - @override - Future start({ - required String? deviceSelector, - required String? flavor, - required String? target, - required bool recordingRequired, - required bool launchHeartbeatEnabled, - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, - }) async { - startCount++; - this.deviceSelector = deviceSelector; - this.flavor = flavor; - this.target = target; - this.recordingRequired = recordingRequired; - this.launchHeartbeatEnabled = launchHeartbeatEnabled; - return session; - } -} - -class FakeTestExecutionSession implements TestExecutionSession { - FakeTestExecutionSession({required this.runtimeTarget, this.targetDevice}); - - @override - final RuntimeTarget runtimeTarget; - - @override - final TargetDevice? targetDevice; - - int runWithInterruptCount = 0; - int closeCount = 0; - int hotRestartCount = 0; - - @override - Future runWithInterrupt(Future operation) { - runWithInterruptCount++; - return operation; - } - - @override - Future hotRestart() async { - hotRestartCount++; - } - - @override - Future close() async { - closeCount++; - } -} - -class FakeScenarioRunnerFactory implements TestScenarioRunnerFactory { - const FakeScenarioRunnerFactory(this.runner); - - final FakeScenarioRunner runner; - - @override - TestScenarioRunner create({ - required RuntimeTarget runtimeTarget, - required TargetDevice? targetDevice, - required RecordingController? recordingController, - }) { - runner.runtimeTarget = runtimeTarget; - runner.targetDevice = targetDevice; - runner.recordingController = recordingController; - return runner; - } -} - -class ThrowingScenarioRunnerFactory implements TestScenarioRunnerFactory { - const ThrowingScenarioRunnerFactory(this.exception); - - final Exception exception; - - @override - TestScenarioRunner create({ - required RuntimeTarget runtimeTarget, - required TargetDevice? targetDevice, - required RecordingController? recordingController, - }) { - throw exception; - } -} - -class QueueScenarioRunnerFactory implements TestScenarioRunnerFactory { - QueueScenarioRunnerFactory(this.runners); - - final List runners; - int _index = 0; - - @override - TestScenarioRunner create({ - required RuntimeTarget runtimeTarget, - required TargetDevice? targetDevice, - required RecordingController? recordingController, - }) { - final FakeScenarioRunner runner = runners[_index++]; - runner.runtimeTarget = runtimeTarget; - runner.targetDevice = targetDevice; - runner.recordingController = recordingController; - return runner; - } -} - -class FakeScenarioRunner implements TestScenarioRunner { - FakeScenarioRunner(this.report); - - final ScenarioRunReport report; - late RuntimeTarget runtimeTarget; - TargetDevice? targetDevice; - RecordingController? recordingController; - late Scenario scenario; - void Function(StepProgressEvent event)? onProgress; - RunArtifactWriter? runArtifactWriter; - - @override - Future run( - Scenario scenario, { - RunStopPoint? stopPoint, - Set printDiagnostics = const {}, - void Function(StepProgressEvent event)? onProgress, - RunArtifactWriter? runArtifactWriter, - }) async { - this.scenario = scenario; - this.onProgress = onProgress; - this.runArtifactWriter = runArtifactWriter; - onProgress?.call( - StepStartedEvent( - scenarioName: scenario.name, - totalSteps: scenario.steps.length, - step: scenario.steps.first, - action: 'tap', - ), - ); - onProgress?.call( - StepFinishedEvent( - scenarioName: scenario.name, - totalSteps: scenario.steps.length, - report: StepRunReport( - index: scenario.steps.first.index, - label: scenario.steps.first.label, - action: 'tap', - status: StepStatus.passed, - durationMs: 1, - ), - ), - ); - return report; - } -} - -class FailingScenarioRunner extends FakeScenarioRunner { - FailingScenarioRunner(super.report); - - @override - Future run( - Scenario scenario, { - RunStopPoint? stopPoint, - Set printDiagnostics = const {}, - void Function(StepProgressEvent event)? onProgress, - RunArtifactWriter? runArtifactWriter, - }) async { - this.scenario = scenario; - this.runArtifactWriter = runArtifactWriter; - return ScenarioRunReport( - scenarioName: scenario.name, - scenarioDescription: null, - totalSteps: scenario.steps.length, - status: ScenarioRunStatus.failed, - startedAt: DateTime.utc(2026, 7, 1, 9, 30), - durationMs: 1, - steps: const [], - runDirectoryPath: '.runs/child', - artifacts: const [], - ); - } -} - -class HangingScenarioRunner extends FakeScenarioRunner { - HangingScenarioRunner() : super(_passedReport()); - - final Completer _completer = - Completer(); - - @override - Future run( - Scenario scenario, { - RunStopPoint? stopPoint, - Set printDiagnostics = const {}, - void Function(StepProgressEvent event)? onProgress, - RunArtifactWriter? runArtifactWriter, - }) { - this.scenario = scenario; - this.onProgress = onProgress; - this.runArtifactWriter = runArtifactWriter; - return _completer.future; - } -} - -class FakeTargetAppProcessStarter implements TargetAppProcessStarter { - FakeTargetAppProcessStarter(this.process); - - final FakeTargetAppProcess process; - List startedArguments = const []; - int startCount = 0; - - @override - Future start( - String executable, - List arguments, - ) async { - startCount++; - startedArguments = arguments; - return process; - } -} - -class FakeTargetAppProcess implements TargetAppProcess { - final StreamController> _stdoutController = - StreamController>(); - final StreamController> _stderrController = - StreamController>(); - final Completer _exitCodeCompleter = Completer(); - final List stdinWrites = []; - Object? stdinException; - bool stdinExceptionOnce = false; - - @override - Stream> get stdout => _stdoutController.stream; - - @override - Stream> get stderr => _stderrController.stream; - - @override - Future get exitCode => _exitCodeCompleter.future; - - void emitStdout(String line) { - _stdoutController.add(utf8.encode('$line\n')); - } - - void emitStderr(String line) { - _stderrController.add(utf8.encode('$line\n')); - } - - void exit(int exitCode) { - _stdoutController.close(); - _stderrController.close(); - _exitCodeCompleter.complete(exitCode); - } - - @override - void writeStdin(String text) { - final Object? exception = stdinException; - if (exception != null) { - if (stdinExceptionOnce) { - stdinException = null; - stdinExceptionOnce = false; - } - throw exception; - } - stdinWrites.add(text); - } - - @override - bool kill() { - return true; - } } diff --git a/test/test_command_output_test.dart b/test/test_command_output_test.dart new file mode 100644 index 0000000..910b4b8 --- /dev/null +++ b/test/test_command_output_test.dart @@ -0,0 +1,145 @@ +import 'dart:io'; + +import 'package:file_testkit/file_testkit.dart'; +import 'package:flutter_pilot/flutter_pilot.dart'; +import 'package:test/test.dart'; + +/// Verifies pure `test` command output rendering helpers. +void main() { + test('Project Run output renders deterministic all-passed summary', () { + const ProjectRunResult report = ProjectRunResult( + passed: true, + status: ProjectRunStatus.passed, + projectRunReportPath: + '.runs/2026-07-01_09-30_project-run/project_run_report.json', + scenarioReports: [ + ProjectRunScenarioOutputReport( + scenarioPath: 'checkout.yaml', + status: ProjectScenarioRunStatus.passed, + runReportPath: + '.runs/2026-07-01_09-30_project-run/' + '2026-07-01_09-30_checkout/run_report.json', + htmlReportPath: + '.runs/2026-07-01_09-30_project-run/' + '2026-07-01_09-30_checkout/timeline.html', + ), + ProjectRunScenarioOutputReport( + scenarioPath: 'login.yaml', + status: ProjectScenarioRunStatus.passed, + runReportPath: + '.runs/2026-07-01_09-30_project-run/' + '2026-07-01_09-30_login/run_report.json', + htmlReportPath: + '.runs/2026-07-01_09-30_project-run/' + '2026-07-01_09-30_login/timeline.html', + ), + ], + ); + + final String rendered = TestCommandOutput.renderProjectRunSummary(report); + + expect(rendered, ''' +Project Run: passed +Project Run report: .runs/2026-07-01_09-30_project-run/project_run_report.json +Scenario: checkout.yaml (passed) +Run report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_checkout/run_report.json +HTML report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_checkout/timeline.html +Scenario: login.yaml (passed) +Run report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_login/run_report.json +HTML report: .runs/2026-07-01_09-30_project-run/2026-07-01_09-30_login/timeline.html +'''); + }); + + test('Project Run output renders partially failed summary', () { + const ProjectRunResult report = ProjectRunResult( + passed: false, + status: ProjectRunStatus.failed, + projectRunReportPath: '.runs/project-run/project_run_report.json', + scenarioReports: [ + ProjectRunScenarioOutputReport( + scenarioPath: 'login.yaml', + status: ProjectScenarioRunStatus.failed, + runReportPath: '.runs/project-run/login/run_report.json', + htmlReportPath: '.runs/project-run/login/timeline.html', + ), + ProjectRunScenarioOutputReport( + scenarioPath: 'checkout.yaml', + status: ProjectScenarioRunStatus.passed, + runReportPath: '.runs/project-run/checkout/run_report.json', + htmlReportPath: '.runs/project-run/checkout/timeline.html', + ), + ], + ); + + final String rendered = TestCommandOutput.renderProjectRunSummary(report); + + expect(rendered, contains('Project Run: failed')); + expect(rendered, contains('Scenario: login.yaml (failed)')); + expect(rendered, contains('Scenario: checkout.yaml (passed)')); + }); + + test('Project Run output renders environment-level failure summary', () { + const ProjectRunResult report = ProjectRunResult( + passed: false, + status: ProjectRunStatus.environmentFailed, + projectRunReportPath: '.runs/project-run/project_run_report.json', + scenarioReports: [], + ); + + final String rendered = TestCommandOutput.renderProjectRunSummary(report); + + expect(rendered, ''' +Project Run: environmentFailed +Project Run report: .runs/project-run/project_run_report.json +'''); + }); + + test( + 'test command chooses interactive progress only for terminals', + () async { + await FileTestkit.runZoned(() async { + final IOSink sink = File('progress.log').openWrite(); + try { + final StepProgressRenderer? jsonRenderer = + TestCommandOutput.stepProgressRenderer( + sink: sink, + jsonOutput: true, + stderrHasTerminal: true, + ); + final StepProgressRenderer? terminalRenderer = + TestCommandOutput.stepProgressRenderer( + sink: sink, + jsonOutput: false, + stderrHasTerminal: true, + ); + final StepProgressRenderer? redirectedRenderer = + TestCommandOutput.stepProgressRenderer( + sink: sink, + jsonOutput: false, + stderrHasTerminal: false, + ); + final TargetAppLaunchProgressRenderer? terminalLaunchRenderer = + TestCommandOutput.targetAppLaunchProgressRenderer( + sink: sink, + jsonOutput: false, + stderrHasTerminal: true, + ); + final TargetAppLaunchProgressRenderer? redirectedLaunchRenderer = + TestCommandOutput.targetAppLaunchProgressRenderer( + sink: sink, + jsonOutput: false, + stderrHasTerminal: false, + ); + + expect(jsonRenderer, isNull); + expect(terminalRenderer?.interactive, isTrue); + expect(redirectedRenderer?.interactive, isFalse); + expect(terminalLaunchRenderer?.interactive, isTrue); + expect(redirectedLaunchRenderer?.interactive, isFalse); + } finally { + await sink.close(); + } + }); + }, + ); +} From 0502395bf52d925cf245295ed3c3e5df8d4e1b5c Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 15:40:17 +0800 Subject: [PATCH 6/8] refactor: organize source and test modules --- lib/flutter_pilot.dart | 30 +++++++++---------- lib/src/artifacts/artifact_store.dart | 2 +- lib/src/cli.dart | 2 +- lib/src/commands/diff_command.dart | 2 +- lib/src/commands/report_command.dart | 2 +- lib/src/commands/test_command.dart | 16 +++++----- lib/src/commands/validate_command.dart | 2 +- .../{ => diagnostics}/diagnostic_reducer.dart | 0 .../diagnostic_text_renderer.dart | 2 +- lib/src/{ => diff}/run_diff.dart | 2 +- .../{ => execution}/project_run_executor.dart | 16 +++++----- lib/src/{ => execution}/scenario_runner.dart | 14 ++++----- .../test_command_executor.dart | 12 ++++---- .../{ => execution}/test_command_models.dart | 6 ++-- .../{ => execution}/test_command_support.dart | 4 +-- .../test_execution_session.dart | 12 ++++---- .../test_scenario_runner_factory.dart | 12 ++++---- lib/src/recording/recording_contract.dart | 2 +- .../screen_recorder_recording_controller.dart | 2 +- .../{ => reports}/html_timeline_report.dart | 4 +-- lib/src/{ => reports}/project_run_report.dart | 0 lib/src/runtime/pilot_runtime_adapter.dart | 2 +- lib/src/runtime/runtime_contract.dart | 2 +- .../project_scenario_discovery.dart | 0 lib/src/{ => scenario}/scenario.dart | 0 lib/src/{ => scenario}/scenario_dsl_docs.dart | 0 lib/src/{ => scenario}/scenario_parser.dart | 0 lib/src/{ => smoke}/smoke_verifier.dart | 0 .../{ => target}/step_progress_renderer.dart | 6 ++-- .../target_app_launch_progress_renderer.dart | 0 lib/src/{ => target}/target_app_launcher.dart | 0 lib/src/{ => target}/target_device.dart | 0 .../{ => target}/test_device_discovery.dart | 2 +- .../test_target_device_selection.dart | 0 lib/src/test_command_output.dart | 6 ++-- test/{ => app_setup}/app_setup_test.dart | 0 test/{ => artifacts}/artifact_store_test.dart | 0 test/{ => cli}/cli_test.dart | 0 .../{ => cli/output}/terminal_style_test.dart | 0 .../output}/test_command_output_test.dart | 0 test/{ => cli}/test_command_cli_test.dart | 2 +- .../diagnostic_reducer_test.dart | 0 .../diagnostic_text_renderer_test.dart | 0 .../run_diff_acceptance_fixture_test.dart | 0 test/{ => diff}/run_diff_cli_test.dart | 2 +- .../{ => diff}/run_diff_diagnostics_test.dart | 2 +- test/{ => diff}/run_diff_json_test.dart | 2 +- test/{ => diff}/run_diff_screenshot_test.dart | 2 +- test/{ => diff}/run_diff_step_test.dart | 2 +- .../project_run_executor_test.dart | 2 +- .../{ => execution}/scenario_runner_test.dart | 4 +-- .../test_command_executor_test.dart | 2 +- .../test_execution_session_test.dart | 0 ...en_recorder_recording_controller_test.dart | 0 .../html_timeline_report_test.dart | 2 +- .../project_run_report_test.dart | 0 .../pilot_runtime_adapter_test.dart | 0 .../runtime_adapter_selection_test.dart | 0 test/{ => runtime}/runtime_contract_test.dart | 2 +- .../project_scenario_discovery_test.dart | 0 .../scenario_dsl_docs_test.dart | 0 .../scenario_dsl_docs_tool_test.dart | 0 test/{ => scenario}/scenario_parser_test.dart | 0 test/{ => scenario}/scenario_test.dart | 0 .../smoke_scenario_contract_test.dart | 0 test/{ => smoke}/smoke_script_test.dart | 2 +- .../extract_flutter_machine_ws_uri_test.dart | 0 .../step_progress_renderer_test.dart | 0 ...get_app_launch_progress_renderer_test.dart | 0 .../target_app_launcher_test.dart | 0 .../target_device_resolver_test.dart | 0 71 files changed, 94 insertions(+), 94 deletions(-) rename lib/src/{ => diagnostics}/diagnostic_reducer.dart (100%) rename lib/src/{ => diagnostics}/diagnostic_text_renderer.dart (99%) rename lib/src/{ => diff}/run_diff.dart (99%) rename lib/src/{ => execution}/project_run_executor.dart (95%) rename lib/src/{ => execution}/scenario_runner.dart (99%) rename lib/src/{ => execution}/test_command_executor.dart (93%) rename lib/src/{ => execution}/test_command_models.dart (96%) rename lib/src/{ => execution}/test_command_support.dart (84%) rename lib/src/{ => execution}/test_execution_session.dart (96%) rename lib/src/{ => execution}/test_scenario_runner_factory.dart (89%) rename lib/src/{ => reports}/html_timeline_report.dart (99%) rename lib/src/{ => reports}/project_run_report.dart (100%) rename lib/src/{ => scenario}/project_scenario_discovery.dart (100%) rename lib/src/{ => scenario}/scenario.dart (100%) rename lib/src/{ => scenario}/scenario_dsl_docs.dart (100%) rename lib/src/{ => scenario}/scenario_parser.dart (100%) rename lib/src/{ => smoke}/smoke_verifier.dart (100%) rename lib/src/{ => target}/step_progress_renderer.dart (98%) rename lib/src/{ => target}/target_app_launch_progress_renderer.dart (100%) rename lib/src/{ => target}/target_app_launcher.dart (100%) rename lib/src/{ => target}/target_device.dart (100%) rename lib/src/{ => target}/test_device_discovery.dart (97%) rename lib/src/{ => target}/test_target_device_selection.dart (100%) rename test/{ => app_setup}/app_setup_test.dart (100%) rename test/{ => artifacts}/artifact_store_test.dart (100%) rename test/{ => cli}/cli_test.dart (100%) rename test/{ => cli/output}/terminal_style_test.dart (100%) rename test/{ => cli/output}/test_command_output_test.dart (100%) rename test/{ => cli}/test_command_cli_test.dart (99%) rename test/{ => diagnostics}/diagnostic_reducer_test.dart (100%) rename test/{ => diagnostics}/diagnostic_text_renderer_test.dart (100%) rename test/{ => diff}/run_diff_acceptance_fixture_test.dart (100%) rename test/{ => diff}/run_diff_cli_test.dart (99%) rename test/{ => diff}/run_diff_diagnostics_test.dart (99%) rename test/{ => diff}/run_diff_json_test.dart (99%) rename test/{ => diff}/run_diff_screenshot_test.dart (99%) rename test/{ => diff}/run_diff_step_test.dart (99%) rename test/{ => execution}/project_run_executor_test.dart (99%) rename test/{ => execution}/scenario_runner_test.dart (99%) rename test/{ => execution}/test_command_executor_test.dart (99%) rename test/{ => execution}/test_execution_session_test.dart (100%) rename test/{ => recording}/screen_recorder_recording_controller_test.dart (100%) rename test/{ => reports}/html_timeline_report_test.dart (98%) rename test/{ => reports}/project_run_report_test.dart (100%) rename test/{ => runtime}/pilot_runtime_adapter_test.dart (100%) rename test/{ => runtime}/runtime_adapter_selection_test.dart (100%) rename test/{ => runtime}/runtime_contract_test.dart (99%) rename test/{ => scenario}/project_scenario_discovery_test.dart (100%) rename test/{ => scenario}/scenario_dsl_docs_test.dart (100%) rename test/{ => scenario}/scenario_dsl_docs_tool_test.dart (100%) rename test/{ => scenario}/scenario_parser_test.dart (100%) rename test/{ => scenario}/scenario_test.dart (100%) rename test/{ => smoke}/smoke_scenario_contract_test.dart (100%) rename test/{ => smoke}/smoke_script_test.dart (98%) rename test/{ => target}/extract_flutter_machine_ws_uri_test.dart (100%) rename test/{ => target}/step_progress_renderer_test.dart (100%) rename test/{ => target}/target_app_launch_progress_renderer_test.dart (100%) rename test/{ => target}/target_app_launcher_test.dart (100%) rename test/{ => target}/target_device_resolver_test.dart (100%) diff --git a/lib/flutter_pilot.dart b/lib/flutter_pilot.dart index df07aa1..cb25ae8 100644 --- a/lib/flutter_pilot.dart +++ b/lib/flutter_pilot.dart @@ -8,25 +8,25 @@ library; export 'src/cli.dart'; export 'src/artifacts/artifact_store.dart'; export 'src/app_setup.dart'; -export 'src/diagnostic_reducer.dart'; -export 'src/diagnostic_text_renderer.dart'; -export 'src/html_timeline_report.dart'; +export 'src/diagnostics/diagnostic_reducer.dart'; +export 'src/diagnostics/diagnostic_text_renderer.dart'; +export 'src/reports/html_timeline_report.dart'; export 'src/recording/recording_contract.dart'; export 'src/recording/screen_recorder_recording_controller.dart'; -export 'src/project_scenario_discovery.dart'; -export 'src/project_run_report.dart'; +export 'src/scenario/project_scenario_discovery.dart'; +export 'src/reports/project_run_report.dart'; export 'src/runtime/pilot_runtime_adapter.dart'; export 'src/runtime/pilot_runtime_vm_service.dart'; export 'src/runtime/runtime_adapter_selector.dart'; export 'src/runtime/runtime_contract.dart'; -export 'src/run_diff.dart'; -export 'src/scenario.dart'; -export 'src/scenario_dsl_docs.dart'; -export 'src/scenario_parser.dart'; -export 'src/scenario_runner.dart'; -export 'src/step_progress_renderer.dart'; -export 'src/target_app_launch_progress_renderer.dart'; -export 'src/target_app_launcher.dart'; -export 'src/target_device.dart'; -export 'src/test_command_support.dart'; +export 'src/diff/run_diff.dart'; +export 'src/scenario/scenario.dart'; +export 'src/scenario/scenario_dsl_docs.dart'; +export 'src/scenario/scenario_parser.dart'; +export 'src/execution/scenario_runner.dart'; +export 'src/target/step_progress_renderer.dart'; +export 'src/target/target_app_launch_progress_renderer.dart'; +export 'src/target/target_app_launcher.dart'; +export 'src/target/target_device.dart'; +export 'src/execution/test_command_support.dart'; export 'src/terminal_style.dart'; diff --git a/lib/src/artifacts/artifact_store.dart b/lib/src/artifacts/artifact_store.dart index 79ea9d9..8d57673 100644 --- a/lib/src/artifacts/artifact_store.dart +++ b/lib/src/artifacts/artifact_store.dart @@ -4,7 +4,7 @@ import 'dart:typed_data'; import 'package:path/path.dart' as p; -import '../scenario.dart'; +import '../scenario/scenario.dart'; const JsonEncoder _artifactJsonEncoder = JsonEncoder.withIndent(' '); diff --git a/lib/src/cli.dart b/lib/src/cli.dart index 3019447..00b5a7b 100644 --- a/lib/src/cli.dart +++ b/lib/src/cli.dart @@ -7,7 +7,7 @@ import 'commands/diff_command.dart'; import 'commands/report_command.dart'; import 'commands/test_command.dart'; import 'commands/validate_command.dart'; -import 'test_command_support.dart'; +import 'execution/test_command_support.dart'; /// Command-line entry point for Flutter Pilot. /// diff --git a/lib/src/commands/diff_command.dart b/lib/src/commands/diff_command.dart index b549e25..78aedf6 100644 --- a/lib/src/commands/diff_command.dart +++ b/lib/src/commands/diff_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import '../run_diff.dart'; +import '../diff/run_diff.dart'; /// `diff` command for comparing two existing Scenario Run directories. /// diff --git a/lib/src/commands/report_command.dart b/lib/src/commands/report_command.dart index 4e81918..e337871 100644 --- a/lib/src/commands/report_command.dart +++ b/lib/src/commands/report_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import '../html_timeline_report.dart'; +import '../reports/html_timeline_report.dart'; /// `report` command for regenerating HTML from an existing run directory. /// diff --git a/lib/src/commands/test_command.dart b/lib/src/commands/test_command.dart index 8e4ee6a..fce978e 100644 --- a/lib/src/commands/test_command.dart +++ b/lib/src/commands/test_command.dart @@ -3,14 +3,14 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import '../diagnostic_text_renderer.dart'; -import '../project_scenario_discovery.dart'; -import '../scenario.dart'; -import '../scenario_parser.dart'; -import '../scenario_runner.dart'; -import '../step_progress_renderer.dart'; -import '../target_app_launch_progress_renderer.dart'; -import '../test_command_support.dart'; +import '../diagnostics/diagnostic_text_renderer.dart'; +import '../scenario/project_scenario_discovery.dart'; +import '../scenario/scenario.dart'; +import '../execution/scenario_runner.dart'; +import '../scenario/scenario_parser.dart'; +import '../target/step_progress_renderer.dart'; +import '../target/target_app_launch_progress_renderer.dart'; +import '../execution/test_command_support.dart'; /// `test` command shell for validating CLI arguments before app launch. /// diff --git a/lib/src/commands/validate_command.dart b/lib/src/commands/validate_command.dart index 47f5d4f..7fab351 100644 --- a/lib/src/commands/validate_command.dart +++ b/lib/src/commands/validate_command.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import '../scenario_parser.dart'; +import '../scenario/scenario_parser.dart'; /// `validate` command for checking Scenario YAML without connecting to Flutter. /// diff --git a/lib/src/diagnostic_reducer.dart b/lib/src/diagnostics/diagnostic_reducer.dart similarity index 100% rename from lib/src/diagnostic_reducer.dart rename to lib/src/diagnostics/diagnostic_reducer.dart diff --git a/lib/src/diagnostic_text_renderer.dart b/lib/src/diagnostics/diagnostic_text_renderer.dart similarity index 99% rename from lib/src/diagnostic_text_renderer.dart rename to lib/src/diagnostics/diagnostic_text_renderer.dart index 79b0f37..417ef5c 100644 --- a/lib/src/diagnostic_text_renderer.dart +++ b/lib/src/diagnostics/diagnostic_text_renderer.dart @@ -1,5 +1,5 @@ import 'diagnostic_reducer.dart'; -import 'scenario_runner.dart'; +import '../execution/scenario_runner.dart'; /// Renders printed diagnostics as terminal-friendly text. /// diff --git a/lib/src/run_diff.dart b/lib/src/diff/run_diff.dart similarity index 99% rename from lib/src/run_diff.dart rename to lib/src/diff/run_diff.dart index 41a2150..89fe5ba 100644 --- a/lib/src/run_diff.dart +++ b/lib/src/diff/run_diff.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; -import 'diagnostic_reducer.dart'; +import '../diagnostics/diagnostic_reducer.dart'; /// Failure while loading or comparing Scenario Run reports. /// diff --git a/lib/src/project_run_executor.dart b/lib/src/execution/project_run_executor.dart similarity index 95% rename from lib/src/project_run_executor.dart rename to lib/src/execution/project_run_executor.dart index 78bad37..fe445fa 100644 --- a/lib/src/project_run_executor.dart +++ b/lib/src/execution/project_run_executor.dart @@ -3,16 +3,16 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:screen_recorder/screen_recorder.dart' as screen_recorder; -import 'artifacts/artifact_store.dart'; -import 'project_scenario_discovery.dart'; -import 'project_run_report.dart'; -import 'recording/screen_recorder_recording_controller.dart'; -import 'runtime/runtime_adapter_selector.dart'; +import '../artifacts/artifact_store.dart'; +import '../reports/project_run_report.dart'; +import '../recording/screen_recorder_recording_controller.dart'; +import '../runtime/runtime_adapter_selector.dart'; +import '../scenario/project_scenario_discovery.dart'; import 'scenario_runner.dart'; -import 'target_app_launch_progress_renderer.dart'; -import 'target_app_launcher.dart'; +import '../target/target_app_launch_progress_renderer.dart'; +import '../target/target_app_launcher.dart'; import 'test_command_models.dart'; -import 'test_device_discovery.dart'; +import '../target/test_device_discovery.dart'; import 'test_execution_session.dart'; import 'test_scenario_runner_factory.dart'; diff --git a/lib/src/scenario_runner.dart b/lib/src/execution/scenario_runner.dart similarity index 99% rename from lib/src/scenario_runner.dart rename to lib/src/execution/scenario_runner.dart index 59a6dd4..cf951ff 100644 --- a/lib/src/scenario_runner.dart +++ b/lib/src/execution/scenario_runner.dart @@ -1,12 +1,12 @@ import 'dart:io'; -import 'artifacts/artifact_store.dart'; -import 'diagnostic_reducer.dart'; -import 'html_timeline_report.dart'; -import 'recording/recording_contract.dart'; -import 'runtime/runtime_contract.dart'; -import 'scenario.dart'; -import 'target_device.dart'; +import '../artifacts/artifact_store.dart'; +import '../diagnostics/diagnostic_reducer.dart'; +import '../reports/html_timeline_report.dart'; +import '../recording/recording_contract.dart'; +import '../runtime/runtime_contract.dart'; +import '../scenario/scenario.dart'; +import '../target/target_device.dart'; /// Runner for replaying one Scenario through a Runtime Adapter. /// diff --git a/lib/src/test_command_executor.dart b/lib/src/execution/test_command_executor.dart similarity index 93% rename from lib/src/test_command_executor.dart rename to lib/src/execution/test_command_executor.dart index db6b4f4..ce2d657 100644 --- a/lib/src/test_command_executor.dart +++ b/lib/src/execution/test_command_executor.dart @@ -3,14 +3,14 @@ import 'dart:io'; import 'package:screen_recorder/screen_recorder.dart' as screen_recorder; -import 'recording/screen_recorder_recording_controller.dart'; -import 'runtime/runtime_adapter_selector.dart'; -import 'runtime/runtime_contract.dart'; +import '../recording/screen_recorder_recording_controller.dart'; +import '../runtime/runtime_adapter_selector.dart'; +import '../runtime/runtime_contract.dart'; import 'scenario_runner.dart'; -import 'target_app_launch_progress_renderer.dart'; -import 'target_app_launcher.dart'; +import '../target/target_app_launch_progress_renderer.dart'; +import '../target/target_app_launcher.dart'; import 'test_command_models.dart'; -import 'test_device_discovery.dart'; +import '../target/test_device_discovery.dart'; import 'test_execution_session.dart'; import 'test_scenario_runner_factory.dart'; diff --git a/lib/src/test_command_models.dart b/lib/src/execution/test_command_models.dart similarity index 96% rename from lib/src/test_command_models.dart rename to lib/src/execution/test_command_models.dart index 8673921..190c4f3 100644 --- a/lib/src/test_command_models.dart +++ b/lib/src/execution/test_command_models.dart @@ -1,6 +1,6 @@ -import 'project_scenario_discovery.dart'; -import 'project_run_report.dart'; -import 'scenario.dart'; +import '../reports/project_run_report.dart'; +import '../scenario/project_scenario_discovery.dart'; +import '../scenario/scenario.dart'; import 'scenario_runner.dart'; /// Parsed `test` command inputs that are validated before app launch. diff --git a/lib/src/test_command_support.dart b/lib/src/execution/test_command_support.dart similarity index 84% rename from lib/src/test_command_support.dart rename to lib/src/execution/test_command_support.dart index 8639477..144f1d7 100644 --- a/lib/src/test_command_support.dart +++ b/lib/src/execution/test_command_support.dart @@ -8,7 +8,7 @@ library; export 'project_run_executor.dart'; export 'test_command_executor.dart'; export 'test_command_models.dart'; -export 'test_command_output.dart'; -export 'test_device_discovery.dart'; +export '../test_command_output.dart'; +export '../target/test_device_discovery.dart'; export 'test_execution_session.dart'; export 'test_scenario_runner_factory.dart'; diff --git a/lib/src/test_execution_session.dart b/lib/src/execution/test_execution_session.dart similarity index 96% rename from lib/src/test_execution_session.dart rename to lib/src/execution/test_execution_session.dart index ce3a55c..75886f2 100644 --- a/lib/src/test_execution_session.dart +++ b/lib/src/execution/test_execution_session.dart @@ -1,13 +1,13 @@ import 'dart:async'; import 'dart:io'; -import 'runtime/runtime_contract.dart'; -import 'target_app_launch_progress_renderer.dart'; -import 'target_app_launcher.dart'; -import 'target_device.dart'; +import '../runtime/runtime_contract.dart'; +import '../target/test_target_device_selection.dart'; +import '../target/target_app_launch_progress_renderer.dart'; +import '../target/target_app_launcher.dart'; +import '../target/target_device.dart'; import 'test_command_models.dart'; -import 'test_device_discovery.dart'; -import 'test_target_device_selection.dart'; +import '../target/test_device_discovery.dart'; /// Starts the shared execution lifetime used by `flutter_pilot test`. abstract interface class TestExecutionSessionFactory { diff --git a/lib/src/test_scenario_runner_factory.dart b/lib/src/execution/test_scenario_runner_factory.dart similarity index 89% rename from lib/src/test_scenario_runner_factory.dart rename to lib/src/execution/test_scenario_runner_factory.dart index 035ec96..6a147c5 100644 --- a/lib/src/test_scenario_runner_factory.dart +++ b/lib/src/execution/test_scenario_runner_factory.dart @@ -1,12 +1,12 @@ import 'dart:io'; -import 'artifacts/artifact_store.dart'; -import 'recording/recording_contract.dart'; -import 'runtime/runtime_adapter_selector.dart'; -import 'runtime/runtime_contract.dart'; -import 'scenario.dart'; +import '../artifacts/artifact_store.dart'; +import '../recording/recording_contract.dart'; +import '../runtime/runtime_adapter_selector.dart'; +import '../runtime/runtime_contract.dart'; +import '../scenario/scenario.dart'; import 'scenario_runner.dart'; -import 'target_device.dart'; +import '../target/target_device.dart'; /// Creates a Scenario runner for one launched Runtime Target. abstract interface class TestScenarioRunnerFactory { diff --git a/lib/src/recording/recording_contract.dart b/lib/src/recording/recording_contract.dart index 739b23f..f15a96e 100644 --- a/lib/src/recording/recording_contract.dart +++ b/lib/src/recording/recording_contract.dart @@ -1,4 +1,4 @@ -import '../scenario.dart'; +import '../scenario/scenario.dart'; /// Boundary used by the runner to control full-run Scenario Recording. /// diff --git a/lib/src/recording/screen_recorder_recording_controller.dart b/lib/src/recording/screen_recorder_recording_controller.dart index fcbd9df..7c9f2b8 100644 --- a/lib/src/recording/screen_recorder_recording_controller.dart +++ b/lib/src/recording/screen_recorder_recording_controller.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:screen_recorder/screen_recorder.dart' as screen_recorder; -import '../scenario.dart'; +import '../scenario/scenario.dart'; import 'recording_contract.dart'; /// Recording Controller backed by the `screen_recorder` package. diff --git a/lib/src/html_timeline_report.dart b/lib/src/reports/html_timeline_report.dart similarity index 99% rename from lib/src/html_timeline_report.dart rename to lib/src/reports/html_timeline_report.dart index 49986d6..5370de6 100644 --- a/lib/src/html_timeline_report.dart +++ b/lib/src/reports/html_timeline_report.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'dart:io'; -import 'artifacts/artifact_store.dart'; -import 'scenario_runner.dart'; +import '../artifacts/artifact_store.dart'; +import '../execution/scenario_runner.dart'; /// Generates a browser-readable timeline from one Scenario run report. /// diff --git a/lib/src/project_run_report.dart b/lib/src/reports/project_run_report.dart similarity index 100% rename from lib/src/project_run_report.dart rename to lib/src/reports/project_run_report.dart diff --git a/lib/src/runtime/pilot_runtime_adapter.dart b/lib/src/runtime/pilot_runtime_adapter.dart index 4ebd226..d578353 100644 --- a/lib/src/runtime/pilot_runtime_adapter.dart +++ b/lib/src/runtime/pilot_runtime_adapter.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import 'package:pilot_runtime/pilot_runtime.dart'; -import '../scenario.dart'; +import '../scenario/scenario.dart'; import 'runtime_contract.dart'; /// Runtime Adapter backed by the `pilot_runtime` package. diff --git a/lib/src/runtime/runtime_contract.dart b/lib/src/runtime/runtime_contract.dart index 7cf8ac5..240ef37 100644 --- a/lib/src/runtime/runtime_contract.dart +++ b/lib/src/runtime/runtime_contract.dart @@ -1,6 +1,6 @@ import 'dart:typed_data'; -import '../scenario.dart'; +import '../scenario/scenario.dart'; /// Runtime Target selected for one Scenario run. /// diff --git a/lib/src/project_scenario_discovery.dart b/lib/src/scenario/project_scenario_discovery.dart similarity index 100% rename from lib/src/project_scenario_discovery.dart rename to lib/src/scenario/project_scenario_discovery.dart diff --git a/lib/src/scenario.dart b/lib/src/scenario/scenario.dart similarity index 100% rename from lib/src/scenario.dart rename to lib/src/scenario/scenario.dart diff --git a/lib/src/scenario_dsl_docs.dart b/lib/src/scenario/scenario_dsl_docs.dart similarity index 100% rename from lib/src/scenario_dsl_docs.dart rename to lib/src/scenario/scenario_dsl_docs.dart diff --git a/lib/src/scenario_parser.dart b/lib/src/scenario/scenario_parser.dart similarity index 100% rename from lib/src/scenario_parser.dart rename to lib/src/scenario/scenario_parser.dart diff --git a/lib/src/smoke_verifier.dart b/lib/src/smoke/smoke_verifier.dart similarity index 100% rename from lib/src/smoke_verifier.dart rename to lib/src/smoke/smoke_verifier.dart diff --git a/lib/src/step_progress_renderer.dart b/lib/src/target/step_progress_renderer.dart similarity index 98% rename from lib/src/step_progress_renderer.dart rename to lib/src/target/step_progress_renderer.dart index dd7a945..be38da5 100644 --- a/lib/src/step_progress_renderer.dart +++ b/lib/src/target/step_progress_renderer.dart @@ -1,8 +1,8 @@ import 'dart:io'; -import 'scenario.dart'; -import 'scenario_runner.dart'; -import 'terminal_style.dart'; +import '../scenario/scenario.dart'; +import '../execution/scenario_runner.dart'; +import '../terminal_style.dart'; /// Renders deterministic plain-text Step progress for CLI output. /// diff --git a/lib/src/target_app_launch_progress_renderer.dart b/lib/src/target/target_app_launch_progress_renderer.dart similarity index 100% rename from lib/src/target_app_launch_progress_renderer.dart rename to lib/src/target/target_app_launch_progress_renderer.dart diff --git a/lib/src/target_app_launcher.dart b/lib/src/target/target_app_launcher.dart similarity index 100% rename from lib/src/target_app_launcher.dart rename to lib/src/target/target_app_launcher.dart diff --git a/lib/src/target_device.dart b/lib/src/target/target_device.dart similarity index 100% rename from lib/src/target_device.dart rename to lib/src/target/target_device.dart diff --git a/lib/src/test_device_discovery.dart b/lib/src/target/test_device_discovery.dart similarity index 97% rename from lib/src/test_device_discovery.dart rename to lib/src/target/test_device_discovery.dart index b72d1e3..58785df 100644 --- a/lib/src/test_device_discovery.dart +++ b/lib/src/target/test_device_discovery.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:screen_recorder/screen_recorder.dart' as screen_recorder; import 'target_device.dart'; -import 'test_command_models.dart'; +import '../execution/test_command_models.dart'; /// Discovers Flutter Devices and Recording Devices for `test`. abstract interface class TestDeviceDiscovery { diff --git a/lib/src/test_target_device_selection.dart b/lib/src/target/test_target_device_selection.dart similarity index 100% rename from lib/src/test_target_device_selection.dart rename to lib/src/target/test_target_device_selection.dart diff --git a/lib/src/test_command_output.dart b/lib/src/test_command_output.dart index 508979d..00276d8 100644 --- a/lib/src/test_command_output.dart +++ b/lib/src/test_command_output.dart @@ -1,8 +1,8 @@ import 'dart:io'; -import 'step_progress_renderer.dart'; -import 'target_app_launch_progress_renderer.dart'; -import 'test_command_models.dart'; +import 'target/step_progress_renderer.dart'; +import 'target/target_app_launch_progress_renderer.dart'; +import 'execution/test_command_models.dart'; /// Terminal output helpers for the `test` command. class TestCommandOutput { diff --git a/test/app_setup_test.dart b/test/app_setup/app_setup_test.dart similarity index 100% rename from test/app_setup_test.dart rename to test/app_setup/app_setup_test.dart diff --git a/test/artifact_store_test.dart b/test/artifacts/artifact_store_test.dart similarity index 100% rename from test/artifact_store_test.dart rename to test/artifacts/artifact_store_test.dart diff --git a/test/cli_test.dart b/test/cli/cli_test.dart similarity index 100% rename from test/cli_test.dart rename to test/cli/cli_test.dart diff --git a/test/terminal_style_test.dart b/test/cli/output/terminal_style_test.dart similarity index 100% rename from test/terminal_style_test.dart rename to test/cli/output/terminal_style_test.dart diff --git a/test/test_command_output_test.dart b/test/cli/output/test_command_output_test.dart similarity index 100% rename from test/test_command_output_test.dart rename to test/cli/output/test_command_output_test.dart diff --git a/test/test_command_cli_test.dart b/test/cli/test_command_cli_test.dart similarity index 99% rename from test/test_command_cli_test.dart rename to test/cli/test_command_cli_test.dart index 3490fab..ff61417 100644 --- a/test/test_command_cli_test.dart +++ b/test/cli/test_command_cli_test.dart @@ -5,7 +5,7 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/test_command_fakes.dart'; +import '../support/test_command_fakes.dart'; /// Verifies `flutter_pilot test` CLI mode selection and output wiring. void main() { diff --git a/test/diagnostic_reducer_test.dart b/test/diagnostics/diagnostic_reducer_test.dart similarity index 100% rename from test/diagnostic_reducer_test.dart rename to test/diagnostics/diagnostic_reducer_test.dart diff --git a/test/diagnostic_text_renderer_test.dart b/test/diagnostics/diagnostic_text_renderer_test.dart similarity index 100% rename from test/diagnostic_text_renderer_test.dart rename to test/diagnostics/diagnostic_text_renderer_test.dart diff --git a/test/run_diff_acceptance_fixture_test.dart b/test/diff/run_diff_acceptance_fixture_test.dart similarity index 100% rename from test/run_diff_acceptance_fixture_test.dart rename to test/diff/run_diff_acceptance_fixture_test.dart diff --git a/test/run_diff_cli_test.dart b/test/diff/run_diff_cli_test.dart similarity index 99% rename from test/run_diff_cli_test.dart rename to test/diff/run_diff_cli_test.dart index cf44c99..014e55d 100644 --- a/test/run_diff_cli_test.dart +++ b/test/diff/run_diff_cli_test.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:test/test.dart'; -import 'support/run_diff_fixtures.dart'; +import '../support/run_diff_fixtures.dart'; /// Exercise `flutter_pilot diff` through real Dart subprocesses. void main() { diff --git a/test/run_diff_diagnostics_test.dart b/test/diff/run_diff_diagnostics_test.dart similarity index 99% rename from test/run_diff_diagnostics_test.dart rename to test/diff/run_diff_diagnostics_test.dart index 501650f..bf3952d 100644 --- a/test/run_diff_diagnostics_test.dart +++ b/test/diff/run_diff_diagnostics_test.dart @@ -4,7 +4,7 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/run_diff_fixtures.dart'; +import '../support/run_diff_fixtures.dart'; /// Verify visible text and runtime failure Run Diff behavior. void main() { diff --git a/test/run_diff_json_test.dart b/test/diff/run_diff_json_test.dart similarity index 99% rename from test/run_diff_json_test.dart rename to test/diff/run_diff_json_test.dart index 55533f7..58a6c32 100644 --- a/test/run_diff_json_test.dart +++ b/test/diff/run_diff_json_test.dart @@ -4,7 +4,7 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/run_diff_fixtures.dart'; +import '../support/run_diff_fixtures.dart'; /// Verify stable machine-readable Run Diff JSON output. void main() { diff --git a/test/run_diff_screenshot_test.dart b/test/diff/run_diff_screenshot_test.dart similarity index 99% rename from test/run_diff_screenshot_test.dart rename to test/diff/run_diff_screenshot_test.dart index a128273..a5ead02 100644 --- a/test/run_diff_screenshot_test.dart +++ b/test/diff/run_diff_screenshot_test.dart @@ -4,7 +4,7 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/run_diff_fixtures.dart'; +import '../support/run_diff_fixtures.dart'; /// Verify Screenshot artifact Run Diff behavior. void main() { diff --git a/test/run_diff_step_test.dart b/test/diff/run_diff_step_test.dart similarity index 99% rename from test/run_diff_step_test.dart rename to test/diff/run_diff_step_test.dart index ee5901d..e881413 100644 --- a/test/run_diff_step_test.dart +++ b/test/diff/run_diff_step_test.dart @@ -2,7 +2,7 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/run_diff_fixtures.dart'; +import '../support/run_diff_fixtures.dart'; /// Verify Step alignment and Step finding behavior through Run Diff APIs. void main() { diff --git a/test/project_run_executor_test.dart b/test/execution/project_run_executor_test.dart similarity index 99% rename from test/project_run_executor_test.dart rename to test/execution/project_run_executor_test.dart index e7ec25a..da5ef06 100644 --- a/test/project_run_executor_test.dart +++ b/test/execution/project_run_executor_test.dart @@ -7,7 +7,7 @@ import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; -import 'support/test_command_fakes.dart'; +import '../support/test_command_fakes.dart'; /// Verifies Project Run execution through `DefaultProjectRunExecutor`. void main() { diff --git a/test/scenario_runner_test.dart b/test/execution/scenario_runner_test.dart similarity index 99% rename from test/scenario_runner_test.dart rename to test/execution/scenario_runner_test.dart index a7997ab..f56d473 100644 --- a/test/scenario_runner_test.dart +++ b/test/execution/scenario_runner_test.dart @@ -6,8 +6,8 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/fake_recording_controller.dart'; -import 'support/fake_runtime_adapter.dart'; +import '../support/fake_recording_controller.dart'; +import '../support/fake_runtime_adapter.dart'; /// Exercises Scenario execution through the public runner API. /// diff --git a/test/test_command_executor_test.dart b/test/execution/test_command_executor_test.dart similarity index 99% rename from test/test_command_executor_test.dart rename to test/execution/test_command_executor_test.dart index a7ca908..3970f4d 100644 --- a/test/test_command_executor_test.dart +++ b/test/execution/test_command_executor_test.dart @@ -5,7 +5,7 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/test_command_fakes.dart'; +import '../support/test_command_fakes.dart'; /// Verifies single Entry Scenario execution through `DefaultTestCommandExecutor`. void main() { diff --git a/test/test_execution_session_test.dart b/test/execution/test_execution_session_test.dart similarity index 100% rename from test/test_execution_session_test.dart rename to test/execution/test_execution_session_test.dart diff --git a/test/screen_recorder_recording_controller_test.dart b/test/recording/screen_recorder_recording_controller_test.dart similarity index 100% rename from test/screen_recorder_recording_controller_test.dart rename to test/recording/screen_recorder_recording_controller_test.dart diff --git a/test/html_timeline_report_test.dart b/test/reports/html_timeline_report_test.dart similarity index 98% rename from test/html_timeline_report_test.dart rename to test/reports/html_timeline_report_test.dart index c083aa2..f6a9359 100644 --- a/test/html_timeline_report_test.dart +++ b/test/reports/html_timeline_report_test.dart @@ -5,7 +5,7 @@ import 'package:file_testkit/file_testkit.dart'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/fake_runtime_adapter.dart'; +import '../support/fake_runtime_adapter.dart'; /// Verifies HTML timeline output through public runner and report APIs. void main() { diff --git a/test/project_run_report_test.dart b/test/reports/project_run_report_test.dart similarity index 100% rename from test/project_run_report_test.dart rename to test/reports/project_run_report_test.dart diff --git a/test/pilot_runtime_adapter_test.dart b/test/runtime/pilot_runtime_adapter_test.dart similarity index 100% rename from test/pilot_runtime_adapter_test.dart rename to test/runtime/pilot_runtime_adapter_test.dart diff --git a/test/runtime_adapter_selection_test.dart b/test/runtime/runtime_adapter_selection_test.dart similarity index 100% rename from test/runtime_adapter_selection_test.dart rename to test/runtime/runtime_adapter_selection_test.dart diff --git a/test/runtime_contract_test.dart b/test/runtime/runtime_contract_test.dart similarity index 99% rename from test/runtime_contract_test.dart rename to test/runtime/runtime_contract_test.dart index d215d66..7b1a0a2 100644 --- a/test/runtime_contract_test.dart +++ b/test/runtime/runtime_contract_test.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; -import 'support/fake_runtime_adapter.dart'; +import '../support/fake_runtime_adapter.dart'; /// Exercises the Runtime Adapter contract through the public package export. /// diff --git a/test/project_scenario_discovery_test.dart b/test/scenario/project_scenario_discovery_test.dart similarity index 100% rename from test/project_scenario_discovery_test.dart rename to test/scenario/project_scenario_discovery_test.dart diff --git a/test/scenario_dsl_docs_test.dart b/test/scenario/scenario_dsl_docs_test.dart similarity index 100% rename from test/scenario_dsl_docs_test.dart rename to test/scenario/scenario_dsl_docs_test.dart diff --git a/test/scenario_dsl_docs_tool_test.dart b/test/scenario/scenario_dsl_docs_tool_test.dart similarity index 100% rename from test/scenario_dsl_docs_tool_test.dart rename to test/scenario/scenario_dsl_docs_tool_test.dart diff --git a/test/scenario_parser_test.dart b/test/scenario/scenario_parser_test.dart similarity index 100% rename from test/scenario_parser_test.dart rename to test/scenario/scenario_parser_test.dart diff --git a/test/scenario_test.dart b/test/scenario/scenario_test.dart similarity index 100% rename from test/scenario_test.dart rename to test/scenario/scenario_test.dart diff --git a/test/smoke_scenario_contract_test.dart b/test/smoke/smoke_scenario_contract_test.dart similarity index 100% rename from test/smoke_scenario_contract_test.dart rename to test/smoke/smoke_scenario_contract_test.dart diff --git a/test/smoke_script_test.dart b/test/smoke/smoke_script_test.dart similarity index 98% rename from test/smoke_script_test.dart rename to test/smoke/smoke_script_test.dart index 0831a98..b071d01 100644 --- a/test/smoke_script_test.dart +++ b/test/smoke/smoke_script_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:flutter_pilot/src/smoke_verifier.dart'; +import 'package:flutter_pilot/src/smoke/smoke_verifier.dart'; import 'package:test/test.dart'; /// Verifies the real-runtime smoke report contract. diff --git a/test/extract_flutter_machine_ws_uri_test.dart b/test/target/extract_flutter_machine_ws_uri_test.dart similarity index 100% rename from test/extract_flutter_machine_ws_uri_test.dart rename to test/target/extract_flutter_machine_ws_uri_test.dart diff --git a/test/step_progress_renderer_test.dart b/test/target/step_progress_renderer_test.dart similarity index 100% rename from test/step_progress_renderer_test.dart rename to test/target/step_progress_renderer_test.dart diff --git a/test/target_app_launch_progress_renderer_test.dart b/test/target/target_app_launch_progress_renderer_test.dart similarity index 100% rename from test/target_app_launch_progress_renderer_test.dart rename to test/target/target_app_launch_progress_renderer_test.dart diff --git a/test/target_app_launcher_test.dart b/test/target/target_app_launcher_test.dart similarity index 100% rename from test/target_app_launcher_test.dart rename to test/target/target_app_launcher_test.dart diff --git a/test/target_device_resolver_test.dart b/test/target/target_device_resolver_test.dart similarity index 100% rename from test/target_device_resolver_test.dart rename to test/target/target_device_resolver_test.dart From a9165f2f22a5b994948a633f526a485ff0c2ea8b Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 15:43:38 +0800 Subject: [PATCH 7/8] docs(context): define test execution session --- CONTEXT.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 01e1817..33ce6f3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,6 +24,10 @@ _Avoid_: Scenario Suite, full test, all YAML files A Flutter Pilot execution that runs multiple Project Scenarios as one batch for a Target App Package. A Project Run has one batch-level run directory that groups each Scenario Run directory and a project-level summary report. _Avoid_: Full test run, test suite run, all-Scenario run +**Test Execution Session**: +The shared execution lifetime inside `flutter_pilot test` that prepares one launched Target App Package for Scenario execution, exposes the Runtime Target and Target Device selected for that launch, handles launch progress and interruption, and owns cleanup. A single Entry Scenario uses one Test Execution Session for one Scenario Run; a Project Run uses one Test Execution Session across multiple Project Scenarios with hot restarts between later Scenarios. +_Avoid_: Run command, Project Run, Scenario Run + **Runtime Target**: The running Flutter app instance that a Scenario is executed against. Connection details for the Runtime Target are provided outside the Scenario. _Avoid_: Target configuration, environment block From 7eb769d9e6d106055f254c4dd0b77373ffc308f9 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 16:01:08 +0800 Subject: [PATCH 8/8] ci(docs): update docs check test paths --- .github/workflows/docs-check.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 2b528b1..65c675b 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -7,10 +7,10 @@ on: paths: - "docs/**" - "lib/src/cli.dart" - - "lib/src/scenario_dsl_docs.dart" + - "lib/src/scenario/scenario_dsl_docs.dart" - "tool/check_*_docs.dart" - "tool/generate_*_docs.dart" - - "test/*docs*_test.dart" + - "test/**docs*_test.dart" - "package.json" - "package-lock.json" - ".github/workflows/docs-check.yml" @@ -49,7 +49,7 @@ jobs: run: dart analyze --fatal-infos --fatal-warnings - name: Test docs examples - run: dart test test/scenario_dsl_docs_test.dart test/scenario_dsl_docs_tool_test.dart + run: dart test test/scenario/scenario_dsl_docs_test.dart test/scenario/scenario_dsl_docs_tool_test.dart - name: Check Scenario DSL docs run: dart run tool/check_scenario_dsl_docs.dart