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 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 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 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 8748946..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. /// @@ -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/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 0045061..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. /// @@ -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/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_command_executor.dart b/lib/src/execution/project_run_executor.dart similarity index 55% rename from lib/src/project_run_command_executor.dart rename to lib/src/execution/project_run_executor.dart index d759fe4..fe445fa 100644 --- a/lib/src/project_run_command_executor.dart +++ b/lib/src/execution/project_run_executor.dart @@ -1,32 +1,29 @@ -import 'dart:async'; 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 'runtime/runtime_contract.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_device.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'; -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,16 +31,17 @@ 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(), + 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 DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { /// 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; @@ -67,8 +68,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, @@ -81,110 +82,25 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { 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 ProjectRunCommandReport( - 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, - ); - 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, - ), + launchHeartbeatEnabled: launchHeartbeatEnabled, + onLaunchProgress: onLaunchProgress, ); - 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,13 +115,13 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { final TestScenarioRunner runner; try { runner = runnerFactory.create( - runtimeTarget: RuntimeTarget(vmServiceUri: launch.runtimeTargetUri), - 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, @@ -222,8 +138,9 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { 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 @@ -243,21 +160,16 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { ), ); } - } 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; @@ -268,8 +180,7 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { ); } finally { stopwatch.stop(); - await launchHeartbeat?.stop(); - await launch?.cleanup(); + await session?.close(); } final bool allPassed = environmentFailure == null && @@ -308,7 +219,7 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { scenarioResults: scenarioResults, ); projectRunWriter.writeProjectRunReport(projectReport.toJson()); - return ProjectRunCommandReport( + return ProjectRunResult( passed: projectStatus == ProjectRunStatus.passed, status: projectStatus, projectRunReportPath: p.join( @@ -333,33 +244,14 @@ class DefaultProjectRunCommandExecutor implements ProjectRunCommandExecutor { ); } - 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/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/execution/test_command_executor.dart b/lib/src/execution/test_command_executor.dart new file mode 100644 index 0000000..ce2d657 --- /dev/null +++ b/lib/src/execution/test_command_executor.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +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 'scenario_runner.dart'; +import '../target/target_app_launch_progress_renderer.dart'; +import '../target/target_app_launcher.dart'; +import 'test_command_models.dart'; +import '../target/test_device_discovery.dart'; +import 'test_execution_session.dart'; +import 'test_scenario_runner_factory.dart'; + +/// Executes a validated `test` command. +abstract interface class TestCommandExecutor { + /// Run the Scenario described by `options` and return its run report. + /// + /// `onLaunchProgress`, when provided, receives Target App Launch Progress + /// events before Scenario execution starts. `launchHeartbeatEnabled` controls + /// whether long pending launches emit heartbeat events. `onProgress`, when + /// provided, receives Scenario Step progress events during execution. + Future run( + TestCommandOptions options, { + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + bool launchHeartbeatEnabled = false, + void Function(StepProgressEvent event)? onProgress, + }); +} + +/// Default `test` command executor for launching and running one Scenario. +class DefaultTestCommandExecutor implements TestCommandExecutor { + /// Creates an executor with injectable launch and discovery boundaries. + /// + /// `launchHeartbeatTicks` and `launchClock` make Target App Launch Progress + /// deterministic in tests without waiting on real time. + 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; + final TargetAppLaunchClock launchClock; + + @override + Future run( + TestCommandOptions options, { + void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, + bool launchHeartbeatEnabled = false, + void Function(StepProgressEvent event)? onProgress, + }) async { + final bool recordingRequired = options.scenario.recording?.enabled == true; + final TestExecutionSession session; + try { + session = await _effectiveSessionFactory.start( + deviceSelector: options.device, + flavor: options.flavor, + target: options.target, + recordingRequired: recordingRequired, + launchHeartbeatEnabled: launchHeartbeatEnabled, + onLaunchProgress: onLaunchProgress, + ); + } on TestExecutionTargetDeviceException catch (error) { + throw TestCommandException(message: error.message, exitCode: 64); + } on TestExecutionLaunchException catch (error) { + throw TestCommandException( + message: error.message, + exitCode: 1, + alreadyRendered: error.alreadyRendered, + ); + } + + try { + final TestScenarioRunner runner; + try { + runner = runnerFactory.create( + runtimeTarget: session.runtimeTarget, + targetDevice: session.targetDevice, + recordingController: recordingRequired + ? ScreenRecorderRecordingController( + recorder: screen_recorder.ScreenRecorder.defaultRecorder(), + deviceSelector: session.targetDevice!.id, + outputDirectory: Directory.current, + ) + : null, + ); + } on RuntimeAdapterSelectionException catch (error) { + throw TestCommandException(message: error.message, exitCode: 1); + } + final Future runFuture = runner.run( + options.scenario, + stopPoint: options.stopPoint, + printDiagnostics: options.printDiagnostics, + onProgress: onProgress, + ); + return await session.runWithInterrupt(runFuture); + } on RuntimeOperationException catch (error) { + throw TestCommandException(message: error.message, exitCode: 1); + } finally { + 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_models.dart b/lib/src/execution/test_command_models.dart similarity index 89% rename from lib/src/test_command_models.dart rename to lib/src/execution/test_command_models.dart index 303b1c2..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. @@ -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_support.dart b/lib/src/execution/test_command_support.dart similarity index 71% rename from lib/src/test_command_support.dart rename to lib/src/execution/test_command_support.dart index 3d61db8..144f1d7 100644 --- a/lib/src/test_command_support.dart +++ b/lib/src/execution/test_command_support.dart @@ -5,9 +5,10 @@ /// 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'; -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/execution/test_execution_session.dart b/lib/src/execution/test_execution_session.dart new file mode 100644 index 0000000..75886f2 --- /dev/null +++ b/lib/src/execution/test_execution_session.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'dart:io'; + +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 '../target/test_device_discovery.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/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 1c07ea1..d578353 100644 --- a/lib/src/runtime/pilot_runtime_adapter.dart +++ b/lib/src/runtime/pilot_runtime_adapter.dart @@ -1,6 +1,9 @@ +import 'dart:io'; +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. @@ -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..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. /// @@ -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/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 93% rename from lib/src/target_app_launcher.dart rename to lib/src/target/target_app_launcher.dart index dd36484..de7fc25 100644 --- a/lib/src/target_app_launcher.dart +++ b/lib/src/target/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/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_executor.dart b/lib/src/test_command_executor.dart deleted file mode 100644 index 24ea09b..0000000 --- a/lib/src/test_command_executor.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'dart:async'; -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 '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_scenario_runner_factory.dart'; -import 'test_target_device_selection.dart'; - -/// Executes a validated `test` command. -abstract interface class TestCommandExecutor { - /// Run the Scenario described by `options` and return its run report. - /// - /// `onLaunchProgress`, when provided, receives Target App Launch Progress - /// events before Scenario execution starts. `launchHeartbeatEnabled` controls - /// whether long pending launches emit heartbeat events. `onProgress`, when - /// provided, receives Scenario Step progress events during execution. - Future run( - TestCommandOptions options, { - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, - bool launchHeartbeatEnabled = false, - void Function(StepProgressEvent event)? onProgress, - }); -} - -/// Default `test` command executor for launching and running one Scenario. -class DefaultTestCommandExecutor implements TestCommandExecutor { - /// Creates an executor with injectable launch and discovery boundaries. - /// - /// `launchHeartbeatTicks` and `launchClock` make Target App Launch Progress - /// deterministic in tests without waiting on real time. - const DefaultTestCommandExecutor({ - this.deviceDiscovery = const DefaultTestDeviceDiscovery(), - this.launcher = const TargetAppLauncher(), - this.runnerFactory = const DefaultTestScenarioRunnerFactory(), - this.interruptSignals, - this.launchHeartbeatTicks, - this.launchClock = DateTime.now, - }); - - final TestDeviceDiscovery deviceDiscovery; - final TargetAppLauncher launcher; - final TestScenarioRunnerFactory runnerFactory; - final Stream? interruptSignals; - final Stream? launchHeartbeatTicks; - final TargetAppLaunchClock launchClock; - - @override - Future run( - TestCommandOptions options, { - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, - bool launchHeartbeatEnabled = false, - 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( - deviceSelector: options.device, - 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, - ); - 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')}' - : ''; - throw TestCommandException( - message: '${error.message}$stderrContext', - exitCode: 1, - alreadyRendered: onLaunchProgress != null, - ); - } - - try { - final TestScenarioRunner runner; - try { - runner = runnerFactory.create( - runtimeTarget: RuntimeTarget(vmServiceUri: launch.runtimeTargetUri), - targetDevice: targetDevice, - recordingController: recordingRequired - ? ScreenRecorderRecordingController( - recorder: screen_recorder.ScreenRecorder.defaultRecorder(), - deviceSelector: targetDevice!.id, - outputDirectory: Directory.current, - ) - : null, - ); - } on RuntimeAdapterSelectionException catch (error) { - throw TestCommandException(message: error.message, exitCode: 1); - } - final Future runFuture = runner.run( - options.scenario, - stopPoint: options.stopPoint, - 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(); - } - } on RuntimeOperationException catch (error) { - throw TestCommandException(message: error.message, exitCode: 1); - } finally { - await launch.cleanup(); - } - } -} diff --git a/lib/src/test_command_output.dart b/lib/src/test_command_output.dart index 5946056..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 { @@ -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/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/cli/output/test_command_output_test.dart b/test/cli/output/test_command_output_test.dart new file mode 100644 index 0000000..910b4b8 --- /dev/null +++ b/test/cli/output/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(); + } + }); + }, + ); +} diff --git a/test/cli/test_command_cli_test.dart b/test/cli/test_command_cli_test.dart new file mode 100644 index 0000000..ff61417 --- /dev/null +++ b/test/cli/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/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/execution/project_run_executor_test.dart b/test/execution/project_run_executor_test.dart new file mode 100644 index 0000000..da5ef06 --- /dev/null +++ b/test/execution/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/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/execution/test_command_executor_test.dart b/test/execution/test_command_executor_test.dart new file mode 100644 index 0000000..3970f4d --- /dev/null +++ b/test/execution/test_command_executor_test.dart @@ -0,0 +1,812 @@ +import 'dart:async'; +import 'dart:convert'; + +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 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( + passedScenarioRunReport(), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'launched', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: 'staging', + target: 'lib/main_staging.dart', + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: 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'}, + }), + ); + await Future.delayed(Duration.zero); + process.exit(0); + final ScenarioRunReport report = await reportFuture; + + expect(report.status, ScenarioRunStatus.passed); + expect(starter.startedArguments, [ + 'run', + '--machine', + '--flavor', + 'staging', + '--target', + 'lib/main_staging.dart', + ]); + expect( + 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']); + }); + }); + + 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( + passedScenarioRunReport(), + ); + 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 { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: const ThrowingScenarioRunnerFactory( + RuntimeAdapterSelectionException( + 'Invalid FLUTTER_PILOT_RUNTIME value "other_runtime".', + ), + ), + ); + final Scenario scenario = Scenario( + name: 'invalid_runtime_switch', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + + await expectLater( + reportFuture, + throwsA( + isA().having( + (TestCommandException error) => error.message, + 'message', + contains('FLUTTER_PILOT_RUNTIME'), + ), + ), + ); + expect(process.stdinWrites, ['q\n']); + }); + }, + ); + + test( + 'default executor forwards Step progress events to the runner', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'forwarded_progress', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + final List progressEvents = []; + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + onProgress: progressEvents.add, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + await Future.delayed(Duration.zero); + process.exit(0); + await reportFuture; + + expect(runner.onProgress, isNotNull); + expect(progressEvents, hasLength(2)); + expect(progressEvents.first, isA()); + expect(progressEvents.last, isA()); + }); + }, + ); + + test('default executor emits launch progress before Step progress', () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'launch_then_steps', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + final List progressOrder = []; + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + onLaunchProgress: (TargetAppLaunchProgressEvent event) { + progressOrder.add(event.runtimeType.toString()); + }, + onProgress: (StepProgressEvent event) { + progressOrder.add(event.runtimeType.toString()); + }, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, + }), + ); + await Future.delayed(Duration.zero); + process.exit(0); + await reportFuture; + + expect(progressOrder, [ + 'TargetAppLaunchStartedEvent', + 'TargetAppLaunchSucceededEvent', + 'StepStartedEvent', + 'StepFinishedEvent', + ]); + }); + }); + + test( + 'default 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( + passedScenarioRunReport(), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + 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'), + ], + ), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'recorded', + recording: const ScenarioRecording(enabled: true), + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + 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); + process.exit(0); + await reportFuture; + + expect(starter.startedArguments, [ + 'run', + '--machine', + '--device-id', + 'pixel-8', + ]); + expect(runner.targetDevice?.id, 'pixel-8'); + expect(runner.recordingController, isNotNull); + }); + }, + ); + + test( + 'default executor reports explicit Target Device launch choices', + () async { + await FileTestkit.runZoned(() async { + 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', + isSupported: true, + emulator: true, + sdk: 'Android 35', + ), + ], + ), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'explicit_device', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + TargetAppLaunchChoices? launchChoices; + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: 'Pixel', + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + onLaunchProgress: (TargetAppLaunchProgressEvent event) { + if (event is TargetAppLaunchStartedEvent) { + launchChoices = event.choices; + } + }, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + await Future.delayed(Duration.zero); + process.exit(0); + await reportFuture; + + expect(launchChoices?.targetDevice?.id, 'pixel-8'); + expect( + launchChoices?.selectionReason, + isA().having( + (ExplicitTargetDeviceSelectionReason reason) => reason.selector, + 'selector', + 'Pixel', + ), + ); + }); + }, + ); + + 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( + passedScenarioRunReport(), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + 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'), + ], + ), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'recorded', + recording: const ScenarioRecording(enabled: true), + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + TargetAppLaunchChoices? launchChoices; + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + onLaunchProgress: (TargetAppLaunchProgressEvent event) { + if (event is TargetAppLaunchStartedEvent) { + launchChoices = event.choices; + } + }, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + await Future.delayed(Duration.zero); + process.exit(0); + await reportFuture; + + expect(launchChoices?.targetDevice?.id, 'pixel-8'); + expect( + launchChoices?.selectionReason, + isA(), + ); + }); + }, + ); + + test( + 'default executor reports flavor and entrypoint launch choices', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final FakeScenarioRunner runner = FakeScenarioRunner( + passedScenarioRunReport(), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + ); + final Scenario scenario = Scenario( + name: 'flavored', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + TargetAppLaunchChoices? launchChoices; + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: 'staging', + target: 'lib/main_staging.dart', + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + onLaunchProgress: (TargetAppLaunchProgressEvent event) { + if (event is TargetAppLaunchStartedEvent) { + launchChoices = event.choices; + } + }, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + await Future.delayed(Duration.zero); + process.exit(0); + await reportFuture; + + expect(launchChoices?.flavor, 'staging'); + expect(launchChoices?.target, 'lib/main_staging.dart'); + }); + }, + ); + + test( + 'default executor emits launch failure progress with stderr tail', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory( + FakeScenarioRunner(passedScenarioRunReport()), + ), + ); + final Scenario scenario = Scenario( + name: 'launch_failure', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + final List launchEvents = + []; + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + onLaunchProgress: launchEvents.add, + ); + for (int index = 1; index <= 45; index++) { + process.emitStderr('stderr line $index'); + } + process.exit(1); + + await expectLater(reportFuture, throwsA(isA())); + final TargetAppLaunchFailedEvent failedEvent = launchEvents + .whereType() + .single; + expect( + failedEvent.message, + contains('Flutter exited before Runtime Target URI was available.'), + ); + expect(failedEvent.stderrLines, hasLength(40)); + expect(failedEvent.stderrLines, isNot(contains('stderr line 5'))); + expect(failedEvent.stderrLines, contains('stderr line 6')); + expect(failedEvent.stderrLines, contains('stderr line 45')); + }); + }, + ); + + test( + 'default executor emits non-interactive launch heartbeat until success', + () async { + await FileTestkit.runZoned(() async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final StreamController ticks = StreamController(); + DateTime now = DateTime.utc(2026, 6, 30, 12); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory( + FakeScenarioRunner(passedScenarioRunReport()), + ), + launchHeartbeatTicks: ticks.stream, + launchClock: () => now, + ); + final Scenario scenario = Scenario( + name: 'launch_heartbeat', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + final List launchEvents = + []; + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + onLaunchProgress: launchEvents.add, + launchHeartbeatEnabled: true, + ); + while (starter.startedArguments.isEmpty) { + await Future.delayed(Duration.zero); + } + now = DateTime.utc(2026, 6, 30, 12, 0, 10); + ticks.add(null); + await Future.delayed(Duration.zero); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': { + 'appId': 'app-1', + 'wsUri': 'ws://127.0.0.1:1234/token=/ws', + }, + }), + ); + await Future.delayed(Duration.zero); + process.exit(0); + await reportFuture; + now = DateTime.utc(2026, 6, 30, 12, 0, 20); + ticks.add(null); + await Future.delayed(Duration.zero); + await ticks.close(); + + expect( + launchEvents.whereType(), + hasLength(1), + ); + expect( + launchEvents.whereType(), + hasLength(1), + ); + }); + }, + ); + + test('default executor cleans up launched app 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 DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + deviceDiscovery: FakeDeviceDiscovery(), + launcher: TargetAppLauncher(starter: starter), + runnerFactory: FakeScenarioRunnerFactory(runner), + interruptSignals: interruptController.stream, + ); + final Scenario scenario = Scenario( + name: 'interrupt', + steps: const [ + ScenarioStep( + index: 1, + action: TapAction(finder: Finder(byText: 'Continue')), + ), + ], + ); + + final Future reportFuture = executor.run( + TestCommandOptions( + scenario: scenario, + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': {'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'), + ), + ), + ); + expect(process.stdinWrites, ['q\n']); + await interruptController.close(); + }); + }); +} diff --git a/test/execution/test_execution_session_test.dart b/test/execution/test_execution_session_test.dart new file mode 100644 index 0000000..b300f2b --- /dev/null +++ b/test/execution/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; + } +} 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 73% rename from test/pilot_runtime_adapter_test.dart rename to test/runtime/pilot_runtime_adapter_test.dart index e379d9c..b4b7f06 100644 --- a/test/pilot_runtime_adapter_test.dart +++ b/test/runtime/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/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/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/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/target_app_launch_progress_renderer_test.dart b/test/target/target_app_launch_progress_renderer_test.dart new file mode 100644 index 0000000..0d1108c --- /dev/null +++ b/test/target/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/target_app_launcher_test.dart b/test/target/target_app_launcher_test.dart similarity index 98% rename from test/target_app_launcher_test.dart rename to test/target/target_app_launcher_test.dart index 9d70bd8..f52f917 100644 --- a/test/target_app_launcher_test.dart +++ b/test/target/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/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 diff --git a/test/test_command_executor_test.dart b/test/test_command_executor_test.dart deleted file mode 100644 index b095c03..0000000 --- a/test/test_command_executor_test.dart +++ /dev/null @@ -1,2854 +0,0 @@ -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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: 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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: 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 ProjectRunCommandReport report = ProjectRunCommandReport( - 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 ProjectRunCommandReport report = ProjectRunCommandReport( - 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 ProjectRunCommandReport report = ProjectRunCommandReport( - 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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - testCommandExecutor: executor, - projectRunCommandExecutor: 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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: 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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: 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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: 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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: 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 FakeProjectRunCommandExecutor projectExecutor = - FakeProjectRunCommandExecutor(report: _passedProjectReport()); - - final int exitCode = await FlutterPilotCli( - projectRunCommandExecutor: 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(); - } - }); - }, - ); - - 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 DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - deviceDiscovery: FakeDeviceDiscovery(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - ); - final Scenario scenario = Scenario( - name: 'launched', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: 'staging', - target: 'lib/main_staging.dart', - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, - }), - ); - await Future.delayed(Duration.zero); - process.exit(0); - final ScenarioRunReport report = await reportFuture; - - expect(report.status, ScenarioRunStatus.passed); - expect(starter.startedArguments, [ - 'run', - '--machine', - '--flavor', - 'staging', - '--target', - 'lib/main_staging.dart', - ]); - expect( - runner.runtimeTarget.vmServiceUri.toString(), - 'ws://127.0.0.1:1234/token=/ws', - ); - expect(runner.scenario.name, 'launched'); - expect(process.stdinWrites, ['q\n']); - }); - }); - - test( - 'default executor reports invalid hidden runtime switch values', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - deviceDiscovery: FakeDeviceDiscovery(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: const ThrowingScenarioRunnerFactory( - RuntimeAdapterSelectionException( - 'Invalid FLUTTER_PILOT_RUNTIME value "other_runtime".', - ), - ), - ); - final Scenario scenario = Scenario( - name: 'invalid_runtime_switch', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - - await expectLater( - reportFuture, - throwsA( - isA().having( - (TestCommandException error) => error.message, - 'message', - contains('FLUTTER_PILOT_RUNTIME'), - ), - ), - ); - expect(process.stdinWrites, ['q\n']); - }); - }, - ); - - test( - 'default executor forwards Step progress events to the runner', - () 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(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - ); - final Scenario scenario = Scenario( - name: 'forwarded_progress', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - final List progressEvents = []; - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - onProgress: progressEvents.add, - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - await Future.delayed(Duration.zero); - process.exit(0); - await reportFuture; - - expect(runner.onProgress, isNotNull); - expect(progressEvents, hasLength(2)); - expect(progressEvents.first, isA()); - expect(progressEvents.last, isA()); - }); - }, - ); - - test('default executor emits launch progress before Step progress', () 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(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - ); - final Scenario scenario = Scenario( - name: 'launch_then_steps', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - final List progressOrder = []; - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - onLaunchProgress: (TargetAppLaunchProgressEvent event) { - progressOrder.add(event.runtimeType.toString()); - }, - onProgress: (StepProgressEvent event) { - progressOrder.add(event.runtimeType.toString()); - }, - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, - }), - ); - await Future.delayed(Duration.zero); - process.exit(0); - await reportFuture; - - expect(progressOrder, [ - 'TargetAppLaunchStartedEvent', - 'TargetAppLaunchSucceededEvent', - 'StepStartedEvent', - 'StepFinishedEvent', - ]); - }); - }); - - test( - 'default 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(_passedReport()); - final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - 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'), - ], - ), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - ); - final Scenario scenario = Scenario( - name: 'recorded', - recording: const ScenarioRecording(enabled: true), - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - 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); - process.exit(0); - await reportFuture; - - expect(starter.startedArguments, [ - 'run', - '--machine', - '--device-id', - 'pixel-8', - ]); - expect(runner.targetDevice?.id, 'pixel-8'); - expect(runner.recordingController, isNotNull); - }); - }, - ); - - test( - 'default executor reports explicit Target Device 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, - emulator: true, - sdk: 'Android 35', - ), - ], - ), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - ); - final Scenario scenario = Scenario( - name: 'explicit_device', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - TargetAppLaunchChoices? launchChoices; - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: 'Pixel', - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - onLaunchProgress: (TargetAppLaunchProgressEvent event) { - if (event is TargetAppLaunchStartedEvent) { - launchChoices = event.choices; - } - }, - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - await Future.delayed(Duration.zero); - process.exit(0); - await reportFuture; - - expect(launchChoices?.targetDevice?.id, 'pixel-8'); - expect( - launchChoices?.selectionReason, - isA().having( - (ExplicitTargetDeviceSelectionReason reason) => reason.selector, - 'selector', - '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( - '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, - emulator: true, - sdk: 'Android 35', - ), - ], - recordingDevices: [ - RecordingDeviceIdentity(id: 'pixel-8'), - ], - ), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - ); - final Scenario scenario = Scenario( - name: 'recorded', - recording: const ScenarioRecording(enabled: true), - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - TargetAppLaunchChoices? launchChoices; - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - onLaunchProgress: (TargetAppLaunchProgressEvent event) { - if (event is TargetAppLaunchStartedEvent) { - launchChoices = event.choices; - } - }, - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - await Future.delayed(Duration.zero); - process.exit(0); - await reportFuture; - - expect(launchChoices?.targetDevice?.id, 'pixel-8'); - expect( - launchChoices?.selectionReason, - isA(), - ); - }); - }, - ); - - 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 { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final FakeScenarioRunner runner = FakeScenarioRunner(_passedReport()); - final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - deviceDiscovery: FakeDeviceDiscovery(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - ); - final Scenario scenario = Scenario( - name: 'flavored', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - TargetAppLaunchChoices? launchChoices; - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: 'staging', - target: 'lib/main_staging.dart', - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - onLaunchProgress: (TargetAppLaunchProgressEvent event) { - if (event is TargetAppLaunchStartedEvent) { - launchChoices = event.choices; - } - }, - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - await Future.delayed(Duration.zero); - process.exit(0); - await reportFuture; - - expect(launchChoices?.flavor, 'staging'); - expect(launchChoices?.target, 'lib/main_staging.dart'); - }); - }, - ); - - test( - 'default executor emits launch failure progress with stderr tail', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - deviceDiscovery: FakeDeviceDiscovery(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory( - FakeScenarioRunner(_passedReport()), - ), - ); - final Scenario scenario = Scenario( - name: 'launch_failure', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - final List launchEvents = - []; - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - onLaunchProgress: launchEvents.add, - ); - for (int index = 1; index <= 45; index++) { - process.emitStderr('stderr line $index'); - } - process.exit(1); - - await expectLater(reportFuture, throwsA(isA())); - final TargetAppLaunchFailedEvent failedEvent = launchEvents - .whereType() - .single; - expect( - failedEvent.message, - contains('Flutter exited before Runtime Target URI was available.'), - ); - expect(failedEvent.stderrLines, hasLength(40)); - expect(failedEvent.stderrLines, isNot(contains('stderr line 5'))); - expect(failedEvent.stderrLines, contains('stderr line 6')); - expect(failedEvent.stderrLines, contains('stderr line 45')); - }); - }, - ); - - test( - 'default executor emits non-interactive launch heartbeat until success', - () async { - await FileTestkit.runZoned(() async { - final FakeTargetAppProcess process = FakeTargetAppProcess(); - final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( - process, - ); - final StreamController ticks = StreamController(); - DateTime now = DateTime.utc(2026, 6, 30, 12); - final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - deviceDiscovery: FakeDeviceDiscovery(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory( - FakeScenarioRunner(_passedReport()), - ), - launchHeartbeatTicks: ticks.stream, - launchClock: () => now, - ); - final Scenario scenario = Scenario( - name: 'launch_heartbeat', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - final List launchEvents = - []; - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - onLaunchProgress: launchEvents.add, - launchHeartbeatEnabled: true, - ); - while (starter.startedArguments.isEmpty) { - await Future.delayed(Duration.zero); - } - now = DateTime.utc(2026, 6, 30, 12, 0, 10); - ticks.add(null); - await Future.delayed(Duration.zero); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': { - 'appId': 'app-1', - 'wsUri': 'ws://127.0.0.1:1234/token=/ws', - }, - }), - ); - await Future.delayed(Duration.zero); - process.exit(0); - await reportFuture; - now = DateTime.utc(2026, 6, 30, 12, 0, 20); - ticks.add(null); - await Future.delayed(Duration.zero); - await ticks.close(); - - expect( - launchEvents.whereType(), - hasLength(1), - ); - expect( - launchEvents.whereType(), - hasLength(1), - ); - }); - }, - ); - - test('default executor cleans up launched app 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 DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( - deviceDiscovery: FakeDeviceDiscovery(), - launcher: TargetAppLauncher(starter: starter), - runnerFactory: FakeScenarioRunnerFactory(runner), - interruptSignals: interruptController.stream, - ); - final Scenario scenario = Scenario( - name: 'interrupt', - steps: const [ - ScenarioStep( - index: 1, - action: TapAction(finder: Finder(byText: 'Continue')), - ), - ], - ); - - final Future reportFuture = executor.run( - TestCommandOptions( - scenario: scenario, - device: null, - flavor: null, - target: null, - stopPoint: null, - printDiagnostics: const {}, - jsonOutput: false, - ), - ); - process.emitStdout( - jsonEncode({ - 'event': 'app.debugPort', - 'params': {'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'), - ), - ), - ); - expect(process.stdinWrites, ['q\n']); - 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 DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - 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( - ProjectRunCommandOptions( - 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 ProjectRunCommandReport 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 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 DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - 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( - ProjectRunCommandOptions( - 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 ProjectRunCommandReport 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 DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - 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( - ProjectRunCommandOptions( - 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 ProjectRunCommandReport 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 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 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 ProjectRunCommandReport report = await executor.run( - ProjectRunCommandOptions( - 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 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( - 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 DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - 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( - ProjectRunCommandOptions( - 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 ProjectRunCommandReport 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 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 Future reportFuture = executor.run( - ProjectRunCommandOptions( - 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 DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - 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( - ProjectRunCommandOptions( - 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 DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - 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( - ProjectRunCommandOptions( - 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 ProjectRunCommandReport 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 DefaultProjectRunCommandExecutor executor = - DefaultProjectRunCommandExecutor( - 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( - ProjectRunCommandOptions( - 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 ProjectRunCommandReport 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 FakeProjectRunCommandExecutor implements ProjectRunCommandExecutor { - FakeProjectRunCommandExecutor({required this.report}); - - final ProjectRunCommandReport report; - late ProjectRunCommandOptions options; - bool ran = false; - void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress; - bool? launchHeartbeatEnabled; - void Function(StepProgressEvent event)? onProgress; - - @override - Future run( - ProjectRunCommandOptions 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; - } -} - -ProjectRunCommandReport _passedProjectReport() { - return const ProjectRunCommandReport( - 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 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; - } -}