diff --git a/CONTEXT.md b/CONTEXT.md index b72d5d7..c807de8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,7 +53,7 @@ User-facing feedback shown while Flutter Pilot prepares the Target App Package f _Avoid_: Step progress, Scenario progress, Flutter build progress **Target Device**: -The device selected for a high-level Flutter Pilot test run. It may be selected explicitly by the user or automatically when Scenario Recording requires one recordable device. The Target App Package runs on this device, and any Scenario Recording for that run must record the same device. +The device selected for a high-level Flutter Pilot test run. It may be selected explicitly by the user or automatically when Scenario Recording requires one recordable device. The Target App Package runs on this Flutter Device. Scenario Recording for that run must use the paired Recording Device for the same physical or virtual device, matched by exact id or by unique exact name. _Avoid_: Runtime Target, Recording Device **Runtime Adapter**: diff --git a/README.md b/README.md index 6a96d44..baa4daf 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ widgets to tap, where to type, what to wait for, when to scroll, and where to capture diagnostic artifacts. Scenario metadata can also request full-run device video recording with -`scenario.recording`. Recording is run-level context: it starts before the first -Step, stops during run shutdown, and is reported as a Device Video Recording -artifact rather than a Step artifact. +`scenario.recording`. Recording is run-level context: Flutter Pilot may prepare +device capture before app launch, starts the saved video segment before the +first Step, stops it during run shutdown, and reports it as a Device Video +Recording artifact rather than a Step artifact. Runtime connection details are not stored in YAML. The same Scenario can be validated, shared, committed, and replayed against different Runtime Targets by @@ -236,9 +237,10 @@ vocabulary and selects the app entrypoint file; it does not accept a VM service URI. When `scenario.recording` is enabled, Flutter Pilot records the resolved Target -Device. The Target Device must also be available as a Recording Device with the -same device id. If `--device` is omitted, Flutter Pilot auto-selects only when -exactly one supported Flutter Device id is also recordable. +Device. The Target Device must also pair with a Recording Device by exact id or +by a unique exact name match. If `--device` is omitted, Flutter Pilot +auto-selects only when exactly one supported Flutter Device has a paired +Recording Device. ## Artifacts diff --git a/README_zh.md b/README_zh.md index 785b2d8..6d7afb7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -22,8 +22,9 @@ Flutter Pilot 把一次 UI 旅程视为一个可移植的 Scenario。Scenario 诊断产物。 Scenario metadata 也可以通过 `scenario.recording` 请求整次运行的设备视频录制。 -录制属于运行级上下文:它在第一个 Step 执行前启动,在运行收尾时停止,并作为 -Device Video Recording 产物记录,而不是某个 Step 的产物。 +录制属于运行级上下文:Flutter Pilot 可能会在应用启动前准备设备捕获,在第一个 +Step 执行前启动最终保存的视频片段,在运行收尾时停止,并作为 Device Video +Recording 产物记录,而不是某个 Step 的产物。 Runtime Target 的连接信息不会写进 YAML。相同的 Scenario 可以被校验、分享、 提交到仓库,并通过 CLI 参数在不同的 Runtime Target 上运行。 @@ -225,9 +226,9 @@ Step Library 候选,不会被直接运行。 用于选择应用入口文件,不接受 VM service URI。 启用 `scenario.recording` 时,Flutter Pilot 会录制已解析的 Target Device。 -这个 Target Device 必须同时是具有相同 device id 的 Recording Device。如果没有 -传入 `--device`,只有在恰好一个受支持 Flutter Device id 同时可录制时才会自动 -选择。 +这个 Target Device 必须能通过完全相同的 id,或唯一的完全相同名称,配对到一个 +Recording Device。如果没有传入 `--device`,只有在恰好一个受支持 Flutter Device +能配对到 Recording Device 时才会自动选择。 ## 产物 diff --git a/analysis_options.yaml b/analysis_options.yaml index 85f4554..d3882cc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,3 +3,4 @@ include: package:lints/recommended.yaml analyzer: exclude: - examples/smoke_app/** + - packages/pilot_runtime/test/** diff --git a/docs-internal/adr/0002-build-screen-recorder-as-independent-device-recording-package.md b/docs-internal/adr/0002-build-screen-recorder-as-independent-device-recording-package.md index bd3353c..643b988 100644 --- a/docs-internal/adr/0002-build-screen-recorder-as-independent-device-recording-package.md +++ b/docs-internal/adr/0002-build-screen-recorder-as-independent-device-recording-package.md @@ -7,6 +7,8 @@ package rather than as part of the Flutter Pilot Runtime Adapter. The package owns device-level recording concepts: - a Recording Device selected through recording backend discovery +- an optional Prepared Capture that opens backend capture resources before a + saved segment starts - a Recording Session started with `startRecord` - explicit `stopRecord` and discard behavior - a stop result that returns the final saved video path @@ -36,8 +38,10 @@ by default unless overwrite is explicitly requested. Physical iOS recording will use an in-package Swift helper based on the CMIO/AVFoundation approach already validated in the local `ios_screen` -prototype. The `screen_recorder` package will not shell out to that prototype or -depend on files outside its own package. +prototype. The helper supports prepared capture so AVFoundation can be opened +before a saved `.mov` segment begins and then disposed explicitly after the +segment is finalized. The `screen_recorder` package will not shell out to that +prototype or depend on files outside its own package. This keeps Flutter Pilot's Runtime Target model focused on Flutter app runtime communication through `mcp_flutter`, while `screen_recorder` remains a diff --git a/docs-internal/adr/0003-split-runtime-uri-run-from-target-device-test.md b/docs-internal/adr/0003-split-runtime-uri-run-from-target-device-test.md index 5446fb9..90305fd 100644 --- a/docs-internal/adr/0003-split-runtime-uri-run-from-target-device-test.md +++ b/docs-internal/adr/0003-split-runtime-uri-run-from-target-device-test.md @@ -13,9 +13,10 @@ Flutter app entrypoint file, while `--device` selects the Target Device and `--flavor` selects the Flutter flavor. Scenario Recording under `test` makes Target Device a first-class concept. When -recording is enabled, the selected Target Device must be both a supported -Flutter Device and a Recording Device with the same device id, and Flutter Pilot -uses that resolved id for both app launch and device recording. The trade-off is -that workflows which want to connect to an already-running app, such as an IDE -debug session or an externally supplied VM service URI, are out of the first -CLI contract and would require a future command if they become important again. +recording is enabled, the selected Target Device must be a supported Flutter +Device paired with a Recording Device by exact id or by a unique exact name +match. Flutter Pilot uses the Flutter Device id for app launch and the paired +Recording Device id for device recording. The trade-off is that workflows which +want to connect to an already-running app, such as an IDE debug session or an +externally supplied VM service URI, are out of the first CLI contract and would +require a future command if they become important again. diff --git a/docs-internal/flutter-pilot-prd.md b/docs-internal/flutter-pilot-prd.md index a1a4a35..ab61c92 100644 --- a/docs-internal/flutter-pilot-prd.md +++ b/docs-internal/flutter-pilot-prd.md @@ -169,7 +169,7 @@ The result is a reproducible bug report package that can be consumed by humans, - The `capture` action records diagnostic artifacts at a Step. `capture: {}` uses the default bundle: `screenshot: true`, `snapshot: true`, `widgetTree: false`, and `logs: true`. Each option can be explicitly overridden. - Failed Steps automatically capture the same default bundle as `capture: {}`. - Raw Widget Tree dumps may be available, but agent-facing output should default to compact summaries of visible text, interactive widgets, routes, logs, runtime failures, and likely suspects. The Diagnostic Reducer writes this summary as `diagnosticSummary` in the run report when `--print` captures raw Snapshot, Widget Tree, or error diagnostics. -- Scenario-level device video recording is supported as an optional run-level artifact. When recording is enabled, `test` requires the selected Target Device to also be available as a Recording Device with the same device id. The Device Video Recording is stored under the run directory as `artifacts/device-video-recording.` and recorded in reports with a run-directory-relative path. Richer recording parameters remain out of scope. Step screenshots and timeline reports remain the primary step-level visual artifacts. +- Scenario-level device video recording is supported as an optional run-level artifact. When recording is enabled, `test` requires the selected Target Device to pair with a Recording Device by exact id or by a unique exact name match. Flutter Pilot may prepare capture before app launch for backends that require it, but the saved Device Video Recording segment starts after Runtime Target initialization and before the first Step. The Device Video Recording is stored under the run directory as `artifacts/device-video-recording.` and recorded in reports with a run-directory-relative path. Richer recording parameters remain out of scope. Step screenshots and timeline reports remain the primary step-level visual artifacts. - The implementation should be organized around deep modules: - Scenario model and parser: validates YAML and produces a typed scenario. - Finder and action model: represents user intent independently from `pilot_runtime` command details. diff --git a/docs-internal/scenario-recording-prd.md b/docs-internal/scenario-recording-prd.md index a982b6f..e689a80 100644 --- a/docs-internal/scenario-recording-prd.md +++ b/docs-internal/scenario-recording-prd.md @@ -11,15 +11,17 @@ Teams that already have the `screen_recorder` package need Flutter Pilot to optionally create a Device Video Recording for the full Scenario Run without turning recording into a Step or mixing it into Capture Action semantics. Users need a strict Scenario DSL option that enables run-scoped recording, -starts before the first Step executes, stops after the run completes, and fails -clearly when recording was requested but cannot be started. +starts the saved video segment before the first Step executes, stops after the +run completes, and fails clearly when recording was requested but cannot be +started. ## Solution Add a Scenario-level recording option under `scenario.recording`. The option is part of Scenario metadata, not a Step Action. When enabled, Flutter Pilot -creates one Recording Session for the full Scenario Run: it starts recording -before Step execution begins and stops recording during run shutdown. +creates one Recording Session for the full Scenario Run: it may prepare device +capture before Target App launch, starts the saved segment before Step +execution begins, and stops recording during run shutdown. The first slice supports only a strict recording toggle in the YAML Scenario schema. The accepted forms are: @@ -42,7 +44,7 @@ backend availability. 1. As a Flutter developer, I want a Scenario to request full-run device video recording, so that I can review the entire visual flow around a bug. 2. As a Flutter developer, I want recording to be configured in Scenario metadata, so that it is clearly separate from Step behavior. -3. As a Flutter developer, I want recording to start before the first Step, so that the video includes pre-interaction context. +3. As a Flutter developer, I want the saved recording segment to start before the first Step, so that the video includes pre-interaction context without Flutter build or launch time. 4. As a Flutter developer, I want recording to stop after the run finishes, so that the final artifact covers the complete Scenario Run. 5. As a Flutter developer, I want recording to remain optional, so that ordinary runs do not pay recording cost by default. 6. As a Flutter developer, I want `scenario.recording: {}` to enable default recording behavior, so that simple Scenarios stay compact. @@ -86,15 +88,17 @@ backend availability. - Unknown fields inside `scenario.recording` are validation errors. - The parser continues to return typed domain objects on success and `ScenarioValidationException` on failure. - The runner treats Scenario Recording as run lifecycle state, not step lifecycle state. +- The executor may prepare backend recording capture before Target App launch when a backend requires it. Physical iOS uses this to keep AVFoundation capture warm without starting the saved movie segment. - When recording is enabled, the runner starts a Recording Session before executing any Scenario Step. - When recording is enabled, the runner stops the Recording Session during run shutdown so the final Device Video Recording path is available. +- Prepared recording capture is disposed after Target App cleanup. Disposal is awaited and idempotent. - If recording startup fails, the run fails before Step execution begins. - Validation remains schema-only; host recording capability is checked only during `test`. - Device Video Recording is stored as a run-level artifact rather than a Step artifact. - The artifact store should expose Device Video Recording with stable run-level metadata so JSON and HTML reporting can discover it without scanning raw directories. - Recording integration should depend on a narrow recording boundary rather than teaching the Runtime Adapter about device recording. - The Runtime Adapter remains responsible for Flutter Runtime Target operations only: Finder resolution, Step actions, Screenshot, Snapshot, Widget Tree, and Logs. -- Build or adapt a small recording integration module that translates Scenario Recording intent into `screen_recorder` session lifecycle calls. +- Build or adapt a small recording integration module that translates Scenario Recording intent into `screen_recorder` prepared capture and Recording Session lifecycle calls. - Keep recording session acquisition, stop, and failure normalization behind a fakeable interface so runner tests do not require real Android or iOS devices. - Documentation and glossary language should use Scenario Recording, Recording Session, Recording Device, and Device Video Recording consistently. - No ADR is required for the Scenario DSL addition by itself; the harder-to-reverse package-separation decision is already covered by the existing screen recorder ADR. @@ -112,7 +116,7 @@ backend availability. - CLI validation tests should verify that valid recording DSL passes `validate`. - CLI validation tests should verify that invalid recording DSL reports structured field paths. - Runner tests should use a fake recording boundary in the same spirit as existing fake Runtime Adapter tests. -- Runner tests should verify that recording starts before Step execution when enabled. +- Runner tests should verify that recording preparation can happen before launch and that the saved Recording Session starts before Step execution when enabled. - Runner tests should verify that recording is not started when recording is omitted or explicitly disabled. - Runner tests should verify that startup failure ends the run before any Step executes. - Runner tests should verify that a successful run saves a run-level Device Video Recording artifact. diff --git a/docs-internal/scenario-yaml.md b/docs-internal/scenario-yaml.md index 1024988..e3b6dd3 100644 --- a/docs-internal/scenario-yaml.md +++ b/docs-internal/scenario-yaml.md @@ -113,16 +113,17 @@ scenario: recording: true ``` -When recording is enabled, `flutter_pilot test` starts a Recording Session before -executing the first Step and stops it during run shutdown. If recording startup -fails, the run fails before any Step executes. The final Device Video Recording -is reported as a run-level artifact in `run_report.json`; it is not attached to -an individual Step. +When recording is enabled, `flutter_pilot test` may prepare device capture +before Target App launch, starts the saved Recording Session before executing +the first Step, and stops it during run shutdown. If recording preparation or +startup fails, the run fails before any Step executes. The final Device Video +Recording is reported as a run-level artifact in `run_report.json`; it is not +attached to an individual Step. `test` records the same Target Device that runs the app. The selected Target -Device must also be available as a Recording Device with the same device id. -Without `--device`, Flutter Pilot auto-selects only when exactly one supported -Flutter Device id is also recordable. +Device must pair with a Recording Device by exact id or by a unique exact name +match. Without `--device`, Flutter Pilot auto-selects only when exactly one +supported Flutter Device has a paired Recording Device. ## Steps diff --git a/docs-internal/screen-recorder-prd.md b/docs-internal/screen-recorder-prd.md index 50e35d9..b05620b 100644 --- a/docs-internal/screen-recorder-prd.md +++ b/docs-internal/screen-recorder-prd.md @@ -116,7 +116,8 @@ the final extension. - The primary contract is a programmatic API, not a CLI-first command surface. - Add a thin CLI for manual smoke testing and interactive foreground recording. - The CLI must call the same core library API that programmatic callers use. -- Core API includes `listDevices`, `startRecord`, `stopRecord`, and discard behavior. +- Core API includes `listDevices`, `startRecord`, `stopRecord`, discard behavior, and an optional prepared capture lifecycle. +- Prepared capture API includes `prepare`, `startRecord(preparedCapture: ...)`, and `dispose(capture)`. Backends without a separate prepared mode keep direct recording behavior and do not start recording during preparation. - `startRecord` returns a Recording Session. - `stopRecord` accepts a Recording Session and returns a Recording Result. - Discard behavior accepts a Recording Session and cleans up backend artifacts without returning a saved recording. @@ -156,9 +157,11 @@ the final extension. - iOS simulator discard stops the process and removes the local output file. - Physical iOS recording uses an in-package Swift helper based on the native AVFoundation/CoreMediaIO capture approach validated in the local prototype. - Physical iOS recording does not shell out to or depend on the local prototype directory. -- The Swift helper lists physical iOS capture devices and records one selected device to `.mov`. -- Physical iOS recording starts the helper as a long-running process and stops it by signaling the process so the helper can finalize the movie file. -- Physical iOS discard stops the helper and removes the local output file. +- The Swift helper lists physical iOS capture devices and can serve one selected device through a line-delimited JSON protocol. +- Physical iOS prepared recording starts the helper before a segment is requested, waits until AVFoundation has produced a real frame, then starts the `.mov` writer only when `startRecord(preparedCapture: ...)` is called. +- Physical iOS stop finalizes the active `.mov` segment while keeping the AVFoundation capture session alive for a later segment. +- Physical iOS dispose sends helper shutdown, waits for process exit, and stops native capture so host/device recording indicators do not remain active. +- Physical iOS discard finalizes or stops the active segment as needed and removes the local output file. - Support simultaneous active recordings on different Recording Devices. - Reject starting a second active Recording Session for the same Recording Device. - Build deep modules for device resolution, output naming, backend lifecycle, process execution, and CLI interaction. diff --git a/docs-internal/test-command-prd.md b/docs-internal/test-command-prd.md index 8f86311..e5432ed 100644 --- a/docs-internal/test-command-prd.md +++ b/docs-internal/test-command-prd.md @@ -36,11 +36,11 @@ The command supports common Flutter app launch options: - `--target` / `-t` selects the Flutter app entrypoint file. When Scenario Recording is enabled, Flutter Pilot requires the Target Device to -also be available as a Recording Device with the same device id. If the user -does not pass `--device`, Flutter Pilot automatically selects a Target Device -only when exactly one supported Flutter Device id is also present in the -Recording Device list. Zero or multiple recordable Target Devices fail before -launch and ask the user to pass `--device`. +pair with a Recording Device by exact id or by a unique exact name match. If the +user does not pass `--device`, Flutter Pilot automatically selects a Target +Device only when exactly one supported Flutter Device has a paired Recording +Device. Zero or multiple recordable Target Devices fail before launch and ask +the user to pass `--device`. The existing `run` command and user-supplied VM service URI mode are removed from the CLI. Runtime Target remains an internal model produced by the `test` @@ -80,7 +80,7 @@ launch flow, not a user-provided command option. 1. As a Flutter developer, I want Scenario Recording to record the same device that runs the app, so that the Device Video Recording reflects the actual Scenario Run. 1. As a Flutter developer, I want recording-enabled Scenarios to fail when no recordable Target Device exists, so that requested video artifacts are never silently skipped. 1. As a Flutter developer, I want recording-enabled Scenarios to fail when multiple recordable Target Devices exist and I did not pass `--device`, so that Flutter Pilot does not choose the wrong device. -1. As a Flutter developer, I want recording-enabled Scenarios to auto-select the Target Device when exactly one supported Flutter Device id is also a Recording Device id, so that single-device setups stay convenient. +1. As a Flutter developer, I want recording-enabled Scenarios to auto-select the Target Device when exactly one supported Flutter Device pairs with a Recording Device, so that single-device setups stay convenient. 1. As a Flutter developer, I want `scenario.recording.enabled: false` to behave like no recording, so that templates can disable recording without triggering device discovery. 1. As a Flutter developer, I want recording-disabled Scenarios to run on macOS or Chrome when Flutter supports them, so that ordinary Scenario execution is not limited by screen recording. 1. As a Flutter developer, I want recording-enabled Scenarios to reject macOS and Chrome Target Devices, so that unsupported recording platforms fail clearly. @@ -106,7 +106,7 @@ launch flow, not a user-provided command option. 1. As a Flutter developer, I want `validate` to remain schema-only, so that I can lint Scenario YAML without devices or Flutter app launch. 1. As a Flutter developer, I want `report` and `diff` to continue working on run directories, so that post-run workflows are preserved. 1. As a CI user, I want `test` to fail non-interactively when Target Device selection is ambiguous, so that pipelines do not hang waiting for input. -1. As a CI user, I want to pass an explicit Target Device id, so that the same device is used for Flutter launch and recording. +1. As a CI user, I want to pass an explicit Target Device id, so that Flutter launch uses that device and recording uses its paired Recording Device. 1. As an AI coding agent, I want `test` to be the deterministic reproduction command, so that I can run one command to launch the app and collect artifacts. 1. As an AI coding agent, I want Target Device resolution errors before app launch, so that I can fix command inputs before Scenario execution begins. 1. As a maintainer, I want the existing ScenarioRunner reused by `test`, so that `test` and prior Scenario execution behavior do not diverge. @@ -116,7 +116,7 @@ launch flow, not a user-provided command option. 1. As a maintainer, I want Target Device to remain separate from Recording Device, so that screen recording backend details do not become the app launch model. 1. As a maintainer, I want the `run` command removed rather than kept as an alias, so that the CLI surface has one Scenario execution path. 1. As a maintainer, I want old `run` invocations to fail as unknown commands, so that the code does not preserve deprecated behavior. -1. As a maintainer, I want physical iOS Recording Device discovery to include devices visible through `xcrun xctrace list devices`, so that Flutter-visible physical iPhones can participate in Target Device matching. +1. As a maintainer, I want physical iOS Recording Device discovery to expose only devices that the AVFoundation helper can record, so that Flutter-visible physical iPhones pair by unique exact name when their Flutter UDID differs from their recording id. 1. As a maintainer, I want Device Video Recording path metadata to be run-directory-relative, so that reports and HTML timeline links remain portable. 1. As a maintainer, I want Project Run discovery and aggregation to stay narrow in the first version, so that it adds batch execution without suite configuration, parallel orchestration, or a second report format. @@ -175,13 +175,14 @@ launch flow, not a user-provided command option. - If the user does not pass `--device` and Scenario Recording is disabled or omitted, `test` does not discover devices and lets Flutter choose its default device. - If the user passes `--device`, `test` discovers Flutter Devices and resolves the selector to exactly one supported Flutter Device. - Unsupported Flutter Devices are excluded from Target Device resolution. -- If Scenario Recording is enabled, Target Device resolution also requires a Recording Device with the same device id. -- Recording Device matching is id-only. Recording Device name is not used as a fallback for Target Device consistency. -- If the user does not pass `--device` and Scenario Recording is enabled, `test` builds the id intersection of supported Flutter Devices and Recording Devices. -- If the recording-required device id intersection has exactly one device, `test` auto-selects that Target Device. -- If the recording-required device id intersection has zero or multiple devices, `test` fails with exit code `64` before launching Flutter. +- If Scenario Recording is enabled, Target Device resolution also requires a Recording Device paired by exact id or by a unique exact name match. +- Recording Device exact id matching takes precedence. Recording Device exact name matching is accepted only when it resolves to one Recording Device. +- If the user does not pass `--device` and Scenario Recording is enabled, `test` builds the set of supported Flutter Devices that have a paired Recording Device. +- If the recording-required paired-device set has exactly one device, `test` auto-selects that Target Device. +- If the recording-required paired-device set has zero or multiple devices, `test` fails with exit code `64` before launching Flutter. - Single-file Target Device resolution failures do not create Scenario Run directories. Project Run Target Device resolution failures create the batch directory and `project_run_report.json` with an environment-level failure. -- Scenario Recording startup failure after a Runtime Target URI is available is a Scenario Run failure and creates a run report. +- Scenario Recording preparation happens after Target Device and Recording Device pairing. Backends that need pre-launch capture setup, such as physical iOS, prepare before Flutter app launch. +- Scenario Recording segment startup failure after a Runtime Target URI is available is a Scenario Run failure and creates a run report. - `test` shows the resolved Target Device in Target App Launch Progress when one is selected. - When no Target Device is resolved because Flutter will choose its default @@ -202,14 +203,15 @@ launch flow, not a user-provided command option. - If launch progress is enabled, startup failure output is rendered by Target App Launch Progress with elapsed time and the buffered stderr tail. - `test` does not expose a launch timeout option and does not impose its own startup timeout. -- If `flutter run` exits before producing a VM service URI, `test` fails without starting recording or creating a run directory. +- If `flutter run` exits before producing a VM service URI, `test` fails without starting a Scenario Recording segment or creating a run directory. Any prepared recording capture is still disposed. - If `flutter run` exits unexpectedly while the Scenario is still running, `test` reports the unexpected process exit and includes the last 40 stderr lines. - Runtime Adapter failures do not automatically print `flutter run` stderr unless the Flutter process exited unexpectedly. -- `test` starts Scenario Recording only after `app.debugPort.wsUri` has been received and before the first Scenario Step executes. -- `test` does not record Flutter build, install, or app cold-start time before the Runtime Target URI is available. -- `test` stops Scenario Recording before stopping the launched Flutter app during normal cleanup and Ctrl-C cleanup. -- `test` stops the launched Flutter app process when the Scenario completes or fails. +- `test` starts the saved Scenario Recording segment only after `app.debugPort.wsUri` has been received and before the first Scenario Step executes. +- `test` does not include Flutter build, install, or app cold-start time in the saved Device Video Recording segment before the Runtime Target URI is available. +- `test` stops the Scenario Recording segment during Scenario shutdown so the final video artifact can be attached to the run report. +- `test` stops the launched Flutter app process when the Scenario completes or fails, then disposes any prepared recording capture. Physical iOS helper disposal must be awaited so native capture indicators do not remain active. - A Project Run launches the Target App Package once and reuses that launched app for all selected Project Scenarios. +- A Project Run reuses one prepared recording capture for the batch and starts one saved segment per recording-enabled Project Scenario. - In a Project Run, the first Scenario runs after launch without an extra hot restart. - In a Project Run, Flutter Pilot performs a hot restart before every later Scenario, including after a previous Scenario failure. - A Scenario execution failure inside a Project Run records that Scenario's artifacts and does not stop later Scenarios. @@ -226,9 +228,9 @@ launch flow, not a user-provided command option. - The existing ScenarioRunner remains responsible for Scenario execution, Step behavior, capture behavior, Scenario Recording lifecycle, and run report generation. - The Runtime Adapter remains responsible only for operations against the Runtime Target. - A Target App Launcher module should encapsulate `flutter run --machine` process management, VM URI extraction, stderr buffering, and cleanup. -- A Target Device module should encapsulate Flutter Device parsing, Target Device modeling, Recording Device id matching, and device resolution errors. +- A Target Device module should encapsulate Flutter Device parsing, Target Device modeling, Recording Device id/name matching, and device resolution errors. - The Target Device module may directly depend on the `screen_recorder` public API, but it outputs Flutter Pilot's Target Device model rather than exposing `screen_recorder` types as the app launch model. -- Physical iOS Recording Device discovery should merge physical iOS devices from `xcrun xctrace list devices` with helper-discovered devices, excluding simulators and offline devices. +- Physical iOS Recording Device discovery should expose helper-discovered AVFoundation devices only. `xctrace` devices that the helper cannot record must not be added as Recording Devices. - The ADR for this CLI decision is maintained in `docs-internal/adr/0003-split-runtime-uri-run-from-target-device-test.md`. ## Testing Decisions @@ -242,7 +244,7 @@ launch flow, not a user-provided command option. - Project Run executor tests should cover launch reuse, hot restart between Scenarios, continuing after Scenario failures, stopping on hot restart failure, project-level report output, and final exit status. - Complex `test` success paths should be covered through injectable modules or command-level tests rather than subprocess tests that would start a real Flutter app. - Existing ScenarioRunner tests should remain the primary coverage for Scenario execution, Step lifecycle, capture, failure handling, `--until`, `--print`, and Scenario Recording lifecycle. -- Screen recorder package tests should cover physical iOS discovery through `xcrun xctrace list devices`. +- Screen recorder package tests should cover physical iOS helper discovery without adding `xctrace`-only devices. - Artifact/report tests should cover Device Video Recording as a run-level artifact with run-directory-relative paths. - Prior art for parser and CLI behavior is the existing Scenario parser tests and CLI subprocess tests. - Prior art for runner behavior is the fake Runtime Adapter-based ScenarioRunner test style. @@ -273,8 +275,8 @@ launch flow, not a user-provided command option. - Custom recording output directories. - Desktop or web screen recording support. - macOS, Windows, Linux, or Chrome Recording Device backends. -- Device id/name cross-table ambiguity handling beyond id-only Recording Device matching. -- Recording Flutter build or app launch before `app.debugPort.wsUri` is available. +- User-facing device id/name cross-table ambiguity handling beyond exact id and unique exact name Recording Device matching. +- Including Flutter build, install, or app cold-start time in the saved Device Video Recording segment. - Saving full `flutter run --machine` logs as run artifacts in the first version. - Printing Flutter or Flutter Pilot version information at command start. - Adding Target Device id or name to run directory names. @@ -287,13 +289,13 @@ launch flow, not a user-provided command option. Scenarios against a running Flutter app instance obtained from `flutter run`. - `test` is intentionally a convenience orchestration layer over the existing ScenarioRunner. It should not fork Scenario execution semantics. -- The Target Device and Recording Device id alignment was calibrated locally - after updating physical iOS discovery: Android, iOS Simulator, and physical - iOS devices all matched by device id in the observed environment. -- Physical iOS discovery through `xcrun xctrace list devices` makes the device - visible for Target Device matching. Actual physical iOS recording still - depends on the `screen_recorder` backend being able to start a Recording - Session for that device; startup failure remains a Scenario Recording failure. +- The Target Device and Recording Device pairing rule was calibrated locally: + Android and iOS Simulator matched by id, while physical iOS required exact + name pairing because Flutter uses the device UDID and AVFoundation exposes a + different recording id. +- Physical iOS Recording Device discovery must come from the AVFoundation + helper. `xctrace` can show devices that Flutter can launch but the helper + cannot record, so `xctrace`-only devices are not valid Recording Devices. - The issue tracker integration and triage label configuration are not present in the local workspace, so this PRD is currently maintained as a local document rather than published with a `ready-for-agent` label. diff --git a/docs/guide/run-scenario.md b/docs/guide/run-scenario.md index 9c026a4..26e80e5 100644 --- a/docs/guide/run-scenario.md +++ b/docs/guide/run-scenario.md @@ -26,6 +26,11 @@ flutter_pilot test scenarios/login.yaml --target lib/main_staging.dart `--device` selects the Target Device. `--target` selects the Flutter app entrypoint file. +When Scenario Recording is enabled, the Target Device must pair with a +Recording Device by exact id or by a unique exact name match. Flutter Pilot uses +the Flutter Device id for app launch and the paired Recording Device id for +device video recording. + ## Stop at a checkpoint Use `--until` with a Step number or Step label: diff --git a/docs/reference/scenario-dsl.md b/docs/reference/scenario-dsl.md index 54aee09..8fa7ad5 100644 --- a/docs/reference/scenario-dsl.md +++ b/docs/reference/scenario-dsl.md @@ -51,7 +51,9 @@ and may contain letters, digits, `_`, or `-`. `scenario.description` is optional and must be a string. Scenario Recording is optional run-level metadata under `scenario.recording`. -It is not a Step action. +It is not a Step action. When enabled, Flutter Pilot may prepare device capture +before app launch, then starts the saved video segment before the first Step and +stores the result as a run-level Device Video Recording artifact. ```yaml scenario scenario: diff --git a/docs/superpowers/specs/2026-07-12-ios-recording-device-matching-design.md b/docs/superpowers/specs/2026-07-12-ios-recording-device-matching-design.md new file mode 100644 index 0000000..1484648 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-ios-recording-device-matching-design.md @@ -0,0 +1,79 @@ +# iOS Recording Device Matching Design + +## Problem + +Flutter Pilot currently assumes that a Flutter Device and its corresponding +Recording Device have the same id. That holds for Android devices and iOS +Simulators, but not for physical iOS devices: Flutter uses the device UDID while +the AVFoundation recording backend exposes a different device id. + +On a physical iPhone, Flutter Pilot can therefore launch the correct Target App +Package but pass an unresolvable Flutter UDID to `screen_recorder`. Recording +then fails with `deviceNotFound`, even though the same iPhone is recordable by +its AVFoundation name or id. + +## Scope + +This change fixes Target Device to Recording Device matching for Scenario +Recording. It does not change Target App launch timeouts, the Scenario YAML +schema, recording lifecycle, or video artifact layout. + +## Design + +Recording Device discovery will retain both the backend-specific device id and +the human-readable device name. Target Device resolution will pair a supported +Flutter Device with a Recording Device using this priority: + +1. exact device id +2. exact device name when no id match exists + +The resolved run context will retain the selected Recording Device selector +separately from the Target Device. Flutter Pilot will continue passing the +Flutter Device id to `flutter run`, while `ScreenRecorderRecordingController` +will receive the paired Recording Device id. + +The pairing is an execution concern rather than Target Device metadata. Run +reports continue to describe the Flutter-selected Target Device and do not +expose backend-specific recording identity. + +## Ambiguity And Failure Handling + +An exact id match always wins over name matching. A name fallback must resolve +to exactly one Recording Device. Multiple Recording Devices with the same name +are ambiguous and cause Target Device resolution to fail before app launch. + +When recording is required, a Flutter Device with neither an id match nor a +unique exact-name match is not recordable. Existing clear pre-launch failure +behavior remains in place. + +Non-recording runs do not discover or pair Recording Devices. Existing Android +and iOS Simulator behavior remains unchanged because their exact id matches +continue to take precedence. + +## Data Flow + +1. Device discovery reads Flutter Devices and Recording Devices. +2. Target Device resolution selects the Flutter Device requested by `--device` + or the unique recordable Flutter Device. +3. Recording Device pairing selects an exact id match or unique exact-name + match. +4. The Test Execution Session launches the app with the Flutter Device id and + retains the paired Recording Device id. +5. The test or Project Run executor creates the recording controller with the + paired Recording Device id. + +## Tests + +Public behavior tests will cover: + +- exact id pairing remains preferred +- different ids with the same exact name pair successfully +- duplicate Recording Device names fail as ambiguous +- unmatched devices remain non-recordable +- the app launcher receives the Flutter Device id +- the recording controller receives the paired Recording Device id +- Project Run uses the same paired selector behavior + +The tests use fake discovery and recording boundaries; the manual physical iOS +probe remains the external evidence that Flutter UDID lookup fails while the +paired AVFoundation device succeeds. diff --git a/docs/superpowers/specs/2026-07-12-physical-ios-recording-discovery-design.md b/docs/superpowers/specs/2026-07-12-physical-ios-recording-discovery-design.md new file mode 100644 index 0000000..276990a --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-physical-ios-recording-discovery-design.md @@ -0,0 +1,49 @@ +# Physical iOS Recording Discovery Design + +## Problem + +The physical iOS recording backend combines two different discovery sources: +AVFoundation devices that the Swift helper can record and `xctrace` devices +that Xcode can run. An `xctrace` device may share the Flutter Device UDID while +remaining absent from AVFoundation. + +Flutter Pilot therefore prefers the exact UDID match, starts the helper with an +id the helper cannot resolve, executes every Scenario Step, and only reports a +missing recording when the run stops. + +## Scope + +This change corrects physical iOS Recording Device discovery and startup +failure timing. It does not change Scenario YAML, Target App launch behavior, +video artifact layout, or Android and iOS Simulator recording. + +## Design + +`IosPhysicalRecordingBackend.listDevices` will return only devices discovered +by the AVFoundation Swift helper. `xctrace` metadata will no longer be added to +the Recording Device list because the `screen_recorder` contract promises that +listed devices are recordable by their returned ids. + +Flutter Pilot's existing id-first, unique-exact-name-second pairing will then +match a physical iOS Flutter Device to the AVFoundation Recording Device by +name and pass the AVFoundation id to the helper. + +Physical recording startup will probe the helper process for an immediate exit, +matching the existing iOS Simulator behavior. An immediate exit becomes a +`startFailed` exception containing the helper exit code, stdout, and stderr. +Flutter Pilot will therefore fail before the first Scenario Step instead of +waiting until recording shutdown. + +## Tests + +Public package tests will verify that: + +- physical discovery excludes devices found only by `xctrace` +- AVFoundation helper devices remain discoverable +- an immediately exiting helper fails `startRecord` with `startFailed` +- immediate-exit diagnostics retain the helper error text +- a long-running helper still starts and produces a finalized MOV on stop + +Flutter Pilot tests will continue verifying that differing Flutter and +AVFoundation ids pair by exact device name and that the recording controller +receives the AVFoundation id. diff --git a/docs/superpowers/specs/2026-07-13-physical-ios-prepared-capture-design.md b/docs/superpowers/specs/2026-07-13-physical-ios-prepared-capture-design.md new file mode 100644 index 0000000..444a175 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-physical-ios-prepared-capture-design.md @@ -0,0 +1,140 @@ +# Physical iOS Prepared Capture Design + +## Problem + +Starting the physical iOS AVFoundation capture session after `flutter run` has +attached to an application can disrupt the device's USB debug transport. The +Flutter tool then loses its debug session and the application exits. Starting +capture before Flutter avoids that failure, but the current one-process, +one-file helper also records the Flutter build, install, and cold-start period. + +An accidentally orphaned helper demonstrated that these lifecycles can be +separated: Flutter can launch while the physical-device capture session is +already active, and the Scenario video can begin later. The supported design +must reproduce that behavior with explicit ownership and cleanup rather than +depending on an orphan process. + +## Scope + +This change adds prepared capture only for physical iOS Recording Devices. +Android recording through ADB and iOS Simulator recording through `simctl` +retain their current direct start and stop behavior. Scenario YAML and the +Device Video Recording artifact name and report shape do not change. + +## Lifecycle Contract + +The recording boundary gains four lifecycle operations: + +1. `prepare` establishes any device-level capture resources required before + the Target App launches. +2. `start` begins the Recording Session whose frames belong to the final Device + Video Recording. +3. `stop` finalizes that Recording Session and returns its saved video path. +4. `dispose` releases prepared resources and waits until their process exits. + +Direct backends implement `prepare` and `dispose` without starting an early +recording. Physical iOS implements `prepare` by starting a long-lived helper and +waiting until AVFoundation delivers a real frame. Its `start` and `stop` +operations create and finalize a movie segment without restarting the capture +session. Its `dispose` stops the capture session and waits for the helper to +exit. + +Flutter Pilot invokes `prepare` after Target Device pairing and before +`flutter run`. It invokes `start` only after the Runtime Adapter is initialized +and before the first Step. It invokes `stop` after Scenario execution and +before Target App shutdown. It invokes `dispose` on every terminal path after a +successful prepare, including Target App launch failure, Runtime Adapter +initialization failure, Step failure, Project Run failure, and interruption. + +## Physical iOS Helper Protocol + +The Swift helper becomes a stateful process controlled through stdin and +acknowledged through stdout. Protocol messages are line-delimited and contain +an operation plus any required output path. Paths must use an encoding that +cannot be confused with protocol delimiters. + +The helper states are: + +- `preparing`: configure and start `AVCaptureSession` +- `ready`: at least one real video sample has arrived; no movie is being written +- `recording`: samples are being appended to one `AVAssetWriter` +- `finalizing`: the current writer is finishing its MOV +- `closed`: the capture session is stopped and the helper exits + +The helper emits `READY` only after its sample-buffer delegate receives a real +frame. `START` is valid only in `ready`; it creates a fresh writer and begins its +timeline at the next accepted sample. `STOP` is valid only in `recording`; it +marks the writer input finished and emits `SAVED` only after +`finishWriting` succeeds. It then returns to `ready` without reconfiguring or +stopping `AVCaptureSession`. `SHUTDOWN` finalizes or cancels active work by the +caller's requested cleanup policy, stops capture, and exits. + +Every movie segment starts its media timeline at its first accepted sample, so +the saved video excludes capture warm-up and Target App launch. Starting or +stopping a segment must not call `AVCaptureSession.startRunning` or +`stopRunning`. + +## Single Scenario And Project Run Ownership + +A single-file Test Run owns one prepared physical iOS helper. A Project Run +also owns one prepared helper for the entire Target App lifetime and creates a +separate segment for each recording-enabled Scenario. Scenarios with recording +disabled do not create a writer, while the prepared capture session may remain +ready for a later enabled Scenario. + +The executor owns preparation and disposal because those operations surround +Target App launch. The Scenario runner continues to own logical Recording +Session start and stop because those operations define the final video's +Scenario boundary. + +## Failure And Cleanup Behavior + +- Failure before `READY` is a recording preparation failure and prevents Target + App launch. +- Target App launch failure after `READY` disposes the helper and creates no + Device Video Recording. +- Segment startup failure prevents Step execution for that Scenario. +- Scenario or Step failure still stops and saves the active segment so the + failure remains visible in the video. +- Segment finalization failure fails that Scenario and preserves diagnostics. +- Interruption first requests orderly segment finalization when a Scenario is + active, then requests helper shutdown, waits with a bounded timeout, and uses + forced termination only as a final fallback. +- Disposal is idempotent. Completion is not reported until the helper process + has exited, preventing a stale capture session and the persistent `9:41` + device status indicator. + +Raw helper stderr and protocol violations remain backend diagnostics and are +normalized into the existing recording failure boundary exposed by Flutter +Pilot. + +## Testing + +Implementation follows red-green-refactor and covers public behavior at each +ownership boundary. + +`screen_recorder` tests verify that physical iOS preparation waits for `READY`, +segment start and stop reuse one helper process, consecutive segments produce +separate MOV paths, stop does not exit the helper, dispose waits for exit, and +protocol errors or timeouts become structured failures. Existing Android and +iOS Simulator tests verify that preparation does not start their recording +commands early. + +Flutter Pilot executor tests verify that prepare precedes Target App launch, +logical start remains after Runtime Adapter initialization, stop precedes app +shutdown, and dispose runs on success, launch failure, Scenario failure, and +interruption. Project Run tests verify one physical iOS preparation with one +segment per enabled Scenario and no segment for disabled Scenarios. + +A physical-device smoke test verifies that Flutter remains connected when a +segment starts, the final MOV begins after Target App launch, the helper exits +at run completion, and the device status indicator returns from `9:41`. + +## Documentation Changes + +The test command PRD must replace the current rule that all Scenario Recording +starts only after the Runtime Target URI. The revised rule distinguishes +physical iOS capture preparation before Target App launch from logical +Recording Session start after Runtime initialization. The Scenario Recording +contract continues to promise a final video covering the Scenario rather than +Flutter build and cold-start time. diff --git a/issues/0.0.8-test-command.md b/issues/0.0.8-test-command.md index 27759e8..b47ed5d 100644 --- a/issues/0.0.8-test-command.md +++ b/issues/0.0.8-test-command.md @@ -5,9 +5,9 @@ Parent: https://github.com/drown0315/flutter_pilot/issues/81 Publishing status: Published to GitHub with the `ready-for-agent` label. Issues 82 through 87 are registered as sub-issues of the parent issue. -Prerequisite: physical iOS Recording Device discovery through -`xcrun xctrace list devices` was fixed before this issue set and should be -committed separately. +Historical note: physical iOS Recording Device discovery now exposes +helper-discovered AVFoundation devices only. `xctrace`-only devices are not +recordable and must not be treated as Recording Devices. ## 1. Store Device Video Recording Under Run Artifacts @@ -47,25 +47,25 @@ load Flutter Devices from `flutter devices --machine`, resolve a user-provided Target Device selector by Flutter Device id, exact name, or unique id/name prefix, and reject unsupported or ambiguous devices. -When Scenario Recording is required, the resolved Target Device must also be -available as a Recording Device with the same device id. If no selector is -provided and recording is required, Flutter Pilot should auto-select only when -exactly one supported Flutter Device id is also present in the Recording Device -list. +When Scenario Recording is required, the resolved Target Device must also pair +with a Recording Device by exact id or by a unique exact name match. If no +selector is provided and recording is required, Flutter Pilot should +auto-select only when exactly one supported Flutter Device has a paired +Recording Device. ## Acceptance criteria - [ ] Target Device resolution returns `null` when no selector is provided and recording is not required. - [ ] A non-empty selector resolves to exactly one supported Flutter Device by id, exact name, or unique id/name prefix. - [ ] Empty, whitespace-only, missing, ambiguous, and unsupported Target Device selectors fail with exit-code-64 command errors. -- [ ] Recording-required resolution matches Recording Devices by resolved Flutter Device id only. -- [ ] Recording-required resolution auto-selects only when exactly one supported Flutter Device id is present in the Recording Device list. +- [ ] Recording-required resolution matches Recording Devices by resolved Flutter Device exact id or by unique exact name. +- [ ] Recording-required resolution auto-selects only when exactly one supported Flutter Device has a paired Recording Device. - [ ] Recording-required resolution fails before app launch when there are zero or multiple recordable Target Devices. - [ ] Error messages use Target Device terminology and list useful candidate devices when selection is ambiguous. ## Blocked by -- Physical iOS Recording Device discovery through `xcrun xctrace list devices` must be committed separately. +- Physical iOS Recording Device discovery must expose only helper-discovered AVFoundation devices. ## 3. Launch Target App Package From Test Command Infrastructure @@ -119,7 +119,7 @@ execution entrypoint. - [ ] `test` requires exactly one Scenario file and does not support directory, glob, or no-argument suite execution. - [ ] Scenario validation failures happen before Flutter app launch. - [ ] `--print` remains valid only with `--until`, and `--json` remains scoped to printed diagnostics. -- [ ] `test` starts Scenario Recording only after the Runtime Target URI is available and before the first Scenario Step executes. +- [ ] `test` prepares Scenario Recording before app launch when the backend requires it, then starts the saved segment only after the Runtime Target URI is available and before the first Scenario Step executes. - [ ] `test` stops Scenario Recording before cleaning up the launched Flutter app. - [ ] `test` cleans up the launched Flutter process when the Scenario succeeds, fails, or is interrupted. - [ ] `validate`, `doctor`, `init`, `report`, and `diff` remain available and do not execute Scenarios. @@ -177,7 +177,7 @@ and product docs with one consistent execution story. - [ ] Documentation describes `flutter_pilot test ` as the Scenario execution command. - [ ] Documentation no longer instructs users to run Scenarios by passing a VM service URI to `run`. - [ ] Documentation explains `--device` / `-d`, `--flavor`, and `--target` / `-t`. -- [ ] Documentation explains that Scenario Recording requires the Target Device id to also be a Recording Device id. +- [ ] Documentation explains that Scenario Recording requires the Target Device to pair with a Recording Device by exact id or unique exact name. - [ ] Documentation explains that `validate` remains schema-only and that `doctor` / `init` remain explicit setup commands. - [ ] Documentation states that full-suite Scenario discovery, arbitrary Flutter argument passthrough, and user-supplied VM service URI execution are out of scope for this slice. - [ ] Documentation uses Scenario, Target App Package, Runtime Target, Target Device, Recording Device, and Scenario Recording terminology consistently. diff --git a/lib/src/execution/project_run_executor.dart b/lib/src/execution/project_run_executor.dart index fe445fa..fd5eeac 100644 --- a/lib/src/execution/project_run_executor.dart +++ b/lib/src/execution/project_run_executor.dart @@ -1,11 +1,9 @@ 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 '../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'; @@ -41,6 +39,7 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { this.outputDirectory, this.clock = DateTime.now, this.launchHeartbeatTicks, + this.recordingControllerFactory = defaultRecordingControllerFactory, }) : _sessionFactory = sessionFactory; /// Discovers Flutter and Recording Devices before app launch when needed. @@ -67,6 +66,9 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { /// Optional heartbeat stream used by launch progress tests. final Stream? launchHeartbeatTicks; + /// Creates the recording controller for the shared execution session. + final RecordingControllerFactory recordingControllerFactory; + @override Future run( ProjectRunOptions options, { @@ -119,11 +121,7 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { targetDevice: session.targetDevice, recordingController: scenarioFile.scenario.recording?.enabled == true - ? ScreenRecorderRecordingController( - recorder: screen_recorder.ScreenRecorder.defaultRecorder(), - deviceSelector: session.targetDevice!.id, - outputDirectory: Directory.current, - ) + ? session.recordingController : null, ); } on RuntimeAdapterSelectionException catch (error) { @@ -170,6 +168,11 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { phase: ProjectRunEnvironmentFailurePhase.launch, message: error.message, ); + } on TestExecutionRecordingException catch (error) { + environmentFailure = ProjectRunEnvironmentFailure( + phase: ProjectRunEnvironmentFailurePhase.launch, + message: error.message, + ); } on TestCommandException catch (error) { if (error.exitCode == 130) { rethrow; @@ -180,7 +183,20 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { ); } finally { stopwatch.stop(); - await session?.close(); + try { + await session?.close(); + } on TestExecutionRecordingException catch (error) { + final bool hasScenarioFailure = scenarioResults.any( + (ProjectScenarioRunReport report) => + report.status == ProjectScenarioRunStatus.failed, + ); + if (environmentFailure == null && !hasScenarioFailure) { + environmentFailure = ProjectRunEnvironmentFailure( + phase: ProjectRunEnvironmentFailurePhase.launch, + message: error.message, + ); + } + } } final bool allPassed = environmentFailure == null && @@ -252,6 +268,7 @@ class DefaultProjectRunExecutor implements ProjectRunExecutor { interruptSignals: interruptSignals, launchHeartbeatTicks: launchHeartbeatTicks, launchClock: clock, + recordingControllerFactory: recordingControllerFactory, ); } } diff --git a/lib/src/execution/test_command_executor.dart b/lib/src/execution/test_command_executor.dart index ce2d657..11723a6 100644 --- a/lib/src/execution/test_command_executor.dart +++ b/lib/src/execution/test_command_executor.dart @@ -1,9 +1,5 @@ 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'; @@ -44,6 +40,7 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { this.interruptSignals, this.launchHeartbeatTicks, this.launchClock = DateTime.now, + this.recordingControllerFactory = defaultRecordingControllerFactory, }) : _sessionFactory = sessionFactory; final TestDeviceDiscovery deviceDiscovery; @@ -53,6 +50,7 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { final Stream? interruptSignals; final Stream? launchHeartbeatTicks; final TargetAppLaunchClock launchClock; + final RecordingControllerFactory recordingControllerFactory; @override Future run( @@ -80,8 +78,11 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { exitCode: 1, alreadyRendered: error.alreadyRendered, ); + } on TestExecutionRecordingException catch (error) { + throw TestCommandException(message: error.message, exitCode: 1); } + bool completedNormally = false; try { final TestScenarioRunner runner; try { @@ -89,11 +90,7 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { runtimeTarget: session.runtimeTarget, targetDevice: session.targetDevice, recordingController: recordingRequired - ? ScreenRecorderRecordingController( - recorder: screen_recorder.ScreenRecorder.defaultRecorder(), - deviceSelector: session.targetDevice!.id, - outputDirectory: Directory.current, - ) + ? session.recordingController : null, ); } on RuntimeAdapterSelectionException catch (error) { @@ -105,11 +102,28 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { printDiagnostics: options.printDiagnostics, onProgress: onProgress, ); - return await session.runWithInterrupt(runFuture); + final ScenarioRunReport report = await session.runWithInterrupt( + runFuture, + ); + completedNormally = report.status == ScenarioRunStatus.passed; + return report; } on RuntimeOperationException catch (error) { throw TestCommandException(message: error.message, exitCode: 1); } finally { + await _closeSession(session, reportFailure: completedNormally); + } + } + + Future _closeSession( + TestExecutionSession session, { + required bool reportFailure, + }) async { + try { await session.close(); + } on TestExecutionRecordingException catch (error) { + if (reportFailure) { + throw TestCommandException(message: error.message, exitCode: 1); + } } } @@ -121,6 +135,7 @@ class DefaultTestCommandExecutor implements TestCommandExecutor { interruptSignals: interruptSignals, launchHeartbeatTicks: launchHeartbeatTicks, launchClock: launchClock, + recordingControllerFactory: recordingControllerFactory, ); } } diff --git a/lib/src/execution/test_execution_session.dart b/lib/src/execution/test_execution_session.dart index 75886f2..17c4642 100644 --- a/lib/src/execution/test_execution_session.dart +++ b/lib/src/execution/test_execution_session.dart @@ -1,6 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'package:screen_recorder/screen_recorder.dart' as screen_recorder; + +import '../recording/recording_contract.dart'; +import '../recording/screen_recorder_recording_controller.dart'; import '../runtime/runtime_contract.dart'; import '../target/test_target_device_selection.dart'; import '../target/target_app_launch_progress_renderer.dart'; @@ -9,14 +13,44 @@ import '../target/target_device.dart'; import 'test_command_models.dart'; import '../target/test_device_discovery.dart'; +/// Builds the Scenario Recording controller for a paired Recording Device. +typedef RecordingControllerFactory = + RecordingController Function({ + required String deviceSelector, + required Directory outputDirectory, + }); + +/// Default recording controller factory used by production executors. +RecordingController defaultRecordingControllerFactory({ + required String deviceSelector, + required Directory outputDirectory, +}) { + return ScreenRecorderRecordingController( + recorder: screen_recorder.ScreenRecorder.defaultRecorder(), + deviceSelector: deviceSelector, + outputDirectory: outputDirectory, + ); +} + +Future _disposeRecordingController( + RecordingController? recordingController, +) async { + try { + await recordingController?.dispose(); + return null; + } on RecordingException catch (error) { + return error; + } +} + /// 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. + /// `recordingRequired` forces Target Device resolution and Recording Device + /// pairing before launch. `onLaunchProgress` receives Target App Launch + /// Progress events when human-readable output is enabled. Future start({ required String? deviceSelector, required String? flavor, @@ -35,6 +69,12 @@ abstract interface class TestExecutionSession { /// Target Device selected before launch, when Flutter Pilot selected one. TargetDevice? get targetDevice; + /// Backend-specific Recording Device id paired with the Target Device. + String? get recordingDeviceSelector; + + /// Prepared Scenario Recording controller owned by this session. + RecordingController? get recordingController; + /// Run `operation` and complete with interruption when Ctrl-C is received. Future runWithInterrupt(Future operation); @@ -77,6 +117,13 @@ final class TestExecutionLaunchException extends TestExecutionSessionException { final bool alreadyRendered; } +/// Failure raised when Scenario Recording cannot be prepared or disposed. +final class TestExecutionRecordingException + extends TestExecutionSessionException { + /// Creates a recording lifecycle failure. + const TestExecutionRecordingException(super.message); +} + /// Default Test Execution Session factory for the `test` command. class DefaultTestExecutionSessionFactory implements TestExecutionSessionFactory { @@ -87,6 +134,7 @@ class DefaultTestExecutionSessionFactory this.interruptSignals, this.launchHeartbeatTicks, this.launchClock = DateTime.now, + this.recordingControllerFactory = defaultRecordingControllerFactory, }); /// Discovers Flutter and Recording Devices before launch when needed. @@ -104,6 +152,9 @@ class DefaultTestExecutionSessionFactory /// Clock used for Target App Launch Progress events. final TargetAppLaunchClock launchClock; + /// Creates the recording controller after Target/Recording Device pairing. + final RecordingControllerFactory recordingControllerFactory; + @override Future start({ required String? deviceSelector, @@ -113,10 +164,26 @@ class DefaultTestExecutionSessionFactory required bool launchHeartbeatEnabled, void Function(TargetAppLaunchProgressEvent event)? onLaunchProgress, }) async { - final TargetDevice? targetDevice = await _resolveTargetDevice( - deviceSelector: deviceSelector, - recordingRequired: recordingRequired, - ); + final ResolvedTargetDevice? resolvedTargetDevice = + await _resolveTargetDevice( + deviceSelector: deviceSelector, + recordingRequired: recordingRequired, + ); + final TargetDevice? targetDevice = resolvedTargetDevice?.targetDevice; + RecordingController? recordingController; + if (recordingRequired) { + final RecordingDeviceIdentity recordingDevice = + resolvedTargetDevice!.recordingDevice!; + recordingController = recordingControllerFactory( + deviceSelector: recordingDevice.id, + outputDirectory: Directory.current, + ); + try { + await recordingController.prepare(); + } on RecordingException catch (error) { + throw TestExecutionRecordingException(error.message); + } + } final DateTime launchStartedAt = launchClock(); final TargetAppLaunchChoices launchChoices = TargetAppLaunchChoices( targetDevice: targetDevice, @@ -163,6 +230,8 @@ class DefaultTestExecutionSessionFactory return _DefaultTestExecutionSession( launch: launch, targetDevice: targetDevice, + recordingDeviceSelector: resolvedTargetDevice?.recordingDevice?.id, + recordingController: recordingController, interruptSignals: interruptSignals, ); } on TargetAppLaunchException catch (error) { @@ -176,6 +245,7 @@ class DefaultTestExecutionSessionFactory ), ); await launchHeartbeat?.stop(); + await _disposeRecordingController(recordingController); final String stderrContext = onLaunchProgress == null && error.stderrLines.isNotEmpty ? '\n${error.stderrLines.join('\n')}' @@ -188,7 +258,7 @@ class DefaultTestExecutionSessionFactory } } - Future _resolveTargetDevice({ + Future _resolveTargetDevice({ required String? deviceSelector, required bool recordingRequired, }) async { @@ -219,6 +289,8 @@ class _DefaultTestExecutionSession implements TestExecutionSession { _DefaultTestExecutionSession({ required TargetAppLaunch launch, required this.targetDevice, + required this.recordingDeviceSelector, + required this.recordingController, required this.interruptSignals, }) : _launch = launch, runtimeTarget = RuntimeTarget( @@ -235,6 +307,12 @@ class _DefaultTestExecutionSession implements TestExecutionSession { @override final TargetDevice? targetDevice; + @override + final String? recordingDeviceSelector; + + @override + final RecordingController? recordingController; + @override Future runWithInterrupt(Future operation) async { StreamSubscription? interruptSub; @@ -276,7 +354,16 @@ class _DefaultTestExecutionSession implements TestExecutionSession { } @override - Future close() { - return _launch.cleanup(); + Future close() async { + try { + await _launch.cleanup(); + } finally { + final RecordingException? error = await _disposeRecordingController( + recordingController, + ); + if (error != null) { + throw TestExecutionRecordingException(error.message); + } + } } } diff --git a/lib/src/recording/recording_contract.dart b/lib/src/recording/recording_contract.dart index f15a96e..c6ac723 100644 --- a/lib/src/recording/recording_contract.dart +++ b/lib/src/recording/recording_contract.dart @@ -6,6 +6,12 @@ import '../scenario/scenario.dart'; /// device display rather than Flutter UI semantics. Implementations should /// normalize platform recording startup and shutdown into this small contract. abstract interface class RecordingController { + /// Prepare the Recording Device before the Target App Package launches. + /// + /// Implementations that do not have a separate prepared mode may treat this + /// as a no-op and keep using direct recording in [start]. + Future prepare(); + /// Start a Recording Session for a Scenario Run. /// /// Args: @@ -25,10 +31,15 @@ abstract interface class RecordingController { /// Throws: /// `RecordingException` when the active session cannot be stopped. Future stop(); + + /// Release any prepared capture state after Target App cleanup. + /// + /// Calling dispose more than once should be harmless. + Future dispose(); } /// Recording operation names used for normalized run-level failures. -enum RecordingOperation { start, stop } +enum RecordingOperation { prepare, start, stop, dispose } /// Final Device Video Recording produced by a Recording Session. /// diff --git a/lib/src/recording/screen_recorder_recording_controller.dart b/lib/src/recording/screen_recorder_recording_controller.dart index 7c9f2b8..d060a40 100644 --- a/lib/src/recording/screen_recorder_recording_controller.dart +++ b/lib/src/recording/screen_recorder_recording_controller.dart @@ -20,17 +20,46 @@ class ScreenRecorderRecordingController implements RecordingController { final screen_recorder.ScreenRecorder _recorder; final String deviceSelector; final Directory outputDirectory; + screen_recorder.PreparedCapture? _capture; screen_recorder.RecordingSession? _session; + bool _disposed = false; @override - Future start(Scenario scenario) async { + Future prepare() async { + if (_capture != null) { + return; + } try { - _session = await _recorder.startRecord( - deviceSelector: deviceSelector, - outputDirectory: outputDirectory.path, - outputName: scenario.name, - overwrite: true, + _capture = await _recorder.prepare(deviceSelector: deviceSelector); + _disposed = false; + } on screen_recorder.ScreenRecorderException catch (error) { + throw RecordingException( + operation: RecordingOperation.prepare, + message: error.message, + cause: error, ); + } + } + + @override + Future start(Scenario scenario) async { + try { + final screen_recorder.PreparedCapture? capture = _capture; + if (capture != null) { + _session = await _recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory.path, + outputName: scenario.name, + overwrite: true, + ); + } else { + _session = await _recorder.startRecord( + deviceSelector: deviceSelector, + outputDirectory: outputDirectory.path, + outputName: scenario.name, + overwrite: true, + ); + } } on screen_recorder.ScreenRecorderException catch (error) { throw RecordingException( operation: RecordingOperation.start, @@ -67,4 +96,27 @@ class ScreenRecorderRecordingController implements RecordingController { ); } } + + @override + Future dispose() async { + if (_disposed) { + return; + } + final screen_recorder.PreparedCapture? capture = _capture; + if (capture == null) { + _disposed = true; + return; + } + try { + await _recorder.dispose(capture); + _capture = null; + _disposed = true; + } on screen_recorder.ScreenRecorderException catch (error) { + throw RecordingException( + operation: RecordingOperation.dispose, + message: error.message, + cause: error, + ); + } + } } diff --git a/lib/src/target/target_device.dart b/lib/src/target/target_device.dart index 6944f07..e9c3977 100644 --- a/lib/src/target/target_device.dart +++ b/lib/src/target/target_device.dart @@ -42,17 +42,20 @@ class FlutterDevice { final String sdk; } -/// Recording Device identity used for id-only Target Device alignment. +/// Recording Device identity used for Target Device alignment. /// /// This keeps Target Device resolution independent from `screen_recorder` -/// backend objects while preserving the only field needed for consistency: -/// the Recording Device id. +/// backend objects while preserving the id and display name needed to align +/// Flutter and recording discovery results. class RecordingDeviceIdentity { /// Creates a Recording Device identity for Target Device matching. - const RecordingDeviceIdentity({required this.id}); + const RecordingDeviceIdentity({required this.id, required this.name}); /// Backend-specific Recording Device id. final String id; + + /// Human-readable Recording Device name. + final String name; } /// Target Device selected for a Scenario test run. @@ -85,6 +88,25 @@ class TargetDevice { final String sdk; } +/// A selected Target Device and its matching Recording Device, when available. +/// +/// Non-recording selection retains the Target Device with a `null` Recording +/// Device identity. Recording-required selection fails instead of returning an +/// unmatched Target Device. +class ResolvedTargetDevice { + /// Creates the result of Target Device selection and recording alignment. + const ResolvedTargetDevice({ + required this.targetDevice, + required this.recordingDevice, + }); + + /// Target Device selected for app launch. + final TargetDevice targetDevice; + + /// Recording Device paired by exact id or unique exact name. + final RecordingDeviceIdentity? recordingDevice; +} + /// Failure raised when Target Device resolution cannot select exactly one device. /// /// CLI code should present `message` to the user and map this exception to a @@ -166,15 +188,15 @@ class TargetDeviceResolver { /// /// Args: /// `selector` is the optional user `--device` value. - /// `recordingRequired` tells whether Scenario Recording requires id alignment + /// `recordingRequired` tells whether Scenario Recording requires alignment /// with a Recording Device. /// `flutterDevices` are devices discovered from Flutter tooling. /// `recordingDevices` are Recording Device identities available for capture. /// /// Returns: - /// A Target Device, or `null` when no selector is provided and recording is - /// not required. - static TargetDevice? resolve({ + /// A resolved Target Device pairing, or `null` when no selector is provided + /// and recording is not required. + static ResolvedTargetDevice? resolve({ required String? selector, required bool recordingRequired, required List flutterDevices, @@ -187,25 +209,34 @@ class TargetDeviceResolver { for (final FlutterDevice device in flutterDevices) if (device.isSupported) device, ]; - final Set recordingDeviceIds = { - for (final RecordingDeviceIdentity device in recordingDevices) device.id, - }; if (selector == null && recordingRequired) { - final List recordableDevices = [ - for (final FlutterDevice device in supportedDevices) - if (recordingDeviceIds.contains(device.id)) device, - ]; + final List recordableDevices = + [ + for (final FlutterDevice device in supportedDevices) + if (_recordingDeviceFor(device, recordingDevices) + case final RecordingDeviceIdentity recordingDevice) + ResolvedTargetDevice( + targetDevice: _targetFromFlutterDevice(device), + recordingDevice: recordingDevice, + ), + ]; if (recordableDevices.length == 1) { - return _targetFromFlutterDevice(recordableDevices.single); + return recordableDevices.single; } + final List recordableFlutterDevices = [ + for (final ResolvedTargetDevice device in recordableDevices) + supportedDevices.singleWhere( + (FlutterDevice candidate) => candidate.id == device.targetDevice.id, + ), + ]; if (recordableDevices.isEmpty) { throw const TargetDeviceResolutionException( - 'No recordable Target Device is available. Pass --device with a Flutter Device id that is also a Recording Device id.', + 'No recordable Target Device is available. Flutter and Recording Devices must share an exact id or a unique exact name.', ); } throw TargetDeviceResolutionException( 'Multiple recordable Target Devices are available. Pass --device with one of: ' - '${_formatCandidates(recordableDevices)}.', + '${_formatCandidates(recordableFlutterDevices)}.', ); } final String? normalizedSelector = selector?.trim(); @@ -219,12 +250,18 @@ class TargetDeviceResolver { supportedDevices, ); if (selectedDevice case _SelectedTargetDeviceMatch(:final device)) { - if (recordingRequired && !recordingDeviceIds.contains(device.id)) { + final RecordingDeviceIdentity? recordingDevice = recordingRequired + ? _recordingDeviceFor(device, recordingDevices) + : null; + if (recordingRequired && recordingDevice == null) { throw TargetDeviceResolutionException( 'Target Device ${device.id} (${device.name}) is not available as a Recording Device.', ); } - return _targetFromFlutterDevice(device); + return ResolvedTargetDevice( + targetDevice: _targetFromFlutterDevice(device), + recordingDevice: recordingDevice, + ); } final bool unsupportedExactMatch = flutterDevices.any( (FlutterDevice device) => @@ -248,6 +285,34 @@ class TargetDeviceResolver { ); } + /// Pair one Flutter Device with a Recording Device. + /// + /// Exact ids take precedence. When ids differ, one exact name match is + /// accepted; multiple exact name matches are rejected as ambiguous. + static RecordingDeviceIdentity? _recordingDeviceFor( + FlutterDevice flutterDevice, + List recordingDevices, + ) { + for (final RecordingDeviceIdentity recordingDevice in recordingDevices) { + if (recordingDevice.id == flutterDevice.id) { + return recordingDevice; + } + } + final List nameMatches = [ + for (final RecordingDeviceIdentity recordingDevice in recordingDevices) + if (recordingDevice.name == flutterDevice.name) recordingDevice, + ]; + if (nameMatches.length == 1) { + return nameMatches.single; + } + if (nameMatches.length > 1) { + throw TargetDeviceResolutionException( + 'ambiguous Recording Device name "${flutterDevice.name}".', + ); + } + return null; + } + /// Resolve a non-empty selector against supported Flutter Devices. /// /// Exact id and exact name matches are preferred before unique id or name diff --git a/lib/src/target/test_device_discovery.dart b/lib/src/target/test_device_discovery.dart index 58785df..fc65d75 100644 --- a/lib/src/target/test_device_discovery.dart +++ b/lib/src/target/test_device_discovery.dart @@ -45,7 +45,7 @@ class DefaultTestDeviceDiscovery implements TestDeviceDiscovery { .listDevices(); return [ for (final screen_recorder.RecordingDevice device in devices) - RecordingDeviceIdentity(id: device.id), + RecordingDeviceIdentity(id: device.id, name: device.name), ]; } } diff --git a/packages/screen_recorder/CHANGELOG.md b/packages/screen_recorder/CHANGELOG.md index 1e892c3..9037469 100644 --- a/packages/screen_recorder/CHANGELOG.md +++ b/packages/screen_recorder/CHANGELOG.md @@ -4,3 +4,5 @@ - Add the programmatic core API with an in-memory fake backend. - Support Recording Device discovery, Recording Session start/stop/discard, output naming rules, result metadata, and stable error codes. +- Add prepared capture support for physical iOS so native capture can be opened + before a saved recording segment starts. diff --git a/packages/screen_recorder/README.md b/packages/screen_recorder/README.md index 036635c..d61c2c4 100644 --- a/packages/screen_recorder/README.md +++ b/packages/screen_recorder/README.md @@ -9,11 +9,14 @@ use Flutter VM service discovery. ## Core API - `listDevices`: return Recording Devices visible to the configured backend. +- `prepare`: prepare a Recording Device for one or more later Recording + Sessions when a backend supports a separate prepared capture lifecycle. - `startRecord`: start a Recording Session for a selected device. - `stopRecord`: stop a Recording Session and return a Recording Result with the saved file path, timestamps, duration, file size, and MIME type. - `discardRecord`: stop a Recording Session and remove backend/local artifacts without returning a saved recording. +- `dispose`: release a prepared capture. ```dart final ScreenRecorder recorder = ScreenRecorder.defaultRecorder(); @@ -28,6 +31,24 @@ final RecordingResult result = await recorder.stopRecord(session); print(result.outputPath); ``` +Prepared capture is optional. Backends without a separate prepared mode keep +their direct recording behavior; physical iOS uses preparation to start native +device capture before the saved movie segment begins. + +```dart +final PreparedCapture capture = await recorder.prepare(deviceSelector: 'iPhone'); + +final RecordingSession session = await recorder.startRecord( + preparedCapture: capture, + outputDirectory: '/tmp', + outputName: 'login_flow', +); + +final RecordingResult result = await recorder.stopRecord(session); +await recorder.dispose(capture); +print(result.outputPath); +``` + Tests and tools can use `ScreenRecorder.fake(...)` to exercise the same API without real devices. @@ -121,8 +142,10 @@ Physical iOS: device The physical iOS backend builds and runs the in-package Swift helper at -`tool/ios_physical/ios_physical_capture.swift`. It does not depend on any local -prototype directory. +`tool/ios_physical/ios_physical_capture.swift`. Its prepared mode starts a +stateful helper, waits until native capture has produced a real frame, writes a +saved `.mov` segment only between `startRecord` and `stopRecord`, and shuts the +helper down on `dispose`. It does not depend on any local prototype directory. ## Manual Smoke Checklist diff --git a/packages/screen_recorder/lib/src/backend/composite_recording_backend.dart b/packages/screen_recorder/lib/src/backend/composite_recording_backend.dart index 50500c8..5758c08 100644 --- a/packages/screen_recorder/lib/src/backend/composite_recording_backend.dart +++ b/packages/screen_recorder/lib/src/backend/composite_recording_backend.dart @@ -1,16 +1,20 @@ import '../common/screen_recorder_exception.dart'; import '../model/recording_device.dart'; +import '../model/prepared_capture.dart'; import '../model/recording_session.dart'; import 'recording_backend.dart'; /// Recording backend that resolves devices across backend priority order. -class CompositeRecordingBackend implements RecordingBackend { +class CompositeRecordingBackend + implements RecordingBackend, PreparedCaptureBackend { CompositeRecordingBackend(this._backends); final List _backends; final Map _sessionBackends = {}; + final Map _captureBackends = + {}; @override RecordingDevicePlatform get platform => RecordingDevicePlatform.android; @@ -64,7 +68,12 @@ class CompositeRecordingBackend implements RecordingBackend { continue; } _sessionBackends[session.id] = backend; - await backend.start(session, overwrite: overwrite); + try { + await backend.start(session, overwrite: overwrite); + } on Object { + _sessionBackends.remove(session.id); + rethrow; + } return; } throw ScreenRecorderException( @@ -76,7 +85,7 @@ class CompositeRecordingBackend implements RecordingBackend { @override Future stop(RecordingSession session) async { - final RecordingBackend? backend = _sessionBackends.remove(session.id); + final RecordingBackend? backend = _sessionBackends[session.id]; if (backend == null) { throw ScreenRecorderException( code: ScreenRecorderErrorCode.stopFailed, @@ -85,11 +94,12 @@ class CompositeRecordingBackend implements RecordingBackend { ); } await backend.stop(session); + _sessionBackends.remove(session.id); } @override Future discard(RecordingSession session) async { - final RecordingBackend? backend = _sessionBackends.remove(session.id); + final RecordingBackend? backend = _sessionBackends[session.id]; if (backend == null) { throw ScreenRecorderException( code: ScreenRecorderErrorCode.discardFailed, @@ -98,5 +108,97 @@ class CompositeRecordingBackend implements RecordingBackend { ); } await backend.discard(session); + _sessionBackends.remove(session.id); + } + + @override + Future prepare(PreparedCapture capture) async { + final RecordingBackend backend = _backendForDevice(capture.device); + _captureBackends[capture.id] = backend; + if (backend is PreparedCaptureBackend) { + return backend.prepare(capture); + } + return false; + } + + @override + Future startRecord( + PreparedCapture capture, + RecordingSession session, { + required bool overwrite, + }) async { + final RecordingBackend backend = _backendForCapture(capture); + _sessionBackends[session.id] = backend; + if (backend is PreparedCaptureBackend) { + try { + await backend.startRecord(capture, session, overwrite: overwrite); + } on Object { + _sessionBackends.remove(session.id); + rethrow; + } + return; + } + try { + await backend.start(session, overwrite: overwrite); + } on Object { + _sessionBackends.remove(session.id); + rethrow; + } + } + + @override + Future stopRecord( + PreparedCapture capture, + RecordingSession session, + ) async { + final RecordingBackend backend = _backendForCapture(capture); + if (backend is PreparedCaptureBackend) { + await backend.stopRecord(capture, session); + _sessionBackends.remove(session.id); + return; + } + await backend.stop(session); + _sessionBackends.remove(session.id); + } + + @override + Future discardRecord( + PreparedCapture capture, + RecordingSession session, + ) async { + final RecordingBackend backend = _backendForCapture(capture); + if (backend is PreparedCaptureBackend) { + await backend.discardRecord(capture, session); + _sessionBackends.remove(session.id); + return; + } + await backend.discard(session); + _sessionBackends.remove(session.id); + } + + @override + Future dispose(PreparedCapture capture) async { + final RecordingBackend? backend = _captureBackends[capture.id]; + if (backend is PreparedCaptureBackend) { + await backend.dispose(capture); + } + _captureBackends.remove(capture.id); + } + + RecordingBackend _backendForDevice(RecordingDevice device) { + for (final RecordingBackend backend in _backends) { + if (backend.platform == device.platform) { + return backend; + } + } + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.startFailed, + message: 'No backend was found for ${device.name}.', + deviceSelector: device.id, + ); + } + + RecordingBackend _backendForCapture(PreparedCapture capture) { + return _captureBackends[capture.id] ?? _backendForDevice(capture.device); } } diff --git a/packages/screen_recorder/lib/src/backend/ios_physical_recording_backend.dart b/packages/screen_recorder/lib/src/backend/ios_physical_recording_backend.dart index e8681a5..a099a7a 100644 --- a/packages/screen_recorder/lib/src/backend/ios_physical_recording_backend.dart +++ b/packages/screen_recorder/lib/src/backend/ios_physical_recording_backend.dart @@ -1,23 +1,32 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import '../common/screen_recorder_exception.dart'; import '../model/recording_device.dart'; +import '../model/prepared_capture.dart'; import '../model/recording_session.dart'; import '../process/command_runner.dart'; import 'recording_backend.dart'; /// Physical iOS recording backend implemented through the in-package Swift helper. -class IosPhysicalRecordingBackend implements RecordingBackend { +class IosPhysicalRecordingBackend + implements RecordingBackend, PreparedCaptureBackend { IosPhysicalRecordingBackend(this._commandRunner); static const String _backendKind = 'iosPhysical'; + static const Duration _startProbeTimeout = Duration(seconds: 1); + static const Duration _readyTimeout = Duration(seconds: 15); + static const Duration _protocolTimeout = Duration(seconds: 10); static const Duration _stopTimeout = Duration(seconds: 10); final ScreenRecorderCommandRunner _commandRunner; - final Map _recordings = - {}; + final Map _standaloneCaptures = + {}; + final Map _preparedCaptures = + {}; String? _helperPath; @override @@ -31,19 +40,7 @@ class IosPhysicalRecordingBackend implements RecordingBackend { ['list'], ScreenRecorderErrorCode.permissionDenied, ); - final List devices = _parseDevices(result.stdout); - try { - final ScreenRecorderCommandResult xctraceResult = await _commandRunner - .run('xcrun', ['xctrace', 'list', 'devices']); - if (xctraceResult.exitCode == 0) { - _addMissingDevices(devices, _parseXctraceDevices(xctraceResult.stdout)); - } - } on Object { - // The helper remains the source of recordable AVFoundation devices. - // xctrace discovery is best-effort metadata for physical devices that - // Xcode can run but AVFoundation does not expose as capture sources. - } - return devices; + return _parseDevices(result.stdout); } @override @@ -72,73 +69,165 @@ class IosPhysicalRecordingBackend implements RecordingBackend { RecordingSession session, { required bool overwrite, }) async { + final PreparedCapture capture = PreparedCapture( + id: 'standalone-${session.id}', + device: session.device, + ); + await prepare(capture); + _standaloneCaptures[session.id] = capture; + try { + await startRecord(capture, session, overwrite: overwrite); + } on Object { + _standaloneCaptures.remove(session.id); + await _cleanupFailedPrepare(capture, _preparedCaptures[capture.id]); + rethrow; + } + } + + Future _probeImmediateExit(ScreenRecorderProcess process) async { + final Completer exitCode = Completer(); + final Timer timer = Timer(_startProbeTimeout, () { + if (!exitCode.isCompleted) { + exitCode.complete(null); + } + }); + process.exitCode.then((int value) { + if (!exitCode.isCompleted) { + exitCode.complete(value); + } + }); + final int? result = await exitCode.future; + timer.cancel(); + return result; + } + + @override + Future prepare(PreparedCapture capture) async { + if (_preparedCaptures.containsKey(capture.id)) { + return true; + } final String helperPath = await _ensureHelperBuilt(); + _IosPreparedCaptureState? state; try { final ScreenRecorderProcess process = await _commandRunner.start(helperPath, [ - 'record', + 'serve', '--device-id', - session.device.id, - '--output', - session.expectedOutputPath, + capture.device.id, ]); - _recordings[session.id] = process; + state = _IosPreparedCaptureState( + capture: capture, + process: process, + events: StreamIterator(process.stdoutLines), + ); + _preparedCaptures[capture.id] = state; + await _waitForEvent( + state, + 'ready', + timeout: _readyTimeout, + failureCode: ScreenRecorderErrorCode.startFailed, + ); + return true; + } on ScreenRecorderException { + await _cleanupFailedPrepare(capture, state); + rethrow; } on Object catch (error) { + await _cleanupFailedPrepare(capture, state); throw ScreenRecorderException( code: ScreenRecorderErrorCode.startFailed, - message: 'Failed to start physical iOS helper recording.', + message: 'Failed to prepare physical iOS helper capture.', backendKind: _backendKind, - deviceSelector: session.device.id, + deviceSelector: capture.device.id, cause: error, ); } } @override - Future stop(RecordingSession session) async { - final ScreenRecorderProcess? process = _recordings.remove(session.id); - if (process == null) { + Future startRecord( + PreparedCapture capture, + RecordingSession session, { + required bool overwrite, + }) async { + final _IosPreparedCaptureState state = _requirePrepared(capture); + if (state.activeSessionId != null) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.alreadyRecording, + message: 'Physical iOS prepared capture already has an active segment.', + backendKind: _backendKind, + deviceSelector: capture.device.id, + ); + } + state.activeSessionId = session.id; + state.process.writeLine( + jsonEncode({ + 'operation': 'start', + 'outputPath': session.expectedOutputPath, + }), + ); + try { + await _waitForEvent( + state, + 'started', + timeout: _protocolTimeout, + failureCode: ScreenRecorderErrorCode.startFailed, + ); + } on Object { + state.activeSessionId = null; + rethrow; + } + } + + @override + Future stopRecord( + PreparedCapture capture, + RecordingSession session, + ) async { + final _IosPreparedCaptureState state = _requirePrepared(capture); + if (state.activeSessionId != session.id) { throw ScreenRecorderException( code: ScreenRecorderErrorCode.stopFailed, - message: - 'Physical iOS recording state was not found for ${session.id}.', + message: 'Physical iOS prepared capture did not own ${session.id}.', backendKind: _backendKind, deviceSelector: session.device.id, ); } - process.kill(); - final int exitCode = await process.exitCode.timeout( - _stopTimeout + Duration(seconds: 5), - onTimeout: () { - process.kill(ProcessSignal.sigkill); - return -1; - }, + state.process.writeLine(jsonEncode({'operation': 'stop'})); + await _waitForEvent( + state, + 'saved', + timeout: _stopTimeout, + failureCode: ScreenRecorderErrorCode.stopFailed, ); - final String stdout = await process.stdout; - final String stderr = await process.stderr; - final File outputFile = File(session.expectedOutputPath); - if (!outputFile.existsSync() || outputFile.lengthSync() == 0) { + state.activeSessionId = null; + _verifyOutputFile(session); + } + + static String _joinDiagnostics(List lines) { + return lines.where((String line) => line.isNotEmpty).join('\n'); + } + + @override + Future stop(RecordingSession session) async { + final PreparedCapture? capture = _standaloneCaptures[session.id]; + if (capture == null) { throw ScreenRecorderException( code: ScreenRecorderErrorCode.stopFailed, - message: 'Physical iOS recording output was not created.', + message: + 'Physical iOS recording state was not found for ${session.id}.', backendKind: _backendKind, deviceSelector: session.device.id, - rawOutput: [ - 'helper exitCode: $exitCode', - 'output exists: ${outputFile.existsSync()}', - if (outputFile.existsSync()) - 'output size: ${outputFile.lengthSync()}', - stdout, - stderr, - ].where((String s) => s.isNotEmpty).join('\n'), ); } + await stopRecord(capture, session); + await dispose(capture); + _standaloneCaptures.remove(session.id); } @override Future discard(RecordingSession session) async { - final ScreenRecorderProcess? process = _recordings.remove(session.id); - if (process == null) { + final PreparedCapture? capture = _standaloneCaptures[session.id]; + if (capture == null) { throw ScreenRecorderException( code: ScreenRecorderErrorCode.discardFailed, message: @@ -147,17 +236,198 @@ class IosPhysicalRecordingBackend implements RecordingBackend { deviceSelector: session.device.id, ); } - process.kill(); - await process.exitCode.timeout( + await discardRecord(capture, session); + await dispose(capture); + _standaloneCaptures.remove(session.id); + } + + @override + Future discardRecord( + PreparedCapture capture, + RecordingSession session, + ) async { + final _IosPreparedCaptureState state = _requirePrepared(capture); + if (state.activeSessionId == session.id) { + state.process.writeLine( + jsonEncode({'operation': 'stop'}), + ); + await _waitForEvent( + state, + 'saved', + timeout: _stopTimeout, + failureCode: ScreenRecorderErrorCode.discardFailed, + ); + state.activeSessionId = null; + } + final File outputFile = File(session.expectedOutputPath); + if (outputFile.existsSync()) { + outputFile.deleteSync(); + } + } + + @override + Future dispose(PreparedCapture capture) async { + final _IosPreparedCaptureState? state = _preparedCaptures[capture.id]; + if (state == null) { + return; + } + if (state.disposed) { + _preparedCaptures.remove(capture.id); + return; + } + await _shutdownPreparedState(capture, state, throwOnFailure: true); + _preparedCaptures.remove(capture.id); + } + + Future _cleanupFailedPrepare( + PreparedCapture capture, + _IosPreparedCaptureState? state, + ) async { + if (state == null) { + return; + } + _preparedCaptures.remove(capture.id); + try { + await _shutdownPreparedState(capture, state, throwOnFailure: false); + } on Object { + state.process.kill(ProcessSignal.sigkill); + } + } + + Future _shutdownPreparedState( + PreparedCapture capture, + _IosPreparedCaptureState state, { + required bool throwOnFailure, + }) async { + if (state.disposed) { + return; + } + try { + state.process.writeLine( + jsonEncode({'operation': 'shutdown'}), + ); + } on Object catch (error) { + if (!throwOnFailure) { + state.process.kill(ProcessSignal.sigkill); + state.disposed = true; + return; + } + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.stopFailed, + message: 'Physical iOS helper shutdown failed.', + backendKind: _backendKind, + deviceSelector: capture.device.id, + cause: error, + ); + } + final int exitCode = await state.process.exitCode.timeout( _stopTimeout + Duration(seconds: 5), onTimeout: () { - process.kill(ProcessSignal.sigkill); + state.process.kill(ProcessSignal.sigkill); return -1; }, ); + state.disposed = true; + if (exitCode != 0 && throwOnFailure) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.stopFailed, + message: 'Physical iOS helper shutdown failed.', + backendKind: _backendKind, + deviceSelector: capture.device.id, + rawOutput: _joinDiagnostics([ + 'helper exitCode: $exitCode', + await state.process.stdout, + await state.process.stderr, + ]), + ); + } + } + + _IosPreparedCaptureState _requirePrepared(PreparedCapture capture) { + final _IosPreparedCaptureState? state = _preparedCaptures[capture.id]; + if (state == null || state.disposed) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.sessionNotFound, + message: 'Physical iOS prepared capture is not active: ${capture.id}', + backendKind: _backendKind, + deviceSelector: capture.device.id, + ); + } + return state; + } + + Future _waitForEvent( + _IosPreparedCaptureState state, + String expectedEvent, { + required Duration timeout, + required ScreenRecorderErrorCode failureCode, + }) async { + final bool received = await state.events.moveNext().timeout( + timeout, + onTimeout: () => false, + ); + if (!received) { + final int? immediateExitCode = await _probeImmediateExit(state.process); + throw ScreenRecorderException( + code: failureCode, + message: 'Timed out waiting for physical iOS helper $expectedEvent.', + backendKind: _backendKind, + deviceSelector: state.capture.device.id, + rawOutput: _joinDiagnostics([ + if (immediateExitCode != null) 'helper exitCode: $immediateExitCode', + await state.process.stdout.timeout( + Duration(milliseconds: 100), + onTimeout: () => '', + ), + await state.process.stderr.timeout( + Duration(milliseconds: 100), + onTimeout: () => '', + ), + ]), + ); + } + final String line = state.events.current; + final Object? decoded = jsonDecode(line); + if (decoded is Map && decoded['event'] == expectedEvent) { + return; + } + if (decoded + case { + 'event': 'error', + 'message': final Object? message, + }) { + throw ScreenRecorderException( + code: failureCode, + message: 'Physical iOS helper reported an error.', + backendKind: _backendKind, + deviceSelector: state.capture.device.id, + rawOutput: message?.toString(), + ); + } + throw ScreenRecorderException( + code: failureCode, + message: + 'Physical iOS helper emitted unexpected event while waiting for $expectedEvent.', + backendKind: _backendKind, + deviceSelector: state.capture.device.id, + rawOutput: line, + ); + } + + void _verifyOutputFile(RecordingSession session) { final File outputFile = File(session.expectedOutputPath); - if (outputFile.existsSync()) { - outputFile.deleteSync(); + if (!outputFile.existsSync() || outputFile.lengthSync() == 0) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.stopFailed, + message: 'Physical iOS recording output was not created.', + backendKind: _backendKind, + deviceSelector: session.device.id, + rawOutput: [ + 'output exists: ${outputFile.existsSync()}', + if (outputFile.existsSync()) + 'output size: ${outputFile.lengthSync()}', + ].join('\n'), + ); } } @@ -259,50 +529,18 @@ class IosPhysicalRecordingBackend implements RecordingBackend { } return devices; } +} - static List _parseXctraceDevices(String output) { - final List devices = []; - var inDevicesSection = false; - for (final String line in output.split('\n')) { - final String trimmedLine = line.trim(); - if (trimmedLine == '== Devices ==') { - inDevicesSection = true; - continue; - } - if (trimmedLine.startsWith('== ') && trimmedLine.endsWith(' ==')) { - inDevicesSection = false; - continue; - } - if (!inDevicesSection || trimmedLine.isEmpty) { - continue; - } - final RegExpMatch? match = RegExp( - r'^(.*?)(?: \([^)]+\))? \(([0-9a-fA-F]{40})\)$', - ).firstMatch(trimmedLine); - if (match == null) { - continue; - } - devices.add( - RecordingDevice( - id: match.group(2)!, - name: match.group(1)!.trim(), - platform: RecordingDevicePlatform.iosPhysical, - ), - ); - } - return devices; - } +class _IosPreparedCaptureState { + _IosPreparedCaptureState({ + required this.capture, + required this.process, + required this.events, + }); - static void _addMissingDevices( - List target, - List additions, - ) { - final Set existingIds = - target.map((RecordingDevice device) => device.id).toSet(); - for (final RecordingDevice device in additions) { - if (existingIds.add(device.id)) { - target.add(device); - } - } - } + final PreparedCapture capture; + final ScreenRecorderProcess process; + final StreamIterator events; + String? activeSessionId; + bool disposed = false; } diff --git a/packages/screen_recorder/lib/src/backend/recording_backend.dart b/packages/screen_recorder/lib/src/backend/recording_backend.dart index e68a520..2239b26 100644 --- a/packages/screen_recorder/lib/src/backend/recording_backend.dart +++ b/packages/screen_recorder/lib/src/backend/recording_backend.dart @@ -1,4 +1,5 @@ import '../model/recording_device.dart'; +import '../model/prepared_capture.dart'; import '../model/recording_session.dart'; /// Backend contract used by the recorder service. @@ -22,3 +23,35 @@ abstract interface class RecordingBackend { /// Stops the backend-owned recording process and removes saved artifacts. Future discard(RecordingSession session); } + +/// Optional backend capability for preparing capture before segment recording. +/// +/// Backends return `true` from [prepare] when they created backend-owned state +/// for the capture. Returning `false` lets the recorder service fall back to the +/// existing direct `start` / `stop` lifecycle for that platform. +abstract interface class PreparedCaptureBackend implements RecordingBackend { + /// Prepares backend capture for `capture.device`. + Future prepare(PreparedCapture capture); + + /// Starts one saved video segment on a prepared capture. + Future startRecord( + PreparedCapture capture, + RecordingSession session, { + required bool overwrite, + }); + + /// Stops the active segment and keeps the prepared capture alive. + Future stopRecord( + PreparedCapture capture, + RecordingSession session, + ); + + /// Stops the active segment and removes its output file. + Future discardRecord( + PreparedCapture capture, + RecordingSession session, + ); + + /// Releases backend state associated with `capture`. + Future dispose(PreparedCapture capture); +} diff --git a/packages/screen_recorder/lib/src/model/prepared_capture.dart b/packages/screen_recorder/lib/src/model/prepared_capture.dart new file mode 100644 index 0000000..3ded2f5 --- /dev/null +++ b/packages/screen_recorder/lib/src/model/prepared_capture.dart @@ -0,0 +1,21 @@ +import 'recording_device.dart'; + +/// Prepared device capture handle used to start one or more Recording Sessions. +/// +/// A prepared capture reserves backend state for a Recording Device before a +/// specific output file is selected. Backends that support preparation can keep +/// device capture warm and create a video segment only when +/// `startRecord` is called with this capture. +class PreparedCapture { + /// Creates a handle owned by one `ScreenRecorder` instance. + const PreparedCapture({ + required this.id, + required this.device, + }); + + /// Unique capture identity within one recorder instance. + final String id; + + /// Recording Device whose screen is prepared for segment recording. + final RecordingDevice device; +} diff --git a/packages/screen_recorder/lib/src/process/command_runner.dart b/packages/screen_recorder/lib/src/process/command_runner.dart index 6436f19..1ceb53c 100644 --- a/packages/screen_recorder/lib/src/process/command_runner.dart +++ b/packages/screen_recorder/lib/src/process/command_runner.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -58,6 +59,19 @@ abstract interface class ScreenRecorderProcess { /// Captured stderr emitted before the process exits. Future get stderr; + + /// Text stdout emitted by the process as individual lines. + /// + /// Backends use this stream for long-running helper protocols that signal + /// readiness before the process exits. The complete stdout remains available + /// through [stdout] for diagnostics. + Stream get stdoutLines; + + /// Writes one protocol line to the process stdin. + void writeLine(String line); + + /// Closes stdin after all protocol commands have been written. + Future closeStdin(); } /// Boundary for running host commands and starting long-running processes. @@ -125,24 +139,79 @@ class ProcessCommandRunner implements ScreenRecorderCommandRunner { class _DartIoScreenRecorderProcess implements ScreenRecorderProcess { _DartIoScreenRecorderProcess(this._process) - : _stdout = utf8.decodeStream(_process.stdout), - _stderr = utf8.decodeStream(_process.stderr); + : _stderr = utf8.decodeStream(_process.stderr) { + _process.stdout.transform(utf8.decoder).listen( + _handleStdoutChunk, + onError: _handleStdoutError, + onDone: _handleStdoutDone, + ); + } final Process _process; - final Future _stdout; + final Completer _stdout = Completer(); final Future _stderr; + final StreamController _stdoutLines = StreamController(); + final StringBuffer _stdoutBuffer = StringBuffer(); + String _pendingStdoutLine = ''; @override Future get exitCode => _process.exitCode; @override - Future get stdout => _stdout; + Future get stdout => _stdout.future; @override Future get stderr => _stderr; + @override + Stream get stdoutLines => _stdoutLines.stream; + @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { return _process.kill(signal); } + + @override + void writeLine(String line) { + _process.stdin.writeln(line); + } + + @override + Future closeStdin() async { + await _process.stdin.close(); + } + + void _handleStdoutChunk(String chunk) { + _stdoutBuffer.write(chunk); + _pendingStdoutLine += chunk; + int newlineIndex = _pendingStdoutLine.indexOf('\n'); + while (newlineIndex != -1) { + String line = _pendingStdoutLine.substring(0, newlineIndex); + if (line.endsWith('\r')) { + line = line.substring(0, line.length - 1); + } + _stdoutLines.add(line); + _pendingStdoutLine = _pendingStdoutLine.substring(newlineIndex + 1); + newlineIndex = _pendingStdoutLine.indexOf('\n'); + } + } + + void _handleStdoutError(Object error, StackTrace stackTrace) { + if (!_stdout.isCompleted) { + _stdout.completeError(error, stackTrace); + } + _stdoutLines.addError(error, stackTrace); + unawaited(_stdoutLines.close()); + } + + void _handleStdoutDone() { + if (_pendingStdoutLine.isNotEmpty) { + _stdoutLines.add(_pendingStdoutLine); + _pendingStdoutLine = ''; + } + if (!_stdout.isCompleted) { + _stdout.complete(_stdoutBuffer.toString()); + } + unawaited(_stdoutLines.close()); + } } diff --git a/packages/screen_recorder/lib/src/screen_recorder_base.dart b/packages/screen_recorder/lib/src/screen_recorder_base.dart index 181657d..9e7bb37 100644 --- a/packages/screen_recorder/lib/src/screen_recorder_base.dart +++ b/packages/screen_recorder/lib/src/screen_recorder_base.dart @@ -11,12 +11,14 @@ import 'backend/ios_physical_recording_backend.dart'; import 'backend/ios_simulator_recording_backend.dart'; import 'backend/recording_backend.dart'; import 'model/recording_device.dart'; +import 'model/prepared_capture.dart'; import 'model/recording_result.dart'; import 'model/recording_session.dart'; import 'process/command_runner.dart'; import 'service/screen_recorder_service.dart'; export 'common/screen_recorder_exception.dart'; +export 'model/prepared_capture.dart'; export 'model/recording_device.dart'; export 'model/recording_result.dart'; export 'model/recording_session.dart'; @@ -110,20 +112,31 @@ class ScreenRecorder { } /// Starts recording the selected Recording Device. + /// + /// Provide exactly one of `deviceSelector` or `preparedCapture`. Future startRecord({ - required String deviceSelector, + String? deviceSelector, + PreparedCapture? preparedCapture, required String outputDirectory, String? outputName, bool overwrite = false, }) { return _service.startRecord( deviceSelector: deviceSelector, + preparedCapture: preparedCapture, outputDirectory: outputDirectory, outputName: outputName, overwrite: overwrite, ); } + /// Prepares the selected Recording Device for one or more later sessions. + Future prepare({ + required String deviceSelector, + }) { + return _service.prepare(deviceSelector: deviceSelector); + } + /// Stops an active Recording Session and returns saved video metadata. Future stopRecord(RecordingSession session) { return _service.stopRecord(session); @@ -133,4 +146,9 @@ class ScreenRecorder { Future discardRecord(RecordingSession session) { return _service.discardRecord(session); } + + /// Releases a prepared capture. Calling this more than once is harmless. + Future dispose(PreparedCapture capture) { + return _service.dispose(capture); + } } diff --git a/packages/screen_recorder/lib/src/service/screen_recorder_service.dart b/packages/screen_recorder/lib/src/service/screen_recorder_service.dart index f6cb4f8..f6af54d 100644 --- a/packages/screen_recorder/lib/src/service/screen_recorder_service.dart +++ b/packages/screen_recorder/lib/src/service/screen_recorder_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import '../backend/recording_backend.dart'; import '../common/screen_recorder_exception.dart'; import '../model/recording_device.dart'; +import '../model/prepared_capture.dart'; import '../model/recording_result.dart'; import '../model/recording_session.dart'; @@ -14,8 +15,11 @@ class ScreenRecorderService { final RecordingBackend _backend; final Map _activeSessions = {}; + final Map _preparedCaptures = + {}; final Set _activeDeviceIds = {}; int _nextSessionNumber = 1; + int _nextCaptureNumber = 1; /// Lists the Recording Devices currently available to this recorder. Future> listDevices() { @@ -24,17 +28,50 @@ class ScreenRecorderService { /// Starts recording the selected Recording Device. /// + /// Provide exactly one of `deviceSelector` or `preparedCapture`. /// `deviceSelector` matches the backend-specific device id, the exact device - /// name, or a case-insensitive device-name prefix. `outputDirectory` is the + /// name, or a case-insensitive device-name prefix. `preparedCapture` starts + /// a segment from a capture returned by [prepare]. `outputDirectory` is the /// local directory where the final video will be saved. `outputName` omits /// any extension because the backend chooses the native format. Future startRecord({ - required String deviceSelector, + String? deviceSelector, + PreparedCapture? preparedCapture, required String outputDirectory, String? outputName, bool overwrite = false, }) async { validateOutputName(outputName); + if ((deviceSelector == null) == (preparedCapture == null)) { + throw const ScreenRecorderException( + code: ScreenRecorderErrorCode.startFailed, + message: + 'Provide exactly one of deviceSelector or preparedCapture to startRecord.', + ); + } + final PreparedCapture? capture = preparedCapture; + if (capture != null) { + return _startRecordFromPreparedCapture( + capture, + outputDirectory: outputDirectory, + outputName: outputName, + overwrite: overwrite, + ); + } + return _startDirectRecord( + deviceSelector: deviceSelector!, + outputDirectory: outputDirectory, + outputName: outputName, + overwrite: overwrite, + ); + } + + Future _startDirectRecord({ + required String deviceSelector, + required String outputDirectory, + required String? outputName, + required bool overwrite, + }) async { final RecordingDevice device = await _backend.resolveDevice(deviceSelector); if (_activeDeviceIds.contains(device.id)) { throw ScreenRecorderException( @@ -63,14 +100,118 @@ class ScreenRecorderService { ); _activeSessions[session.id] = session; _activeDeviceIds.add(device.id); - await _backend.start(session, overwrite: overwrite); + try { + await _backend.start(session, overwrite: overwrite); + } on Object { + _releaseActiveSession(session); + rethrow; + } + return session; + } + + /// Prepares the selected Recording Device for future Recording Sessions. + /// + /// Physical iOS uses this to start native capture before the Target App + /// launches. Backends without a prepared mode return a direct capture handle; + /// their actual recording process still starts in [startRecord]. + Future prepare({ + required String deviceSelector, + }) async { + final RecordingDevice device = await _backend.resolveDevice(deviceSelector); + final PreparedCapture capture = PreparedCapture( + id: 'capture-${_nextCaptureNumber++}', + device: device, + ); + bool backendPrepared = false; + final RecordingBackend backend = _backend; + if (backend is PreparedCaptureBackend) { + backendPrepared = await backend.prepare(capture); + } + _preparedCaptures[capture.id] = _PreparedCaptureState( + capture: capture, + backendPrepared: backendPrepared, + ); + return capture; + } + + /// Starts one Recording Session from a prepared capture. + Future _startRecordFromPreparedCapture( + PreparedCapture capture, { + required String outputDirectory, + String? outputName, + bool overwrite = false, + }) async { + final _PreparedCaptureState state = _requirePreparedCapture(capture); + if (state.activeSessionId != null) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.alreadyRecording, + message: 'Prepared capture already has an active Recording Session.', + deviceSelector: capture.device.id, + ); + } + if (_activeDeviceIds.contains(capture.device.id)) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.alreadyRecording, + message: 'Device is already being recorded: ${capture.device.name}', + deviceSelector: capture.device.id, + ); + } + final String outputPath = buildOutputPath( + outputDirectory: outputDirectory, + outputName: outputName, + device: capture.device, + ); + if (!overwrite && File(outputPath).existsSync()) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.outputAlreadyExists, + message: 'Output file already exists: $outputPath', + deviceSelector: capture.device.id, + ); + } + final RecordingSession session = RecordingSession( + id: 'recording-${_nextSessionNumber++}', + device: capture.device, + startTime: DateTime.now().toUtc(), + expectedOutputPath: outputPath, + ); + _activeSessions[session.id] = session; + _activeDeviceIds.add(capture.device.id); + state.activeSessionId = session.id; + try { + if (state.backendPrepared) { + await (_backend as PreparedCaptureBackend).startRecord( + capture, + session, + overwrite: overwrite, + ); + } else { + await _backend.start(session, overwrite: overwrite); + } + } on Object { + state.activeSessionId = null; + _activeSessions.remove(session.id); + _activeDeviceIds.remove(capture.device.id); + rethrow; + } return session; } /// Stops an active Recording Session and returns saved video metadata. Future stopRecord(RecordingSession session) async { - final RecordingSession sessionToStop = _takeActiveSession(session); - await _backend.stop(sessionToStop); + final RecordingSession sessionToStop = _requireActiveSession(session); + final _PreparedCaptureState? preparedState = + _preparedStateForSession(sessionToStop); + if (preparedState != null && preparedState.backendPrepared) { + await (_backend as PreparedCaptureBackend).stopRecord( + preparedState.capture, + sessionToStop, + ); + preparedState.activeSessionId = null; + } else { + await _backend.stop(sessionToStop); + preparedState?.activeSessionId = null; + } + _releaseActiveSession(sessionToStop); final File outputFile = File(sessionToStop.expectedOutputPath); final DateTime stopTime = DateTime.now().toUtc(); final int fileSizeBytes = @@ -91,8 +232,42 @@ class ScreenRecorderService { /// Discard is used for canceled recordings. It cleans up backend artifacts and /// does not return a Recording Result because no saved video should remain. Future discardRecord(RecordingSession session) async { - final RecordingSession sessionToDiscard = _takeActiveSession(session); - await _backend.discard(sessionToDiscard); + final RecordingSession sessionToDiscard = _requireActiveSession(session); + final _PreparedCaptureState? preparedState = + _preparedStateForSession(sessionToDiscard); + if (preparedState != null && preparedState.backendPrepared) { + await (_backend as PreparedCaptureBackend).discardRecord( + preparedState.capture, + sessionToDiscard, + ); + preparedState.activeSessionId = null; + } else { + await _backend.discard(sessionToDiscard); + preparedState?.activeSessionId = null; + } + _releaseActiveSession(sessionToDiscard); + } + + /// Releases a prepared capture. Repeated disposal is harmless. + Future dispose(PreparedCapture capture) async { + final _PreparedCaptureState? state = _preparedCaptures[capture.id]; + if (state == null || state.disposed) { + return; + } + final String? activeSessionId = state.activeSessionId; + if (activeSessionId != null) { + final RecordingSession? activeSession = _activeSessions[activeSessionId]; + if (activeSession != null) { + await discardRecord(activeSession); + } else { + state.activeSessionId = null; + } + } + if (state.backendPrepared) { + await (_backend as PreparedCaptureBackend).dispose(capture); + } + state.disposed = true; + _preparedCaptures.remove(capture.id); } /// Removes an active Recording Session from this recorder instance. @@ -100,18 +275,45 @@ class ScreenRecorderService { /// A session must belong to this recorder and still be active. Sessions from /// another recorder, already stopped sessions, and already discarded sessions /// fail with `sessionNotFound`. - RecordingSession _takeActiveSession(RecordingSession session) { - final RecordingSession? activeSession = _activeSessions.remove(session.id); + RecordingSession _requireActiveSession(RecordingSession session) { + final RecordingSession? activeSession = _activeSessions[session.id]; if (activeSession == null) { throw ScreenRecorderException( code: ScreenRecorderErrorCode.sessionNotFound, message: 'Recording Session is not active: ${session.id}', ); } - _activeDeviceIds.remove(activeSession.device.id); return activeSession; } + void _releaseActiveSession(RecordingSession session) { + _activeSessions.remove(session.id); + _activeDeviceIds.remove(session.device.id); + } + + _PreparedCaptureState _requirePreparedCapture( + PreparedCapture capture, + ) { + final _PreparedCaptureState? state = _preparedCaptures[capture.id]; + if (state == null || state.disposed) { + throw ScreenRecorderException( + code: ScreenRecorderErrorCode.sessionNotFound, + message: 'Prepared recording capture is not active: ${capture.id}', + deviceSelector: capture.device.id, + ); + } + return state; + } + + _PreparedCaptureState? _preparedStateForSession(RecordingSession session) { + for (final _PreparedCaptureState state in _preparedCaptures.values) { + if (state.activeSessionId == session.id) { + return state; + } + } + return null; + } + /// Builds the final local output path using the backend-native extension. /// /// `outputName` is either caller-provided or generated without an extension. @@ -182,3 +384,15 @@ class ScreenRecorderService { }; } } + +class _PreparedCaptureState { + _PreparedCaptureState({ + required this.capture, + required this.backendPrepared, + }); + + final PreparedCapture capture; + final bool backendPrepared; + String? activeSessionId; + bool disposed = false; +} diff --git a/packages/screen_recorder/test/android_recorder_test.dart b/packages/screen_recorder/test/android_recorder_test.dart index ff9aed3..3ae59dd 100644 --- a/packages/screen_recorder/test/android_recorder_test.dart +++ b/packages/screen_recorder/test/android_recorder_test.dart @@ -154,6 +154,75 @@ offline-1\toffline ); }); + test( + 'prepared capture does not start Android recording until segment start', + () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addAndroidDeviceList({'PHK110': 'OnePlus 13'}); + final ScreenRecorder recorder = ScreenRecorder.android( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_android_test_') + .path; + + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'PHK110', + ); + + expect(commandRunner.startedCommands, isEmpty); + + final RecordingSession session = await recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'prepared_android', + overwrite: true, + ); + final RecordingResult result = await recorder.stopRecord(session); + + expect(result.outputPath, endsWith('prepared_android.mp4')); + expect(commandRunner.startedCommands, contains(contains('scrcpy'))); + }); + + test('prepared capture disposal discards an active Android session', + () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addAndroidDeviceList({'PHK110': 'OnePlus 13'}); + final ScreenRecorder recorder = ScreenRecorder.android( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_android_test_') + .path; + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'PHK110', + ); + final RecordingSession session = await recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'disposed_prepared_android', + overwrite: true, + ); + + await recorder.dispose(capture); + + expect( + commandRunner.lastProcess?.killedWithSignal, + ProcessSignal.sigterm, + ); + expect(File(session.expectedOutputPath).existsSync(), isFalse); + await expectLater( + recorder.discardRecord(session), + throwsA( + isA().having( + (ScreenRecorderException exception) => exception.code, + 'code', + ScreenRecorderErrorCode.sessionNotFound, + ), + ), + ); + }); + test( 'falls back to native screenrecord when scrcpy exits immediately', () async { @@ -623,6 +692,15 @@ class _FakeScreenRecorderProcess implements ScreenRecorderProcess { @override Future get stderr async => _stderr; + @override + Stream get stdoutLines => const Stream.empty(); + + @override + void writeLine(String line) {} + + @override + Future closeStdin() async {} + @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { killedWithSignal = signal; diff --git a/packages/screen_recorder/test/default_recorder_test.dart b/packages/screen_recorder/test/default_recorder_test.dart index e3e8ce8..2128a44 100644 --- a/packages/screen_recorder/test/default_recorder_test.dart +++ b/packages/screen_recorder/test/default_recorder_test.dart @@ -355,6 +355,15 @@ class _FakeScreenRecorderProcess implements ScreenRecorderProcess { @override Future get stderr async => ''; + @override + Stream get stdoutLines => const Stream.empty(); + + @override + void writeLine(String line) {} + + @override + Future closeStdin() async {} + @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { final String? outputPath = outputPathOnKill; diff --git a/packages/screen_recorder/test/ios_physical_recorder_test.dart b/packages/screen_recorder/test/ios_physical_recorder_test.dart index 2b5d400..0b8eecf 100644 --- a/packages/screen_recorder/test/ios_physical_recorder_test.dart +++ b/packages/screen_recorder/test/ios_physical_recorder_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:screen_recorder/screen_recorder.dart'; @@ -41,13 +42,16 @@ ios-device-2\tOffice iPhone\tiOS Device\tApple Inc. ]); }); - test('lists physical iOS devices from xctrace output', () async { + test('excludes xctrace-only devices from Recording Devices', () async { final _FakeCommandRunner commandRunner = _FakeCommandRunner() ..addSwiftBuild() ..addHelperList( const ScreenRecorderCommandResult( exitCode: 0, - stdout: 'id\tname\tmodel\tmanufacturer\n', + stdout: ''' +id\tname\tmodel\tmanufacturer +avfoundation-id\t钟惠彬的 iPhone\tiOS Device\tApple Inc. +''', stderr: '', ), ) @@ -74,28 +78,13 @@ iPhone 17 Simulator (26.4) (58CC29EF-4758-4E4E-A79A-398E4A26C91F) final List devices = await recorder.listDevices(); - expect( - devices, - contains( - const RecordingDevice( - id: '269bfd1ccaa634d5f2250efe6a22016b18fd16da', - name: '钟惠彬的 iPhone', - platform: RecordingDevicePlatform.iosPhysical, - ), - ), - ); - expect( - devices, - isNot( - contains( - const RecordingDevice( - id: '58CC29EF-4758-4E4E-A79A-398E4A26C91F', - name: 'iPhone 17 Simulator', - platform: RecordingDevicePlatform.iosPhysical, - ), - ), + expect(devices, const [ + RecordingDevice( + id: 'avfoundation-id', + name: '钟惠彬的 iPhone', + platform: RecordingDevicePlatform.iosPhysical, ), - ); + ]); }); test( @@ -142,11 +131,9 @@ iPhone 17 Simulator (26.4) (58CC29EF-4758-4E4E-A79A-398E4A26C91F) contains( equals([ commandRunner.helperPath, - 'record', + 'serve', '--device-id', 'ios-device-1', - '--output', - byId.expectedOutputPath, ]), ), ); @@ -258,6 +245,361 @@ iPhone 17 Simulator (26.4) (58CC29EF-4758-4E4E-A79A-398E4A26C91F) }, ); + test('reports helper immediate exit as a start failure', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addPhysicalDeviceList({ + 'avfoundation-id': 'Drown iPhone', + }) + ..completeNextProcessImmediately( + exitCode: 4, + stderr: 'No physical iOS capture device matched id avfoundation-id.', + ); + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_physical_ios_test_') + .path; + + await expectLater( + recorder.startRecord( + deviceSelector: 'Drown iPhone', + outputDirectory: outputDirectory, + outputName: 'immediate_exit', + ), + throwsA( + isA() + .having( + (ScreenRecorderException exception) => exception.code, + 'code', + ScreenRecorderErrorCode.startFailed, + ) + .having( + (ScreenRecorderException exception) => exception.rawOutput, + 'rawOutput', + allOf( + contains('helper exitCode: 4'), + contains('No physical iOS capture device matched'), + ), + ), + ), + ); + }); + + test('cleans up standalone helper when segment start fails', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addHelperList( + const ScreenRecorderCommandResult( + exitCode: 0, + stdout: ''' +id\tname\tmodel\tmanufacturer +ios-device-1\tDrown iPhone\tiOS Device\tApple Inc. +''', + stderr: '', + ), + ); + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_physical_ios_test_') + .path; + + final Future start = recorder.startRecord( + deviceSelector: 'ios-device-1', + outputDirectory: outputDirectory, + outputName: 'start_failure', + ); + await Future.delayed(Duration.zero); + commandRunner.emitStdoutLine('{"event":"ready"}'); + await Future.delayed(Duration.zero); + commandRunner.emitStdoutLine( + '{"event":"error","message":"start failed"}', + ); + + await expectLater(start, throwsA(isA())); + expect(commandRunner.writtenOperations, ['start', 'shutdown']); + expect(commandRunner.lastProcess?.hasExited, isTrue); + }); + + test('cleans up helper when prepared capture readiness fails', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addHelperList( + const ScreenRecorderCommandResult( + exitCode: 0, + stdout: ''' +id\tname\tmodel\tmanufacturer +ios-device-1\tDrown iPhone\tiOS Device\tApple Inc. +''', + stderr: '', + ), + ); + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + + final Future prepare = recorder.prepare( + deviceSelector: 'ios-device-1', + ); + await Future.delayed(Duration.zero); + commandRunner.emitStdoutLine('{"event":"notReady"}'); + + await expectLater( + prepare, + throwsA( + isA().having( + (ScreenRecorderException exception) => exception.code, + 'code', + ScreenRecorderErrorCode.startFailed, + ), + ), + ); + expect(commandRunner.writtenOperations, ['shutdown']); + expect(commandRunner.lastProcess?.hasExited, isTrue); + }); + + test('keeps standalone helper owned when stop fails', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addPhysicalDeviceList({ + 'ios-device-1': 'Drown iPhone', + }); + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_physical_ios_test_') + .path; + final RecordingSession session = await recorder.startRecord( + deviceSelector: 'ios-device-1', + outputDirectory: outputDirectory, + outputName: 'missing_output', + ); + + await expectLater( + recorder.stopRecord(session), + throwsA(isA()), + ); + await recorder.discardRecord(session); + + expect(commandRunner.writtenOperations, [ + 'start', + 'stop', + 'shutdown', + ]); + expect(commandRunner.lastProcess?.hasExited, isTrue); + }); + + test('keeps standalone helper owned when discard fails', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addPhysicalDeviceList({ + 'ios-device-1': 'Drown iPhone', + }) + ..defaultServeOutputBytes = [5, 4, 3, 2]; + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_physical_ios_test_') + .path; + final RecordingSession session = await recorder.startRecord( + deviceSelector: 'ios-device-1', + outputDirectory: outputDirectory, + outputName: 'discard_failure', + ); + + commandRunner.failNextStop = true; + await expectLater( + recorder.discardRecord(session), + throwsA(isA()), + ); + await recorder.discardRecord(session); + + expect(commandRunner.writtenOperations, [ + 'start', + 'stop', + 'stop', + 'shutdown', + ]); + expect(commandRunner.lastProcess?.hasExited, isTrue); + }); + + test('process boundary exposes streamed stdout and stdin lines', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner(); + final ScreenRecorderProcess process = await commandRunner.start( + commandRunner.helperPath, + ['serve', '--device-id', 'ios-device-1'], + ); + final List stdoutLines = []; + final StreamSubscription subscription = + process.stdoutLines.listen(stdoutLines.add); + + commandRunner.emitStdoutLine('READY'); + process.writeLine('START /tmp/ios-recording.mov'); + await process.closeStdin(); + + expect(commandRunner.writtenLines, [ + 'START /tmp/ios-recording.mov', + ]); + expect(stdoutLines, ['READY']); + + await subscription.cancel(); + }); + + test('prepared capture reuses one serve helper across segments', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addPhysicalDeviceList({ + 'ios-device-1': 'Drown iPhone', + }) + ..defaultServeOutputBytes = [5, 4, 3, 2]; + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_physical_ios_test_') + .path; + + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'Drown iPhone', + ); + final RecordingSession firstSession = await recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'first_segment', + overwrite: true, + ); + final RecordingResult firstResult = await recorder.stopRecord( + firstSession, + ); + final RecordingSession secondSession = await recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'second_segment', + overwrite: true, + ); + final RecordingResult secondResult = await recorder.stopRecord( + secondSession, + ); + await recorder.dispose(capture); + + expect(firstResult.outputPath, endsWith('first_segment.mov')); + expect(secondResult.outputPath, endsWith('second_segment.mov')); + expect(commandRunner.startedCommands, >[ + [ + commandRunner.helperPath, + 'serve', + '--device-id', + 'ios-device-1', + ], + ]); + expect(commandRunner.writtenOperations, [ + 'start', + 'stop', + 'start', + 'stop', + 'shutdown', + ]); + expect(commandRunner.lastProcess?.hasExited, isTrue); + }); + + test('prepared capture rejects segment start after disposal', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addPhysicalDeviceList({ + 'ios-device-1': 'Drown iPhone', + }) + ..defaultServeOutputBytes = [5, 4, 3, 2]; + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_physical_ios_test_') + .path; + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'ios-device-1', + ); + + await recorder.dispose(capture); + + await expectLater( + recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'after_dispose', + overwrite: true, + ), + throwsA(isA()), + ); + }); + + test('prepared capture rejects concurrent segments', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addPhysicalDeviceList({ + 'ios-device-1': 'Drown iPhone', + }) + ..defaultServeOutputBytes = [5, 4, 3, 2]; + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_physical_ios_test_') + .path; + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'ios-device-1', + ); + + final RecordingSession activeSession = await recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'active', + overwrite: true, + ); + + await expectLater( + recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'concurrent', + overwrite: true, + ), + throwsA( + isA().having( + (ScreenRecorderException exception) => exception.code, + 'code', + ScreenRecorderErrorCode.alreadyRecording, + ), + ), + ); + await recorder.stopRecord(activeSession); + await recorder.dispose(capture); + }); + + test('prepared capture disposal is idempotent', () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSwiftBuild() + ..addPhysicalDeviceList({ + 'ios-device-1': 'Drown iPhone', + }) + ..autoServeProtocol = true; + final ScreenRecorder recorder = ScreenRecorder.iosPhysical( + commandRunner: commandRunner, + ); + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'ios-device-1', + ); + + await recorder.dispose(capture); + await recorder.dispose(capture); + + expect(commandRunner.writtenOperations, ['shutdown']); + }); + test('reports stop failure when physical iOS output is missing', () async { final _FakeCommandRunner commandRunner = _FakeCommandRunner() ..addSwiftBuild() @@ -299,6 +641,26 @@ class _FakeCommandRunner implements ScreenRecorderCommandRunner { final String helperPath = '${Directory.systemTemp.path}${Platform.pathSeparator}screen_recorder_ios_physical_capture'; _FakeScreenRecorderProcess? _lastProcess; + bool autoServeProtocol = false; + List? defaultServeOutputBytes; + bool failNextStop = false; + int? _nextProcessExitCode; + String _nextProcessStdout = ''; + String _nextProcessStderr = ''; + final List writtenLines = []; + + _FakeScreenRecorderProcess? get lastProcess => _lastProcess; + + List get writtenOperations { + return writtenLines.map((String line) { + final Object? decoded = jsonDecode(line); + if (decoded + case {'operation': final Object? operation}) { + return operation.toString(); + } + return line; + }).toList(); + } void addSwiftBuild() { addRun([ @@ -323,6 +685,7 @@ class _FakeCommandRunner implements ScreenRecorderCommandRunner { } void addPhysicalDeviceList(Map devicesById) { + autoServeProtocol = true; final StringBuffer buffer = StringBuffer('id\tname\tmodel\tmanufacturer\n'); for (final MapEntry entry in devicesById.entries) { buffer.writeln('${entry.key}\t${entry.value}\tiOS Device\tApple Inc.'); @@ -336,6 +699,16 @@ class _FakeCommandRunner implements ScreenRecorderCommandRunner { ); } + void completeNextProcessImmediately({ + required int exitCode, + String stdout = '', + String stderr = '', + }) { + _nextProcessExitCode = exitCode; + _nextProcessStdout = stdout; + _nextProcessStderr = stderr; + } + @override Future run( String executable, @@ -379,8 +752,35 @@ class _FakeCommandRunner implements ScreenRecorderCommandRunner { List arguments, ) async { startedCommands.add([executable, ...arguments]); - final _FakeScreenRecorderProcess process = _FakeScreenRecorderProcess(); + final _FakeScreenRecorderProcess process = _FakeScreenRecorderProcess( + stdoutValue: _nextProcessStdout, + stderrValue: _nextProcessStderr, + writtenLines: writtenLines, + autoServeProtocol: autoServeProtocol, + serveOutputBytes: defaultServeOutputBytes, + shouldFailStop: () { + final bool result = failNextStop; + failNextStop = false; + return result; + }, + ); _lastProcess = process; + final int? immediateExitCode = _nextProcessExitCode; + if (autoServeProtocol && + immediateExitCode == null && + arguments.length == 3 && + arguments[0] == 'serve' && + arguments[1] == '--device-id') { + scheduleMicrotask(() { + process.emitStdoutLine('{"event":"ready"}'); + }); + } + if (immediateExitCode != null) { + process.complete(immediateExitCode); + } + _nextProcessExitCode = null; + _nextProcessStdout = ''; + _nextProcessStderr = ''; return process; } @@ -388,6 +788,7 @@ class _FakeCommandRunner implements ScreenRecorderCommandRunner { required String outputPath, required List bytes, }) { + _lastProcess?.serveOutputBytes = bytes; _lastProcess?.onKill = () { File(outputPath) ..createSync(recursive: true) @@ -395,21 +796,117 @@ class _FakeCommandRunner implements ScreenRecorderCommandRunner { }; } + void emitStdoutLine(String line) { + _lastProcess?.emitStdoutLine(line); + } + static const String _buildKey = 'swiftc'; } class _FakeScreenRecorderProcess implements ScreenRecorderProcess { + _FakeScreenRecorderProcess({ + this.stdoutValue = '', + this.stderrValue = '', + required this.writtenLines, + required this.autoServeProtocol, + required this.shouldFailStop, + this.serveOutputBytes, + }); + final Completer _exitCode = Completer(); + final StreamController _stdoutLines = StreamController(); + final String stdoutValue; + final String stderrValue; + final List writtenLines; + final bool autoServeProtocol; + final bool Function() shouldFailStop; + List? serveOutputBytes; void Function()? onKill; @override Future get exitCode => _exitCode.future; @override - Future get stdout async => ''; + Future get stdout async => stdoutValue; + + @override + Future get stderr async => stderrValue; + + @override + Stream get stdoutLines => _stdoutLines.stream; + + bool get hasExited => _exitCode.isCompleted; + + @override + void writeLine(String line) { + writtenLines.add(line); + final Object? decoded; + try { + decoded = jsonDecode(line); + } on FormatException { + return; + } + if (decoded is! Map) { + return; + } + if (decoded['operation'] == 'shutdown') { + complete(0); + return; + } + if (!autoServeProtocol) { + return; + } + switch (decoded['operation']) { + case 'start': + emitStdoutLine('{"event":"started"}'); + case 'stop': + if (shouldFailStop()) { + emitStdoutLine('{"event":"error","message":"stop failed"}'); + return; + } + final String? outputPath = _lastStartOutputPath(); + final List? bytes = serveOutputBytes; + if (outputPath != null && bytes != null) { + File(outputPath) + ..createSync(recursive: true) + ..writeAsBytesSync(bytes); + } + emitStdoutLine( + jsonEncode({ + 'event': 'saved', + if (outputPath != null) 'outputPath': outputPath, + }), + ); + } + } @override - Future get stderr async => ''; + Future closeStdin() async {} + + void emitStdoutLine(String line) { + _stdoutLines.add(line); + } + + String? _lastStartOutputPath() { + for (final String line in writtenLines.reversed) { + final Object? decoded = jsonDecode(line); + if (decoded + case { + 'operation': 'start', + 'outputPath': final Object? outputPath, + }) { + return outputPath?.toString(); + } + } + return null; + } + + void complete(int exitCode) { + if (!_exitCode.isCompleted) { + _exitCode.complete(exitCode); + } + unawaited(_stdoutLines.close()); + } @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { @@ -417,6 +914,7 @@ class _FakeScreenRecorderProcess implements ScreenRecorderProcess { if (!_exitCode.isCompleted) { _exitCode.complete(0); } + unawaited(_stdoutLines.close()); return true; } } diff --git a/packages/screen_recorder/test/ios_simulator_recorder_test.dart b/packages/screen_recorder/test/ios_simulator_recorder_test.dart index 93e9a34..f597d5f 100644 --- a/packages/screen_recorder/test/ios_simulator_recorder_test.dart +++ b/packages/screen_recorder/test/ios_simulator_recorder_test.dart @@ -155,6 +155,53 @@ void main() { }, ); + test( + 'prepared capture does not start simulator recording until segment start', + () async { + final _FakeCommandRunner commandRunner = _FakeCommandRunner() + ..addSimulatorDeviceList({ + '11111111-1111-1111-1111-111111111111': 'iPhone 16 Pro', + }); + final ScreenRecorder recorder = ScreenRecorder.iosSimulator( + commandRunner: commandRunner, + ); + final String outputDirectory = Directory.systemTemp + .createTempSync('screen_recorder_simulator_test_') + .path; + + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'iPhone 16 Pro', + ); + + expect(commandRunner.startedCommands, isEmpty); + + final RecordingSession session = await recorder.startRecord( + preparedCapture: capture, + outputDirectory: outputDirectory, + outputName: 'prepared_sim', + overwrite: true, + ); + commandRunner.completeProcessWithFile( + outputPath: session.expectedOutputPath, + bytes: [9, 8, 7, 6], + ); + final RecordingResult result = await recorder.stopRecord(session); + + expect(result.outputPath, endsWith('prepared_sim.mov')); + expect( + commandRunner.startedCommands, + contains( + equals([ + commandRunner.simctlPath, + 'io', + '11111111-1111-1111-1111-111111111111', + 'recordVideo', + session.expectedOutputPath, + ]), + ), + ); + }); + test('discards simulator recording and removes local output', () async { final _FakeCommandRunner commandRunner = _FakeCommandRunner() ..addSimulatorDeviceList({ @@ -352,6 +399,15 @@ class _FakeScreenRecorderProcess implements ScreenRecorderProcess { @override Future get stderr async => ''; + @override + Stream get stdoutLines => const Stream.empty(); + + @override + void writeLine(String line) {} + + @override + Future closeStdin() async {} + @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { killedWithSignal = signal; diff --git a/packages/screen_recorder/test/screen_recorder_test.dart b/packages/screen_recorder/test/screen_recorder_test.dart index b33a3df..a562b66 100644 --- a/packages/screen_recorder/test/screen_recorder_test.dart +++ b/packages/screen_recorder/test/screen_recorder_test.dart @@ -1,6 +1,9 @@ import 'dart:io'; import 'package:screen_recorder/screen_recorder.dart'; +import 'package:screen_recorder/src/backend/composite_recording_backend.dart'; +import 'package:screen_recorder/src/backend/recording_backend.dart'; +import 'package:screen_recorder/src/service/screen_recorder_service.dart'; import 'package:test/test.dart'; /// Verifies the public screen_recorder API using an in-memory recording backend. @@ -264,6 +267,28 @@ void main() { await owner.discardRecord(ownerSession); }, ); + + test('keeps prepared capture retryable when backend dispose fails', + () async { + final _FailingDisposePreparedBackend backend = + _FailingDisposePreparedBackend(); + final ScreenRecorderService recorder = ScreenRecorderService( + CompositeRecordingBackend([backend]), + ); + final PreparedCapture capture = await recorder.prepare( + deviceSelector: 'ios-device-1', + ); + + await expectLater( + recorder.dispose(capture), + throwsA(_hasCode(ScreenRecorderErrorCode.discardFailed)), + ); + + backend.disposeShouldFail = false; + await recorder.dispose(capture); + + expect(backend.disposeCount, 2); + }); }); } @@ -284,3 +309,75 @@ Matcher _hasCode(ScreenRecorderErrorCode code) { code, ); } + +class _FailingDisposePreparedBackend + implements RecordingBackend, PreparedCaptureBackend { + final RecordingDevice _device = const RecordingDevice( + id: 'ios-device-1', + name: 'Drown iPhone', + platform: RecordingDevicePlatform.iosPhysical, + ); + + bool disposeShouldFail = true; + int disposeCount = 0; + + @override + RecordingDevicePlatform get platform => RecordingDevicePlatform.iosPhysical; + + @override + Future> listDevices() async { + return [_device]; + } + + @override + Future resolveDevice(String selector) async { + return _device; + } + + @override + Future prepare(PreparedCapture capture) async { + return true; + } + + @override + Future start( + RecordingSession session, { + required bool overwrite, + }) async {} + + @override + Future startRecord( + PreparedCapture capture, + RecordingSession session, { + required bool overwrite, + }) async {} + + @override + Future stop(RecordingSession session) async {} + + @override + Future stopRecord( + PreparedCapture capture, + RecordingSession session, + ) async {} + + @override + Future discard(RecordingSession session) async {} + + @override + Future discardRecord( + PreparedCapture capture, + RecordingSession session, + ) async {} + + @override + Future dispose(PreparedCapture capture) async { + disposeCount++; + if (disposeShouldFail) { + throw const ScreenRecorderException( + code: ScreenRecorderErrorCode.discardFailed, + message: 'Dispose failed.', + ); + } + } +} diff --git a/packages/screen_recorder/tool/ios_physical/ios_physical_capture.swift b/packages/screen_recorder/tool/ios_physical/ios_physical_capture.swift index a8a8027..c25e69f 100644 --- a/packages/screen_recorder/tool/ios_physical/ios_physical_capture.swift +++ b/packages/screen_recorder/tool/ios_physical/ios_physical_capture.swift @@ -58,17 +58,37 @@ func printDeviceList() { } final class MovieRecorder: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + enum Mode { + case record + case serve + } + + enum State { + case preparing + case ready + case recording + case finalizing + case closed + } + private let session = AVCaptureSession() private let videoOutput = AVCaptureVideoDataOutput() private let queue = DispatchQueue(label: "screen-recorder.ios-physical.video") + private let mode: Mode private var writer: AVAssetWriter? private var writerInput: AVAssetWriterInput? private var outputURL: URL? - private var stopping = false + private var state: State = .preparing + private var shutdownRequested = false private var finished = false private var wroteFrames = false + private var startedEmitted = false - func start(device: AVCaptureDevice, outputURL: URL) throws { + init(mode: Mode) { + self.mode = mode + } + + func start(device: AVCaptureDevice, outputURL: URL?) throws { self.outputURL = outputURL session.beginConfiguration() @@ -88,54 +108,167 @@ final class MovieRecorder: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat session.addOutput(videoOutput) session.commitConfiguration() - if FileManager.default.fileExists(atPath: outputURL.path) { + if let outputURL, FileManager.default.fileExists(atPath: outputURL.path) { try FileManager.default.removeItem(at: outputURL) } signal(SIGTERM) { _ in - RecorderSignalBridge.shared.stop() + RecorderSignalBridge.shared.shutdown() } signal(SIGINT) { _ in - RecorderSignalBridge.shared.stop() + RecorderSignalBridge.shared.shutdown() } RecorderSignalBridge.shared.recorder = self session.startRunning() + if mode == .serve { + readCommands() + } RunLoop.main.run() } - func stop() { + func shutdown() { queue.async { [weak self] in guard let self else { return } - if self.stopping { return } - self.stopping = true - self.videoOutput.setSampleBufferDelegate(nil, queue: nil) - guard let writer = self.writer else { - eprint("No video frames were received from the iOS capture device.") - self.finish(exitCode: 7) + if self.finished { return } + self.shutdownRequested = true + switch self.state { + case .recording: + self.stopSegment(exitWhenFinished: true) + case .finalizing: + return + default: + self.finish(exitCode: 0) + } + } + } + + private func readCommands() { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + while let line = readLine() { + self?.handleCommandLine(line) + } + } + } + + private func handleCommandLine(_ line: String) { + guard let data = line.data(using: .utf8) else { + emitError("Command was not valid UTF-8.") + return + } + do { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let operation = object["operation"] as? String else { + emitError("Command must be a JSON object with an operation.") + return + } + switch operation { + case "start": + guard let outputPath = object["outputPath"] as? String else { + emitError("start command requires outputPath.") + return + } + startSegment(outputURL: URL(fileURLWithPath: outputPath)) + case "stop": + queue.async { [weak self] in + self?.stopSegment(exitWhenFinished: false) + } + case "shutdown": + shutdown() + default: + emitError("Unsupported operation: \(operation)") + } + } catch { + emitError("Command JSON parse failed: \(error.localizedDescription)") + } + } + + private func startSegment(outputURL: URL) { + queue.async { [weak self] in + guard let self else { return } + guard self.state == .ready else { + self.emitError("Cannot start segment while state is \(self.state).") return } - self.writerInput?.markAsFinished() - DispatchQueue.global().asyncAfter(deadline: .now() + 10) { [weak self] in - guard let self, !self.finished else { return } - eprint("Timed out while finalizing the movie file.") - writer.cancelWriting() - self.finish(exitCode: 8) + do { + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } + self.outputURL = outputURL + self.writer = nil + self.writerInput = nil + self.wroteFrames = false + self.startedEmitted = false + self.state = .recording + } catch { + self.emitError("Failed to prepare output file: \(error.localizedDescription)") } - writer.finishWriting { [weak self] in - guard let self else { return } + } + } + + private func stopSegment(exitWhenFinished: Bool) { + guard state == .recording else { + if mode == .record { + eprint("No video frames were received from the iOS capture device.") + finish(exitCode: 7) + } else { + emitError("Cannot stop segment while state is \(state).") + } + return + } + state = .finalizing + guard let writer = writer else { + if mode == .record { + eprint("No video frames were received from the iOS capture device.") + finish(exitCode: 7) + } else { + emitError("No video frames were received from the iOS capture device.") + state = .ready + } + return + } + writerInput?.markAsFinished() + DispatchQueue.global().asyncAfter(deadline: .now() + 10) { [weak self] in + guard let self, !self.finished, self.state == .finalizing else { return } + eprint("Timed out while finalizing the movie file.") + writer.cancelWriting() + self.finish(exitCode: 8) + } + writer.finishWriting { [weak self] in + guard let self else { return } + self.queue.async { if let error = writer.error { - eprint("Movie finalization failed: \(error.localizedDescription)") - self.finish(exitCode: 8) + if self.mode == .record { + eprint("Movie finalization failed: \(error.localizedDescription)") + self.finish(exitCode: 8) + } else { + self.emitError("Movie finalization failed: \(error.localizedDescription)") + self.state = .ready + } return } - self.finish(exitCode: self.wroteFrames ? 0 : 7) + let savedURL = self.outputURL + self.writer = nil + self.writerInput = nil + self.outputURL = nil + self.state = .ready + if self.mode == .record || exitWhenFinished || self.shutdownRequested { + self.finish(exitCode: self.wroteFrames ? 0 : 7) + return + } + if self.wroteFrames { + self.emitEvent(["event": "saved", "outputPath": savedURL?.path ?? ""]) + } else { + self.emitError("No video frames were received from the iOS capture device.") + } } } } private func finish(exitCode: Int32) { finished = true + state = .closed + videoOutput.setSampleBufferDelegate(nil, queue: nil) session.stopRunning() DispatchQueue.main.async { Foundation.exit(exitCode) @@ -176,31 +309,64 @@ final class MovieRecorder: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat } func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { - if stopping { return } do { + if state == .preparing { + state = .ready + if mode == .serve { + emitEvent(["event": "ready"]) + return + } + state = .recording + } + guard state == .recording else { return } try prepareWriter(sampleBuffer: sampleBuffer) guard let input = writerInput, input.isReadyForMoreMediaData else { return } + if mode == .serve && !startedEmitted { + emitEvent(["event": "started"]) + startedEmitted = true + } if !input.append(sampleBuffer) { eprint("Failed to append video frame: \(writer?.error?.localizedDescription ?? "unknown error")") - stop() + if mode == .record { + shutdown() + } else { + emitError("Failed to append video frame: \(writer?.error?.localizedDescription ?? "unknown error")") + } } else { wroteFrames = true } } catch { eprint("Recording failed: \(error.localizedDescription)") - stop() + if mode == .record { + shutdown() + } else { + emitError("Recording failed: \(error.localizedDescription)") + } } } + + private func emitError(_ message: String) { + emitEvent(["event": "error", "message": message]) + } + + private func emitEvent(_ object: [String: String]) { + guard let data = try? JSONSerialization.data(withJSONObject: object), + let line = String(data: data, encoding: .utf8) else { + return + } + print(line) + fflush(stdout) + } } final class RecorderSignalBridge { static let shared = RecorderSignalBridge() weak var recorder: MovieRecorder? - func stop() { - recorder?.stop() + func shutdown() { + recorder?.shutdown() } } @@ -222,6 +388,7 @@ func usage() -> Never { Usage: ios_physical_capture list ios_physical_capture record --device-id ID --output PATH + ios_physical_capture serve --device-id ID """) exit(2) } @@ -242,13 +409,28 @@ case "record": exit(4) } do { - let recorder = MovieRecorder() + let recorder = MovieRecorder(mode: .record) try recorder.start(device: selected.device, outputURL: URL(fileURLWithPath: outputPath)) } catch { eprint("Recording failed: \(error.localizedDescription)") exit(6) } +case "serve": + guard let deviceID = value(after: "--device-id", in: args) else { usage() } + let devices = discoverIOSDevices() + guard let selected = devices.first(where: { $0.id == deviceID }) else { + eprint("No physical iOS capture device matched id \(deviceID).") + exit(4) + } + do { + let recorder = MovieRecorder(mode: .serve) + try recorder.start(device: selected.device, outputURL: nil) + } catch { + eprint("Capture failed: \(error.localizedDescription)") + exit(6) + } + default: usage() } diff --git a/test/execution/project_run_executor_test.dart b/test/execution/project_run_executor_test.dart index da5ef06..30fcf36 100644 --- a/test/execution/project_run_executor_test.dart +++ b/test/execution/project_run_executor_test.dart @@ -5,6 +5,7 @@ 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:screen_recorder/screen_recorder.dart' as screen_recorder; import 'package:test/test.dart'; import '../support/test_command_fakes.dart'; @@ -33,6 +34,7 @@ void main() { ]), outputDirectory: Directory.current, clock: () => DateTime.utc(2026, 7, 1, 9, 30), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final List scenarios = [ ProjectScenarioFile( @@ -132,6 +134,7 @@ void main() { ]), outputDirectory: Directory.current, clock: () => DateTime.utc(2026, 7, 1, 9, 30), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final List scenarios = [ ProjectScenarioFile( @@ -170,6 +173,98 @@ void main() { }, ); + test( + 'default Project Run records close recording failure after successful runs', + () 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', + ), + closeException: const TestExecutionRecordingException( + 'recording dispose failed', + ), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + sessionFactory: FakeTestExecutionSessionFactory(session), + runnerFactory: QueueScenarioRunnerFactory([ + FakeScenarioRunner(passedScenarioRunReportFor('login')), + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + recordingControllerFactory: _fakeRecordingControllerFactory, + ); + + final ProjectRunResult result = await 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, + ), + ); + + expect(result.status, ProjectRunStatus.environmentFailed); + expect(session.closeCount, 1); + }); + }, + ); + + test( + 'default Project Run preserves Scenario failure when close recording fails', + () 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', + ), + closeException: const TestExecutionRecordingException( + 'recording dispose failed', + ), + ); + final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( + sessionFactory: FakeTestExecutionSessionFactory(session), + runnerFactory: QueueScenarioRunnerFactory([ + FailingScenarioRunner(failingScenarioRunReportFor('login')), + ]), + outputDirectory: Directory.current, + clock: () => DateTime.utc(2026, 7, 1, 9, 30), + recordingControllerFactory: _fakeRecordingControllerFactory, + ); + + final ProjectRunResult result = await 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, + ), + ); + + expect(result.status, ProjectRunStatus.failed); + expect(session.closeCount, 1); + }); + }, + ); + test( 'default Project Run executor resolves explicit Target Device before launch', () async { @@ -201,6 +296,7 @@ void main() { ]), outputDirectory: Directory.current, clock: () => DateTime.utc(2026, 7, 1, 9, 30), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final List scenarios = [ ProjectScenarioFile( @@ -269,16 +365,16 @@ void main() { final FakeDeviceDiscovery deviceDiscovery = FakeDeviceDiscovery( flutterDevices: [ FlutterDevice( - id: 'pixel-8', - name: 'Pixel 8', - targetPlatform: 'android-arm64', + id: 'flutter-udid', + name: 'Test iPhone', + targetPlatform: 'ios', isSupported: true, - emulator: true, - sdk: 'Android 35', + emulator: false, + sdk: 'iOS 15.8.8', ), ], recordingDevices: [ - RecordingDeviceIdentity(id: 'pixel-8'), + RecordingDeviceIdentity(id: 'avfoundation-id', name: 'Test iPhone'), ], ); final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( @@ -289,6 +385,7 @@ void main() { ]), outputDirectory: Directory.current, clock: () => DateTime.utc(2026, 7, 1, 9, 30), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final List launchEvents = []; @@ -343,10 +440,14 @@ void main() { 'run', '--machine', '--device-id', - 'pixel-8', + 'flutter-udid', ]); - expect(runner.targetDevice?.id, 'pixel-8'); - expect(runner.recordingController, isNotNull); + expect(runner.targetDevice?.id, 'flutter-udid'); + expect( + (runner.recordingController as ScreenRecorderRecordingController) + .deviceSelector, + 'avfoundation-id', + ); final TargetAppLaunchStartedEvent startedEvent = launchEvents .whereType() .single; @@ -386,8 +487,8 @@ void main() { ), ], recordingDevices: [ - const RecordingDeviceIdentity(id: 'pixel-8'), - const RecordingDeviceIdentity(id: 'iphone-15'), + const RecordingDeviceIdentity(id: 'pixel-8', name: 'Pixel 8'), + const RecordingDeviceIdentity(id: 'iphone-15', name: 'iPhone 15'), ], ); final DefaultProjectRunExecutor executor = DefaultProjectRunExecutor( @@ -398,6 +499,7 @@ void main() { ]), outputDirectory: Directory.current, clock: () => DateTime.utc(2026, 7, 1, 9, 30), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final List scenarios = [ ProjectScenarioFile( @@ -840,3 +942,22 @@ void main() { }); }); } + +RecordingController _fakeRecordingControllerFactory({ + required String deviceSelector, + required Directory outputDirectory, +}) { + return ScreenRecorderRecordingController( + recorder: screen_recorder.ScreenRecorder.fake( + devices: [ + screen_recorder.RecordingDevice( + id: deviceSelector, + name: deviceSelector, + platform: screen_recorder.RecordingDevicePlatform.android, + ), + ], + ), + deviceSelector: deviceSelector, + outputDirectory: outputDirectory, + ); +} diff --git a/test/execution/test_command_executor_test.dart b/test/execution/test_command_executor_test.dart index 3970f4d..d152f26 100644 --- a/test/execution/test_command_executor_test.dart +++ b/test/execution/test_command_executor_test.dart @@ -1,8 +1,10 @@ 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:screen_recorder/screen_recorder.dart' as screen_recorder; import 'package:test/test.dart'; import '../support/test_command_fakes.dart'; @@ -129,6 +131,127 @@ void main() { }, ); + test( + 'default executor reports close recording failure after successful run', + () async { + final FakeTestExecutionSession session = FakeTestExecutionSession( + runtimeTarget: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/token=/ws'), + deviceId: 'pixel-8', + ), + closeException: const TestExecutionRecordingException( + 'recording dispose failed', + ), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + sessionFactory: FakeTestExecutionSessionFactory(session), + runnerFactory: FakeScenarioRunnerFactory( + FakeScenarioRunner(passedScenarioRunReport()), + ), + ); + + await expectLater( + executor.run( + TestCommandOptions( + scenario: scenarioFixture('close_recording_failure'), + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + ), + throwsA( + isA().having( + (TestCommandException error) => error.message, + 'message', + 'recording dispose failed', + ), + ), + ); + }, + ); + + test( + 'default executor preserves primary failure when close recording fails', + () async { + final FakeTestExecutionSession session = FakeTestExecutionSession( + runtimeTarget: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/token=/ws'), + deviceId: 'pixel-8', + ), + closeException: const TestExecutionRecordingException( + 'recording dispose failed', + ), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + sessionFactory: FakeTestExecutionSessionFactory(session), + runnerFactory: const ThrowingScenarioRunnerFactory( + RuntimeAdapterSelectionException('runtime selection failed'), + ), + ); + + await expectLater( + executor.run( + TestCommandOptions( + scenario: scenarioFixture('primary_failure'), + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + ), + throwsA( + isA().having( + (TestCommandException error) => error.message, + 'message', + 'runtime selection failed', + ), + ), + ); + expect(session.closeCount, 1); + }, + ); + + test( + 'default executor preserves failed Scenario report when close recording fails', + () async { + final FakeTestExecutionSession session = FakeTestExecutionSession( + runtimeTarget: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/token=/ws'), + deviceId: 'pixel-8', + ), + closeException: const TestExecutionRecordingException( + 'recording dispose failed', + ), + ); + final DefaultTestCommandExecutor executor = DefaultTestCommandExecutor( + sessionFactory: FakeTestExecutionSessionFactory(session), + runnerFactory: FakeScenarioRunnerFactory( + FakeScenarioRunner(failingScenarioRunReportFor('scenario_failure')), + ), + ); + + final ScenarioRunReport report = await executor.run( + TestCommandOptions( + scenario: scenarioFixture('scenario_failure'), + device: null, + flavor: null, + target: null, + stopPoint: null, + printDiagnostics: const {}, + jsonOutput: false, + ), + ); + + expect(report.status, ScenarioRunStatus.failed); + expect(session.closeCount, 1); + }, + ); + test( 'default executor reports invalid hidden runtime switch values', () async { @@ -327,20 +450,24 @@ void main() { deviceDiscovery: FakeDeviceDiscovery( flutterDevices: [ FlutterDevice( - id: 'pixel-8', - name: 'Pixel 8', - targetPlatform: 'android-arm64', + id: 'flutter-udid', + name: 'Test iPhone', + targetPlatform: 'ios', isSupported: true, - emulator: true, - sdk: 'Android 35', + emulator: false, + sdk: 'iOS 15.8.8', ), ], recordingDevices: [ - RecordingDeviceIdentity(id: 'pixel-8'), + RecordingDeviceIdentity( + id: 'avfoundation-id', + name: 'Test iPhone', + ), ], ), launcher: TargetAppLauncher(starter: starter), runnerFactory: FakeScenarioRunnerFactory(runner), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final Scenario scenario = Scenario( name: 'recorded', @@ -381,10 +508,14 @@ void main() { 'run', '--machine', '--device-id', - 'pixel-8', + 'flutter-udid', ]); - expect(runner.targetDevice?.id, 'pixel-8'); - expect(runner.recordingController, isNotNull); + expect(runner.targetDevice?.id, 'flutter-udid'); + expect( + (runner.recordingController as ScreenRecorderRecordingController) + .deviceSelector, + 'avfoundation-id', + ); }); }, ); @@ -415,6 +546,7 @@ void main() { ), launcher: TargetAppLauncher(starter: starter), runnerFactory: FakeScenarioRunnerFactory(runner), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final Scenario scenario = Scenario( name: 'explicit_device', @@ -493,11 +625,12 @@ void main() { ), ], recordingDevices: [ - RecordingDeviceIdentity(id: 'pixel-8'), + RecordingDeviceIdentity(id: 'pixel-8', name: 'Pixel 8'), ], ), launcher: TargetAppLauncher(starter: starter), runnerFactory: FakeScenarioRunnerFactory(runner), + recordingControllerFactory: _fakeRecordingControllerFactory, ); final Scenario scenario = Scenario( name: 'recorded', @@ -810,3 +943,22 @@ void main() { }); }); } + +RecordingController _fakeRecordingControllerFactory({ + required String deviceSelector, + required Directory outputDirectory, +}) { + return ScreenRecorderRecordingController( + recorder: screen_recorder.ScreenRecorder.fake( + devices: [ + screen_recorder.RecordingDevice( + id: deviceSelector, + name: deviceSelector, + platform: screen_recorder.RecordingDevicePlatform.android, + ), + ], + ), + deviceSelector: deviceSelector, + outputDirectory: outputDirectory, + ); +} diff --git a/test/execution/test_execution_session_test.dart b/test/execution/test_execution_session_test.dart index b300f2b..9e0ab09 100644 --- a/test/execution/test_execution_session_test.dart +++ b/test/execution/test_execution_session_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; @@ -63,29 +64,235 @@ void main() { expect(process.stdinWrites, ['q\n']); }, ); + + test('keeps Target and Recording Device selectors separate', () async { + final FakeDeviceDiscovery discovery = FakeDeviceDiscovery( + flutterDevices: const [ + FlutterDevice( + id: 'flutter-udid', + name: 'Test iPhone', + targetPlatform: 'ios', + isSupported: true, + emulator: false, + sdk: 'iOS 15.8.8', + ), + ], + recordingDevices: const [ + RecordingDeviceIdentity(id: 'avfoundation-id', name: 'Test iPhone'), + ], + ); + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final TestExecutionSessionFactory factory = + DefaultTestExecutionSessionFactory( + deviceDiscovery: discovery, + launcher: TargetAppLauncher(starter: starter), + recordingControllerFactory: + ({ + required String deviceSelector, + required Directory outputDirectory, + }) => _EventRecordingController([]), + ); + + final Future sessionFuture = factory.start( + deviceSelector: 'flutter-udid', + flavor: null, + target: null, + recordingRequired: true, + launchHeartbeatEnabled: false, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, + }), + ); + + final TestExecutionSession session = await sessionFuture; + + expect(starter.startedArguments, [ + 'run', + '--machine', + '--device-id', + 'flutter-udid', + ]); + expect(session.targetDevice?.id, 'flutter-udid'); + expect(session.recordingDeviceSelector, 'avfoundation-id'); + + await session.close(); + }); + + test( + 'prepares recording before launch and disposes after app cleanup', + () async { + final List events = []; + final FakeDeviceDiscovery discovery = FakeDeviceDiscovery( + flutterDevices: const [ + FlutterDevice( + id: 'flutter-udid', + name: 'Test iPhone', + targetPlatform: 'ios', + isSupported: true, + emulator: false, + sdk: 'iOS 15.8.8', + ), + ], + recordingDevices: const [ + RecordingDeviceIdentity(id: 'avfoundation-id', name: 'Test iPhone'), + ], + ); + final FakeTargetAppProcess process = FakeTargetAppProcess(events: events); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + events: events, + ); + final TestExecutionSessionFactory factory = + DefaultTestExecutionSessionFactory( + deviceDiscovery: discovery, + launcher: TargetAppLauncher(starter: starter), + recordingControllerFactory: + ({ + required String deviceSelector, + required Directory outputDirectory, + }) { + events.add('recording-controller:$deviceSelector'); + return _EventRecordingController(events); + }, + ); + + final Future sessionFuture = factory.start( + deviceSelector: 'flutter-udid', + flavor: null, + target: null, + recordingRequired: true, + launchHeartbeatEnabled: false, + ); + + await Future.delayed(Duration.zero); + expect(events, [ + 'recording-controller:avfoundation-id', + 'prepare', + 'launch', + ]); + + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, + }), + ); + final TestExecutionSession session = await sessionFuture; + + await session.close(); + + expect(events, [ + 'recording-controller:avfoundation-id', + 'prepare', + 'launch', + 'app-close', + 'dispose', + ]); + }, + ); + + test('wraps recording dispose failures during close', () async { + final FakeTargetAppProcess process = FakeTargetAppProcess(); + final FakeTargetAppProcessStarter starter = FakeTargetAppProcessStarter( + process, + ); + final TestExecutionSessionFactory factory = + DefaultTestExecutionSessionFactory( + deviceDiscovery: FakeDeviceDiscovery( + flutterDevices: const [ + FlutterDevice( + id: 'flutter-udid', + name: 'Test iPhone', + targetPlatform: 'ios', + isSupported: true, + emulator: false, + sdk: 'iOS 15.8.8', + ), + ], + recordingDevices: const [ + RecordingDeviceIdentity( + id: 'avfoundation-id', + name: 'Test iPhone', + ), + ], + ), + launcher: TargetAppLauncher(starter: starter), + recordingControllerFactory: + ({ + required String deviceSelector, + required Directory outputDirectory, + }) => _EventRecordingController( + [], + disposeException: const RecordingException( + operation: RecordingOperation.dispose, + message: 'dispose failed', + ), + ), + ); + + final Future sessionFuture = factory.start( + deviceSelector: 'flutter-udid', + flavor: null, + target: null, + recordingRequired: true, + launchHeartbeatEnabled: false, + ); + process.emitStdout( + jsonEncode({ + 'event': 'app.debugPort', + 'params': {'wsUri': 'ws://127.0.0.1:1234/token=/ws'}, + }), + ); + final TestExecutionSession session = await sessionFuture; + + await expectLater( + session.close(), + throwsA( + isA().having( + (TestExecutionRecordingException error) => error.message, + 'message', + 'dispose failed', + ), + ), + ); + }); } class FakeDeviceDiscovery implements TestDeviceDiscovery { + FakeDeviceDiscovery({ + this.flutterDevices = const [], + this.recordingDevices = const [], + }); + + final List flutterDevices; + final List recordingDevices; int flutterDeviceListCount = 0; int recordingDeviceListCount = 0; @override Future> listFlutterDevices() async { flutterDeviceListCount++; - return const []; + return flutterDevices; } @override Future> listRecordingDevices() async { recordingDeviceListCount++; - return const []; + return recordingDevices; } } class FakeTargetAppProcessStarter implements TargetAppProcessStarter { - FakeTargetAppProcessStarter(this.process); + FakeTargetAppProcessStarter(this.process, {this.events}); final FakeTargetAppProcess process; + final List? events; List startedArguments = const []; @override @@ -93,12 +300,16 @@ class FakeTargetAppProcessStarter implements TargetAppProcessStarter { String executable, List arguments, ) async { + events?.add('launch'); startedArguments = arguments; return process; } } class FakeTargetAppProcess implements TargetAppProcess { + FakeTargetAppProcess({this.events}); + + final List? events; final StreamController> _stdoutController = StreamController>(); final StreamController> _stderrController = @@ -123,6 +334,7 @@ class FakeTargetAppProcess implements TargetAppProcess { void writeStdin(String text) { stdinWrites.add(text); if (text == 'q\n' && !_exitCodeCompleter.isCompleted) { + events?.add('app-close'); _stdoutController.close(); _stderrController.close(); _exitCodeCompleter.complete(0); @@ -134,3 +346,32 @@ class FakeTargetAppProcess implements TargetAppProcess { return true; } } + +class _EventRecordingController implements RecordingController { + _EventRecordingController(this.events, {this.disposeException}); + + final List events; + final RecordingException? disposeException; + + @override + Future prepare() async { + events.add('prepare'); + } + + @override + Future start(Scenario scenario) async {} + + @override + Future stop() async { + return const RecordingResult(path: 'recording.mov'); + } + + @override + Future dispose() async { + events.add('dispose'); + final RecordingException? exception = disposeException; + if (exception != null) { + throw exception; + } + } +} diff --git a/test/recording/screen_recorder_recording_controller_test.dart b/test/recording/screen_recorder_recording_controller_test.dart index 903d212..550352f 100644 --- a/test/recording/screen_recorder_recording_controller_test.dart +++ b/test/recording/screen_recorder_recording_controller_test.dart @@ -37,6 +37,41 @@ void main() { }); }); + test( + 'prepares before starting a screen_recorder Recording Session', + () async { + await FileTestkit.runZoned(() async { + final Directory outputDirectory = Directory('screen_recording_output'); + final ScreenRecorderRecordingController controller = + ScreenRecorderRecordingController( + recorder: screen_recorder.ScreenRecorder.fake( + devices: const [ + screen_recorder.RecordingDevice( + id: 'device-1', + name: 'Pixel 8', + platform: screen_recorder.RecordingDevicePlatform.android, + ), + ], + ), + deviceSelector: 'Pixel', + outputDirectory: outputDirectory, + ); + + await controller.prepare(); + await controller.start( + const Scenario(name: 'prepared_run', steps: []), + ); + final RecordingResult result = await controller.stop(); + await controller.dispose(); + await controller.dispose(); + + expect(result.path, endsWith('prepared_run.mp4')); + expect(result.mimeType, 'video/mp4'); + expect(File(result.path).existsSync(), isTrue); + }); + }, + ); + test('normalizes screen_recorder startup failures', () async { await FileTestkit.runZoned(() async { final ScreenRecorderRecordingController controller = @@ -60,4 +95,106 @@ void main() { ); }); }); + + test('keeps prepared capture retryable when disposal fails', () async { + await FileTestkit.runZoned(() async { + final _DisposeFailingScreenRecorder recorder = + _DisposeFailingScreenRecorder(); + final ScreenRecorderRecordingController controller = + ScreenRecorderRecordingController( + recorder: recorder, + deviceSelector: 'Pixel', + outputDirectory: Directory('screen_recording_output'), + ); + + await controller.prepare(); + await expectLater( + controller.dispose(), + throwsA( + isA().having( + (RecordingException error) => error.operation, + 'operation', + RecordingOperation.dispose, + ), + ), + ); + + recorder.disposeShouldFail = false; + await controller.dispose(); + + expect(recorder.disposeCount, 2); + }); + }); +} + +class _DisposeFailingScreenRecorder implements screen_recorder.ScreenRecorder { + final screen_recorder.PreparedCapture _capture = + screen_recorder.PreparedCapture( + id: 'capture-1', + device: const screen_recorder.RecordingDevice( + id: 'device-1', + name: 'Pixel 8', + platform: screen_recorder.RecordingDevicePlatform.android, + ), + ); + + bool disposeShouldFail = true; + int disposeCount = 0; + + @override + Future> listDevices() async { + return [_capture.device]; + } + + @override + Future prepare({ + required String deviceSelector, + }) async { + return _capture; + } + + @override + Future startRecord({ + String? deviceSelector, + screen_recorder.PreparedCapture? preparedCapture, + required String outputDirectory, + String? outputName, + bool overwrite = false, + }) async { + return screen_recorder.RecordingSession( + id: 'recording-1', + device: _capture.device, + startTime: DateTime.utc(2026, 7, 13), + expectedOutputPath: '$outputDirectory/$outputName.mp4', + ); + } + + @override + Future stopRecord( + screen_recorder.RecordingSession session, + ) async { + return screen_recorder.RecordingResult( + session: session, + outputPath: session.expectedOutputPath, + startTime: session.startTime, + stopTime: session.startTime, + duration: Duration.zero, + fileSizeBytes: 0, + mimeType: 'video/mp4', + ); + } + + @override + Future discardRecord(screen_recorder.RecordingSession session) async {} + + @override + Future dispose(screen_recorder.PreparedCapture capture) async { + disposeCount++; + if (disposeShouldFail) { + throw const screen_recorder.ScreenRecorderException( + code: screen_recorder.ScreenRecorderErrorCode.discardFailed, + message: 'Dispose failed.', + ); + } + } } diff --git a/test/support/fake_recording_controller.dart b/test/support/fake_recording_controller.dart index f3af83e..40080b4 100644 --- a/test/support/fake_recording_controller.dart +++ b/test/support/fake_recording_controller.dart @@ -29,6 +29,19 @@ class FakeRecordingController implements RecordingController { final List runtimeEvents; final List events = []; + @override + Future prepare() async { + if (failure?.operation == RecordingOperation.prepare) { + throw failure!; + } + events.add( + FakeRecordingEvent( + operation: RecordingOperation.prepare, + runtimeEventCountAtStart: runtimeEvents.length, + ), + ); + } + @override Future start(Scenario scenario) async { if (failure?.operation == RecordingOperation.start) { @@ -61,6 +74,19 @@ class FakeRecordingController implements RecordingController { } return result; } + + @override + Future dispose() async { + if (failure?.operation == RecordingOperation.dispose) { + throw failure!; + } + events.add( + FakeRecordingEvent( + operation: RecordingOperation.dispose, + runtimeEventCountAtStart: runtimeEvents.length, + ), + ); + } } /// One call recorded by `FakeRecordingController`. diff --git a/test/support/test_command_fakes.dart b/test/support/test_command_fakes.dart index d2c920e..1c74b19 100644 --- a/test/support/test_command_fakes.dart +++ b/test/support/test_command_fakes.dart @@ -215,7 +215,13 @@ class FakeTestExecutionSessionFactory implements TestExecutionSessionFactory { } class FakeTestExecutionSession implements TestExecutionSession { - FakeTestExecutionSession({required this.runtimeTarget, this.targetDevice}); + FakeTestExecutionSession({ + required this.runtimeTarget, + this.targetDevice, + this.recordingDeviceSelector, + this.recordingController, + this.closeException, + }); @override final RuntimeTarget runtimeTarget; @@ -223,6 +229,14 @@ class FakeTestExecutionSession implements TestExecutionSession { @override final TargetDevice? targetDevice; + @override + final String? recordingDeviceSelector; + + @override + final RecordingController? recordingController; + + final TestExecutionSessionException? closeException; + int runWithInterruptCount = 0; int closeCount = 0; int hotRestartCount = 0; @@ -241,6 +255,10 @@ class FakeTestExecutionSession implements TestExecutionSession { @override Future close() async { closeCount++; + final TestExecutionSessionException? exception = closeException; + if (exception != null) { + throw exception; + } } } diff --git a/test/target/target_device_resolver_test.dart b/test/target/target_device_resolver_test.dart index 052aeae..53cd7a0 100644 --- a/test/target/target_device_resolver_test.dart +++ b/test/target/target_device_resolver_test.dart @@ -13,7 +13,7 @@ void main() { test( 'returns null when no selector is provided and recording is not required', () { - final TargetDevice? device = TargetDeviceResolver.resolve( + final ResolvedTargetDevice? device = TargetDeviceResolver.resolve( selector: null, recordingRequired: false, flutterDevices: const [], @@ -44,28 +44,29 @@ void main() { ), ]; - final TargetDevice byId = TargetDeviceResolver.resolve( + final ResolvedTargetDevice byId = TargetDeviceResolver.resolve( selector: 'emulator-5554', recordingRequired: false, flutterDevices: devices, recordingDevices: const [], )!; - final TargetDevice byName = TargetDeviceResolver.resolve( + final ResolvedTargetDevice byName = TargetDeviceResolver.resolve( selector: 'Drown iPhone', recordingRequired: false, flutterDevices: devices, recordingDevices: const [], )!; - final TargetDevice byPrefix = TargetDeviceResolver.resolve( + final ResolvedTargetDevice byPrefix = TargetDeviceResolver.resolve( selector: 'Pixel', recordingRequired: false, flutterDevices: devices, recordingDevices: const [], )!; - expect(byId.id, 'emulator-5554'); - expect(byName.id, '00008110-001C2D'); - expect(byPrefix.name, 'Pixel 8'); + expect(byId.targetDevice.id, 'emulator-5554'); + expect(byName.targetDevice.id, '00008110-001C2D'); + expect(byPrefix.targetDevice.name, 'Pixel 8'); + expect(byId.recordingDevice, isNull); }); test('rejects empty, ambiguous, unsupported, and missing selectors', () { @@ -152,57 +153,125 @@ void main() { ); }); - test( - 'requires recording devices to match the resolved Flutter Device id', - () { - const List devices = [ - 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: true, - sdk: 'iOS 18.5', + test('pairs a Recording Device by exact id before exact name', () { + const List devices = [ + 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: true, + sdk: 'iOS 18.5', + ), + ]; + + final ResolvedTargetDevice selected = TargetDeviceResolver.resolve( + selector: 'Pixel 8', + recordingRequired: true, + flutterDevices: devices, + recordingDevices: const [ + RecordingDeviceIdentity(id: 'pixel-8', name: 'Wrong name'), + RecordingDeviceIdentity(id: 'other-id', name: 'Pixel 8'), + ], + )!; + + expect(selected.targetDevice.id, 'pixel-8'); + expect(selected.recordingDevice?.id, 'pixel-8'); + }); + + test('pairs a Recording Device by a unique exact name', () { + const FlutterDevice flutterDevice = FlutterDevice( + id: '00008110-001C2D', + name: 'Drown iPhone', + targetPlatform: 'ios', + isSupported: true, + emulator: false, + sdk: 'iOS 18.5', + ); + + final ResolvedTargetDevice selected = TargetDeviceResolver.resolve( + selector: flutterDevice.id, + recordingRequired: true, + flutterDevices: const [flutterDevice], + recordingDevices: const [ + RecordingDeviceIdentity( + id: 'screen-recorder-ios-id', + name: 'Drown iPhone', ), - ]; + ], + )!; + + expect(selected.targetDevice.id, flutterDevice.id); + expect(selected.recordingDevice?.id, 'screen-recorder-ios-id'); + expect(selected.recordingDevice?.name, 'Drown iPhone'); + }); - final TargetDevice selected = TargetDeviceResolver.resolve( - selector: 'Pixel 8', + test('rejects duplicate exact Recording Device names as ambiguous', () { + expect( + () => TargetDeviceResolver.resolve( + selector: 'Drown iPhone', recordingRequired: true, - flutterDevices: devices, + flutterDevices: const [ + FlutterDevice( + id: 'flutter-ios-id', + name: 'Drown iPhone', + targetPlatform: 'ios', + isSupported: true, + emulator: false, + sdk: 'iOS 18.5', + ), + ], recordingDevices: const [ - RecordingDeviceIdentity(id: 'pixel-8'), + RecordingDeviceIdentity(id: 'recorder-1', name: 'Drown iPhone'), + RecordingDeviceIdentity(id: 'recorder-2', name: 'Drown iPhone'), ], - )!; - - expect(selected.id, 'pixel-8'); - expect( - () => TargetDeviceResolver.resolve( - selector: 'iPhone 15', - recordingRequired: true, - flutterDevices: devices, - recordingDevices: const [ - RecordingDeviceIdentity(id: 'Pixel 8'), - ], + ), + throwsA( + isA().having( + (TargetDeviceResolutionException error) => error.message, + 'message', + contains('ambiguous Recording Device'), ), - throwsA( - isA().having( - (TargetDeviceResolutionException error) => error.message, - 'message', - contains('is not available as a Recording Device'), + ), + ); + }); + + test('rejects an unmatched recording-required Target Device', () { + expect( + () => TargetDeviceResolver.resolve( + selector: 'iphone-15', + recordingRequired: true, + flutterDevices: const [ + FlutterDevice( + id: 'iphone-15', + name: 'iPhone 15', + targetPlatform: 'ios', + isSupported: true, + emulator: true, + sdk: 'iOS 18.5', ), + ], + recordingDevices: const [ + RecordingDeviceIdentity(id: 'other', name: 'Other iPhone'), + ], + ), + throwsA( + isA().having( + (TargetDeviceResolutionException error) => error.message, + 'message', + contains('is not available as a Recording Device'), ), - ); - }, - ); + ), + ); + }); test('auto-selects only one recordable supported Flutter Device', () { const List devices = [ @@ -232,17 +301,18 @@ void main() { ), ]; - final TargetDevice selected = TargetDeviceResolver.resolve( + final ResolvedTargetDevice selected = TargetDeviceResolver.resolve( selector: null, recordingRequired: true, flutterDevices: devices, recordingDevices: const [ - RecordingDeviceIdentity(id: 'iphone-15'), - RecordingDeviceIdentity(id: 'macos'), + RecordingDeviceIdentity(id: 'iphone-15', name: 'iPhone 15'), + RecordingDeviceIdentity(id: 'macos', name: 'macOS'), ], )!; - expect(selected.id, 'iphone-15'); + expect(selected.targetDevice.id, 'iphone-15'); + expect(selected.recordingDevice?.id, 'iphone-15'); expect( () => TargetDeviceResolver.resolve( selector: null, @@ -264,8 +334,8 @@ void main() { recordingRequired: true, flutterDevices: devices, recordingDevices: const [ - RecordingDeviceIdentity(id: 'pixel-8'), - RecordingDeviceIdentity(id: 'iphone-15'), + RecordingDeviceIdentity(id: 'pixel-8', name: 'Pixel 8'), + RecordingDeviceIdentity(id: 'iphone-15', name: 'iPhone 15'), ], ), throwsA(