Skip to content

Build-time test discovery + static native test codegen (experimental, opt-in) - #3197

Open
piotruela wants to merge 22 commits into
masterfrom
feat/ios-discovery-step
Open

Build-time test discovery + static native test codegen (experimental, opt-in)#3197
piotruela wants to merge 22 commits into
masterfrom
feat/ios-discovery-step

Conversation

@piotruela

Copy link
Copy Markdown
Collaborator

What & why

Stock Patrol discovers tests at runtime (dynamic registration once the app
launches), so no static list of test identifiers exists before a run. On iOS —
where XCUITest has no built-in sharding — that blocks per-test sharding entirely.

This PR adds experimental, opt-in build-time test discovery: a host
flutter test serializes the Dart test tree to a manifest at build time, and we
generate a real, statically-declared native test per Dart test. Every test becomes
individually addressable (-only-testing on iOS, am instrument -e class#method
on Android).

This should unblock iOS test sharding on device farms that shard by an explicit
test list — SauceLabs (testListFile) and BrowserStack
— plus balanced Android
sharding and cleaner per-test report names.

What's included

  • iOS static XCTest codegen (new STATIC runner macros) + Android static JUnit codegen.
  • Config opt-in: patrol.emit_test_manifest: true (the --emit-test-manifest
    flag overrides it); iOS build fails fast if RunnerUITests.m isn't switched to
    the static macro.
  • patrol test --no-build [--only "<dart name>"]: re-run built tests without
    rebuilding (iOS xcodebuild test-without-building, Android am instrument).
  • Discovery reporting (tests grouped by source file) + docs page.

Usage

# pubspec.yaml
patrol:
  emit_test_manifest: true
patrol build ios --emit-test-manifest         # or: patrol build android
patrol test --no-build --only "my group my test"   # one test, no rebuild

iOS also needs the static macro in RunnerUITests.m (see docs; the CLI prints the
exact snippet if missing).

Verification

Unit tests (manifest/selector/codegen/config) + dart analyze clean. Verified
end-to-end in dev/e2e_app: iOS single test via -only-testingExecuted 1 test; Android via am instrumentOK (1 test); --no-build --only runs one
test with no rebuild on both platforms.

Limitations

Experimental. Host-side discovery can diverge for tests registered conditionally
on the runtime env (Platform.isX, dart-define-derived names). Tests must live
outside integration_test/ (use patrol_test/). Parallel simulators on one host
collide on Patrol's fixed ports — shard across separate hosts/VMs (as farms do).
Initial build is slightly slower (paid once; --no-build re-runs skip building).

Copilot AI review requested due to automatic review settings July 24, 2026 21:52
@github-actions github-actions Bot added docs Concerns doc comments, READMEs, or docs package: patrol Related to the patrol package (native automation, test bundling) package: patrol_cli Related to the patrol_cli package labels Jul 24, 2026
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

🚀 Preview Deployment Ready!

Preview URL: https://pr-3197-patrol-docs.vercel.app


Latest Deployment

  • Commit: dfd13a1
  • Branch: feat/ios-discovery-step
  • Triggered by: @piotruela
  • Deployed at: 2026-07-28 20:35:54 UTC

This preview URL is stable and will be updated with each new commit to this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in build-time Patrol test discovery, static native test generation, and no-build test reruns for Android and iOS.

Changes:

  • Generates manifests and native XCTest/JUnit tests.
  • Adds configuration, selection, and --no-build CLI support.
  • Adds tests, documentation, and an end-to-end example.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
packages/patrol/lib/src/constants.dart Adds discovery-mode flag.
packages/patrol/lib/src/common.dart Registers no-op tests during discovery.
packages/patrol/darwin/patrol/Sources/PatrolImpl/PatrolTestManifest.swift Reads iOS manifests.
packages/patrol/darwin/patrol/Sources/patrol/include/PatrolIntegrationTestIosRunner.h Adds static XCTest runner macros.
packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolJUnitRunner.java Supports explicit skip values.
packages/patrol_cli/test/general/pubspec_reader_test.dart Tests manifest configuration.
packages/patrol_cli/test/crossplatform/test_manifest_test.dart Tests parsing and selectors.
packages/patrol_cli/test/android/android_test_codegen_test.dart Tests JUnit generation.
packages/patrol_cli/lib/src/test_bundler.dart Serializes discovery manifests.
packages/patrol_cli/lib/src/runner/patrol_command.dart Defines new CLI options.
packages/patrol_cli/lib/src/pubspec_reader.dart Reads the opt-in setting.
packages/patrol_cli/lib/src/ios/xcode_test_codegen.dart Generates XCTest methods.
packages/patrol_cli/lib/src/ios/ios_test_backend.dart Integrates iOS discovery and selection.
packages/patrol_cli/lib/src/crossplatform/test_manifest.dart Models manifests and native names.
packages/patrol_cli/lib/src/crossplatform/test_manifest_generator.dart Runs host-side discovery.
packages/patrol_cli/lib/src/crossplatform/app_options.dart Builds discovery and filtered-run commands.
packages/patrol_cli/lib/src/commands/test.dart Adds no-build execution flow.
packages/patrol_cli/lib/src/commands/build_ios.dart Adds iOS opt-in support.
packages/patrol_cli/lib/src/commands/build_android.dart Adds Android opt-in support.
packages/patrol_cli/lib/src/android/android_test_codegen.dart Generates static JUnit classes.
packages/patrol_cli/lib/src/android/android_test_backend.dart Integrates Android generation and reruns.
docs/documentation/ci/meta.json Registers the documentation page.
docs/documentation/ci/build-time-test-discovery.mdx Documents the feature.
docs/cli-commands/test.mdx Documents no-build testing.
docs/cli-commands/build.mdx Documents build-time discovery.
dev/e2e_app/patrol_test/dynamic_test.dart Adds dynamic discovery examples.
dev/e2e_app/ios/RunnerUITests/RunnerUITests.m Enables the static runner.
dev/e2e_app/.gitignore Ignores generated native tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/patrol_cli/lib/src/test_bundler.dart Outdated
Comment thread packages/patrol_cli/lib/src/ios/xcode_test_codegen.dart Outdated
Comment thread packages/patrol_cli/lib/src/android/android_test_codegen.dart Outdated
Comment thread packages/patrol/lib/src/common.dart
Comment thread packages/patrol_cli/lib/src/ios/ios_test_backend.dart
Comment thread packages/patrol_cli/lib/src/android/android_test_backend.dart
Comment thread packages/patrol_cli/lib/src/commands/test.dart Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (1)

packages/patrol_cli/lib/src/android/android_test_codegen.dart:194

  • Although _locateHostTest explicitly accepts .kt hosts, this regex only recognizes Java's FooActivity.class. A Kotlin host uses FooActivity::class.java, so a supported custom activity is silently replaced with MainActivity, potentially producing uncompilable or incorrect generated tests. Parse both forms.
  _HostActivity _parseActivity(String content) {
    final match = RegExp(
      r'setUp\(\s*([A-Za-z_][\w.]*)\.class',
    ).firstMatch(content);
    final token = match?.group(1) ?? 'MainActivity';

Comment thread packages/patrol_cli/lib/src/crossplatform/test_manifest.dart
Comment thread packages/patrol_cli/lib/src/android/android_test_codegen.dart
Comment thread packages/patrol/lib/src/common.dart
Comment thread docs/documentation/ci/build-time-test-discovery.mdx Outdated
Comment thread dev/e2e_app/scripts/generate_ios_testlist.sh Outdated
piotruela added 20 commits July 28, 2026 21:54
Move iOS Dart test discovery out of the runtime +testInvocations
app-launch to build time, and generate real native XCTest methods from
the discovered list so they are statically discoverable (Xcode navigator,
test plans, per-test -only-testing sharding).

Pipeline (patrol build ios --emit-test-manifest):
  - run the bundle on the host in discovery mode (flutter test +
    PATROL_TEST_DISCOVERY) -> serialize the DartGroupEntry tree to a
    manifest JSON. Same dart-defines as the build so names match.
  - codegen reads the manifest and writes PatrolGeneratedTests.inc:
    one - (void)test_<sanitized>_<index> per Dart test, with the verbatim
    Dart name in the body -> [self patrolExecuteDartTest:@"..." skip:...].
    Names stay byte-identical to runtime discovery (same listTestsFlat
    flattening, incl. maxTestLength cropping).
  - the .inc is #included between the new
    PATROL_INTEGRATION_TEST_IOS_RUNNER_STATIC_BEGIN/END macros, so it
    compiles into RunnerUITests without project.pbxproj changes.

Dart side: testDiscoveryEnabled guard in patrolTest/patrolSetUp/
patrolTearDown (register only, no PatrolBinding) so the bundle runs under
host flutter test; single-file bundle branches on the flag (the explorer
test writes the manifest and main() returns early to avoid a deadlock with
the automated binding).

Proven end-to-end on the simulator: all 20 generated selectors compile
into the .xctest binary; -only-testing runs an individual test in
isolation, mapping to the correct Dart test; skip flag is honored.

Notes / follow-ups:
  - PatrolTestManifest.swift + the manifest-reading branch in the original
    macro are the earlier "runtime manifest read" warm-up, now superseded
    by static codegen and safe to drop.
  - Parallel sharding on a single host (bluepill etc.) still needs
    per-shard PatrolServer port isolation (see comment in ios_test_backend
    _generateXcodeTests).
  - dart:io/dart:convert in the shared bundle template still need a web guard.
Port the iOS build-time test discovery to Android: reuse the host
`flutter test` discovery run to produce a manifest, then generate static
JUnit test methods so each Dart test becomes a real, individually
selectable native test (and shows up under its own name in reports
instead of the parameterized `runDartTest[...]` wrapper).

Pipeline (patrol build/test android --emit-test-manifest):
  - extract the manifest discovery (host `flutter test` +
    PATROL_TEST_DISCOVERY) into a shared TestManifestGenerator, used by
    both the iOS and Android backends.
  - AndroidTestCodegen reads the manifest and writes PatrolGeneratedTests
    .java into the app's androidTest source set (next to the host class,
    reusing its package): one @test per Dart test, verbatim Dart name
    embedded and handed to runDartTest(name, skip). Method names are
    sanitized Java identifiers (also sidesteps the Orchestrator 1.5.0
    whitespace limitation), disambiguated by index only on collision.
  - the generated class is @RunWith(AndroidJUnit4.class) so
    AndroidJUnitRunner enumerates its methods.
  - execute() runs only the generated class via
    -Pandroid.testInstrumentationRunnerArguments.class=<fqcn>, which also
    stops the parameterized host class from doing its runtime-discovery
    launch. Per-test selection (the Android analog of iOS -only-testing)
    is then `...class=<fqcn>#<method>`.

PatrolJUnitRunner: add a skip-aware runDartTest(name, skip) overload used
by the generated code, and guard the original runDartTest(name) against a
null skip entry (it NPE'd when the skip map is empty, which is the case
in static mode where listDartTests() is never called).

Wiring: --emit-test-manifest moved to a shared usesEmitTestManifestOption
() and registered on build ios / build android / test; emitTestManifest
added to AndroidAppOptions.

Proven end-to-end on an emulator (example_test.dart): 3 tests discovered,
2 passed, 1 skip honored, reported under their own method names.

Follow-ups:
  - androidx.test.runner.AndroidJUnit4 is deprecated; switch to
    androidx.test.ext:junit once it's on the androidTest classpath.
  - parallel sharding still needs per-shard PatrolServer port isolation
    (same open item as iOS).
Add a shared TestManifest parser (crossplatform/test_manifest.dart) that
flattens the build-time manifest byte-identically to the native runners and
records each test's source file (its top-level, file-derived group).

- TestManifestGenerator now prints a visible per-file discovery report
  (test count, source file paths, skip flags) instead of leaving it on the
  verbose-only channel; the file path is reconstructed from the top-level
  group and validated against disk.
- Refactor XcodeTestCodegen and AndroidTestCodegen onto the shared parser,
  removing the duplicated _FlatTest/_flatten in each.
- Raise the "Generated N static XCTest/JUnit method(s)" logs to the visible
  channel.
- Add unit tests for TestManifest (flatten equivalence, skip, top-level group).
Make build-time test discovery configurable per-project and add a no-rebuild
run path on top of the static native tests it generates.

- pubspec: `patrol.emit_test_manifest: true` (PatrolPubspecConfig). The
  `--emit-test-manifest` flag is now negatable and overrides the config value
  (optionalBoolArg ?? config) on build ios/android and test.
- iOS setup guardrail: when discovery is enabled, `patrol build ios` fails fast
  with instructions if RunnerUITests.m isn't switched to the STATIC macro +
  #include, instead of silently falling back to runtime discovery.
- `patrol test --no-build [--only "<dart name>"]`: run already-built tests
  without rebuilding (gated on emit_test_manifest). iOS uses `xcodebuild
  test-without-building -only-testing`; Android uses `adb shell am instrument
  -e class <fqcn>#<method>` (a true no-rebuild path). `--only` maps exact Dart
  names to selectors via the manifest.
- Move the selector/method-name generation into the shared
  crossplatform/test_manifest.dart (generateIosSelectors /
  generateAndroidMethodNames) so codegen and execution derive identical names
  from one source; both codegens now use it.
- Tests: emit_test_manifest parsing + selector generation.
Add a Build-time test discovery page (how it works, setup incl. the iOS
RunnerUITests.m static-macro change, running without rebuilding, sharding,
trade-offs) under CI, wire it into the nav, and document the config option and
`--no-build`/`--only` in the build/test CLI command pages.
scripts/generate_ios_testlist.sh derives the SauceLabs XCUITest testListFile
(.sauce/ios_testlist.txt) from the static XCTest methods emitted by
`patrol build ios --emit-test-manifest`, by extracting each generated selector
from PatrolGeneratedTests.inc. Deriving from the compiled .inc keeps the list
byte-identical to what XCTest discovers, with no duplicated sanitization. The
generated list is gitignored (regenerate per build); referenced from the
build-time discovery docs.
- Revert dev/e2e_app RunnerUITests.m to the runtime macro
  PATROL_INTEGRATION_TEST_IOS_RUNNER. The static macro requires the generated
  .inc, which only exists with --emit-test-manifest, so committing it broke the
  default (non-discovery) iOS pipeline. The static runner is now shown only in
  the docs as the opt-in step.
- Docs: clarify the runtime macro is the default and that switching back from
  static requires restoring it (--no-emit-test-manifest alone isn't enough on
  iOS).
- Fix: escape \r and \t in the generated Objective-C string literal
  (xcode_test_codegen), matching the Android generator, so test names with those
  characters don't break PatrolGeneratedTests.inc.
- Fix: the static runner's +setUp passed a NULL NSError** to
  startAndReturnError:, so a server start failure was never detected; use
  &err/nil.
… tests

- Static iOS runner's resetPermissions now resets the same resources as the
  runtime runner (HomeKit, MediaLibrary, KeyboardNetwork, Health, Focus,
  LocalNetwork), so --clear-permissions isn't weaker under static discovery.
- Delete stale generated code before discovery and on opt-out: Android removes
  PatrolGeneratedTests.java (so the runtime host class and a stale generated
  class can't both run), iOS removes PatrolGeneratedTests.inc (so a failed
  discovery fails loudly at #include instead of compiling stale tests).
- Add direct tests for the iOS XCTest codegen (selectors, skip, Obj-C escaping
  incl. \r/\t, duplicate-name uniqueness).
- executeWithoutBuilding installs the app + androidTest APKs via adb (glob
  build/app/outputs/apk/**, flavor-aware) before `am instrument`, so the
  documented `patrol build` → `patrol test --no-build` flow works on a clean
  device; errors clearly when APKs are missing.
- Resolve the real instrumentation component from `adb shell pm list
  instrumentation` (match target==applicationId, tolerate flavor suffix, prefer
  a Patrol runner), falling back to the default `<appId>.test`/PatrolJUnitRunner
  — supports custom testApplicationId / BrowserStack runner.
- Fatal (throwToolExit) when discovery is enabled but the manifest can't be
  produced, instead of silently skipping codegen.
- Drop the `--no-build` opt-in flag gate; the backends already validate that the
  built artifacts exist (only the `--only`-requires-`--no-build` and Android/iOS
  platform checks remain).
- Port +uninstallApp into the STATIC runner macro and honor --full-isolation
  (uninstall the app between tests, mirroring the runtime runner); previously
  --emit-test-manifest + --full-isolation silently skipped isolation.
- Fatal (throwToolExit) when discovery is enabled but the manifest can't be
  produced: the static macro has no runtime +testInvocations fallback, so a
  silent skip would leave the build to fail confusingly at #include.
The generated class hardcoded MainActivity.class, so host tests launching a
different activity (e.g. FlutterActivity.class) produced an uncompilable source.
Parse the activity token from the host's `setUp(<Class>.class)` (replicating its
import when the reference is unqualified) and emit that; default to MainActivity.
Adds a test covering a host that uses FlutterActivity.
The generated bundle imported `dart:io` to write the discovery manifest, which
made it uncompilable for Flutter web even with discovery disabled. Add a
conditionally-imported manifest writer in the patrol package (stub no-op for web,
dart:io implementation elsewhere) and have the bundle call writeTestManifest(...)
instead of File(...).writeAsStringSync(...).
In build-time discovery mode patrolTest registered a single default variant, so
tests using a custom TestVariant (which testWidgets expands into several named
cases) could be missing from the manifest or baked with names the on-device run
never registers. Forward `variant` and `semanticsEnabled` to the discovery-mode
testWidgets call so the discovered tree matches the device.
Note that --no-build reuses the previous build (re-run only when code is
unchanged) and how the Android instrumentation component is resolved, including
the custom testApplicationId / flavor applicationIdSuffix caveats.
- clang-format PatrolIntegrationTestIosRunner.h (the manual macro edits for
  full-isolation / permission parity / err fix left non-canonical backslash
  alignment that failed `clang-format --dry-run --Werror`).
- Use a raw string for the JSON fixture in android_test_codegen_test.dart to
  satisfy the `use_raw_strings` lint (analyzer runs with warnings-as-errors).
Run `dart format` on the files touched by the build-time discovery / --no-build
work so `dart format --set-exit-if-changed` passes (formatting only, no
behavior change).
_locateHostTest matched any file referencing PatrolJUnitRunner, but the
generated PatrolGeneratedTests.java references it too. Once generated, it became
a second, ambiguous host candidate whose selection depended on listSync ordering
(passing locally, failing in CI's `findGeneratedClassName` test). Skip the
generated class by name so only the real host is ever matched.
SauceLabs expects a different test-identifier separator by device type:
`TestTarget/TestClass/testMethod` on simulators but `TestTarget.TestClass/
testMethod` (a dot between target and class) on real devices. The generator only
emitted the simulator form, so on real devices no test matched and the run hung
on a blank/gray UITest runner.

- generate_ios_testlist.sh: add SAUCE_DEVICE=simulator|real (default simulator)
  to pick the separator; also mkdir -p the output dir.
- Document the real-vs-simulator format difference in the build-time discovery
  docs.
- Static iOS runner: reset the readiness flag before launching and wait for the
  app to report PatrolAppService readiness (bounded, with a clear failure) before
  sending runDartTest. Previously the request went out after the client's fixed
  1s delay, so a slower first launch failed as if the app had crashed — the very
  case the client's own TODO warns about.
- Android method names: never emit a Java keyword or reserved literal (a Dart
  test named `class` produced `public void class()`, which doesn't compile).
- Stop promising a runtime-discovery fallback that no longer exists: both
  backends abort when discovery fails, so say discovery failed instead.
- generate_ios_testlist.sh: let grep's no-match status through the pipeline, so
  the empty-list diagnostic is actually reachable under `set -euo pipefail`.
- Docs: the generated native method names are sanitized and suffixed; it's the
  embedded Dart name that stays byte-identical. Clarify which to use where.
… lookup

- Discovery now runs only the internal `patrol_test_explorer`
  (`flutter test --plain-name`). Declaring the tests is what builds the group
  tree, so the manifest stays complete, but the user's tests are never
  scheduled - so no setUp/tearDown/setUpAll/tearDownAll around them executes on
  the host. Previously only Patrol's own patrolSetUp/patrolTearDown were guarded,
  so plain hooks (or a project's own wrapper) still ran device-dependent
  fixtures and could fail the discovery run. Verified: with the filter the
  manifest keeps all tests and zero hooks run.
- Android host lookup no longer treats any file mentioning PatrolJUnitRunner as
  the host: runner subclasses (the documented Allure
  `AllurePatrolJUnitRunner.kt` lives next to the host test) and the generated
  class are skipped, the host is recognized by how it uses the runner
  (getInstrumentation + setUp/listDartTests), and candidates are sorted by path
  so the choice is deterministic.
@piotruela
piotruela force-pushed the feat/ios-discovery-step branch from 0742f95 to cc63861 Compare July 28, 2026 20:17
It was a scratch fixture from the POC, used to check that dart-define-derived
and loop-generated test names survive build-time discovery. It isn't referenced
anywhere and only added noise to the e2e suite.
android_test_codegen joined paths with `package:path`'s top-level `join`, which
uses the *host* platform separator rather than the separator of the FileSystem it
writes to. On Windows that mangled the generated file's path, the locator then
mistook the generated class for the host test (it drives the runner the same
way), and findGeneratedClassName looked in the wrong directory - failing
`findGeneratedClassName returns the FQN only after generation` on windows-latest
while passing on ubuntu.

Use `_fs.path.join` so the host platform is irrelevant, and additionally skip our
own output by its generated-code marker rather than by filename alone. Adds a
regression test on a Windows-style MemoryFileSystem (fails without the fix).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (11)

packages/patrol_cli/lib/src/android/android_test_codegen.dart:224

  • The host locator scans Kotlin files, but this expression only recognizes Java's FooActivity.class. A Kotlin host normally calls setUp(FooActivity::class.java), so a custom activity is silently replaced with MainActivity; the import parser also requires Java's semicolon. The generated test then launches or compiles against the wrong activity. Parse both Java and Kotlin forms.
    final match = RegExp(
      r'setUp\(\s*([A-Za-z_][\w.]*)\.class',
    ).firstMatch(content);

packages/patrol_cli/lib/src/android/android_test_backend.dart:683

  • When no flavor is supplied, this accepts every APK with the requested build-mode segment, including stale flavored APKs under paths such as apk/dev/debug. Since listSync() ordering is unspecified, --no-build can install a stale flavor (or pair app and test APKs from different variants) instead of the unflavored artifacts. Resolve the exact standard app/test paths for the requested flavor and mode, as BuildAndroidCommand.printApkPaths already does, rather than taking the first recursive matches.
    bool matches(File apk) {
      final segments = apk.path.split(RegExp(r'[/\\]'));
      if (!segments.contains(buildMode)) {
        return false;
      }
      // When a flavor is set, its (case-sensitive) directory segment is present
      // in both the app and androidTest APK paths; use it to disambiguate.
      if (flavor != null && !segments.contains(flavor)) {
        return false;
      }
      return true;

packages/patrol_cli/lib/src/android/android_test_backend.dart:773

  • This prefix match can select an unrelated installed application (for example, configured package com.foo also matches target com.foobar). If no exact instrumentation exists, --no-build may invoke the wrong runner. Restrict fallback matching to the dot-delimited applicationIdSuffix form.
    final prefixed = entries
        .where((e) => e.target != null && e.target!.startsWith(packageName))
        .toList();

packages/patrol_cli/lib/src/runner/patrol_command.dart:242

  • addMultiOption splits comma-separated values by default, but Dart test names can legitimately contain commas. For example, --only "opens menu, then closes it" is parsed as two requested names and will never match the manifest. Disable comma splitting so each repeated --only occurrence remains an exact test name.
      ..addMultiOption(
        'only',
        help:
            'With --no-build, run only the test(s) whose exact Dart name '
            '(as printed during discovery) is given. Repeatable; omit to run '
            'all discovered tests.',
      );

packages/patrol_cli/lib/src/ios/ios_test_backend.dart:285

  • The error is raised only when no requested name matches. With multiple --only values, one valid name plus one typo silently runs the valid test and reports success without running the other requested test. Track matched names and fail if any distinct requested name is absent.
    for (var i = 0; i < tests.length; i++) {
      if (onlyTests.contains(tests[i].dartName)) {
        out.add(selectors[i]);
      }
    }
    if (out.isEmpty) {
      throwToolExit(
        'None of the requested --only test(s) were found in the manifest.\n'
        'Available tests:\n${tests.map((t) => '  ${t.dartName}').join('\n')}',
      );

packages/patrol_cli/lib/src/android/android_test_backend.dart:647

  • The error is raised only when no requested name matches. With multiple --only values, one valid name plus one typo silently runs the valid test and reports success without running the other requested test. Track matched names and fail if any distinct requested name is absent.

This issue also appears in the following locations of the same file:

  • line 673
  • line 771
    for (var i = 0; i < tests.length; i++) {
      if (onlyTests.contains(tests[i].dartName)) {
        out.add('$fqcn#${methods[i]}');
      }
    }
    if (out.isEmpty) {
      throwToolExit(
        'None of the requested --only test(s) were found in the manifest.\n'
        'Available tests:\n${tests.map((t) => '  ${t.dartName}').join('\n')}',
      );

packages/patrol_cli/lib/src/crossplatform/test_manifest_generator.dart:92

  • An empty manifest is treated as a successful discovery, but Android codegen then emits a class with zero @Test methods and execution is restricted to that class. JUnit reports No runnable methods, so the build succeeds but the resulting test artifact is unusable. Reject an empty discovery before codegen, or generate an explicitly ignored placeholder test.
    final tests = manifest.tests;
    if (tests.isEmpty) {
      _logger.info('No Dart tests discovered.');
      return;

packages/patrol_cli/lib/src/crossplatform/app_options.dart:90

  • The discovery-only defines are emitted before caller-provided dartDefines and --dart-define-from-file values. A project that already defines either reserved key can therefore override PATROL_TEST_DISCOVERY=true or redirect PATROL_MANIFEST_OUTPUT, causing discovery to run normally or report a missing manifest. Emit these two internal defines last so they are authoritative.
      ...['--dart-define', 'PATROL_TEST_DISCOVERY=true'],
      ...['--dart-define', 'PATROL_MANIFEST_OUTPUT=$manifestOutputPath'],
      for (final dartDefine in dartDefines.entries) ...[
        '--dart-define',
        '${dartDefine.key}=${dartDefine.value}',
      ],
      for (final dartDefineFromFilePath in dartDefineFromFilePaths) ...[
        '--dart-define-from-file',
        dartDefineFromFilePath,

packages/patrol_cli/lib/src/crossplatform/test_manifest_generator.dart:44

  • This single global manifest is later used to map --only names to methods in already-built platform artifacts, but every iOS or Android discovery overwrites it. Building Android after iOS (or rebuilding with different targets/defines) can therefore make a later iOS --no-build --only select methods from the Android manifest, and vice versa. Non-manifest builds also leave this file stale. Persist/load a manifest keyed to the platform and concrete build artifact (or record and validate build identity) so selectors cannot be resolved against an unrelated binary.
    final manifestFile = _rootDirectory
        .childDirectory('build')
        .childDirectory('patrol')
        .childFile('patrol_test_manifest.json');

packages/patrol/lib/src/common.dart:117

  • This early return bypasses the existing validation that rejects supplying both nativeAutomatorConfig and platformAutomatorConfig. Discovery therefore succeeds for a test bundle that will throw during normal on-device registration, producing static tests that cannot execute. Keep argument validation before the discovery branch so build-time and runtime registration enforce the same contract.
  if (constants.testDiscoveryEnabled) {
    // Build-time discovery (host `flutter test`): only register the test so it
    // shows up in the group tree. Do NOT initialize PatrolBinding (Live binding,
    // incompatible with the host automated binding) and do NOT run the body
    // (it would block on waitForExecutionRequest()).

packages/patrol_cli/lib/src/android/android_test_backend.dart:505

  • This new execution path has no automated coverage, although AndroidTestBackend is unit-tested elsewhere. It contains artifact selection, manifest-to-method mapping, instrumentation parsing, process failure detection, and two APK installs; regressions here can silently run the wrong variant or report failed instrumentation as successful. Add backend tests for all/selected tests, missing artifacts, flavored APK resolution, custom instrumentation, and failing am instrument output.
  Future<void> executeWithoutBuilding(

@piotruela
piotruela marked this pull request as ready for review July 28, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Concerns doc comments, READMEs, or docs package: patrol_cli Related to the patrol_cli package package: patrol Related to the patrol package (native automation, test bundling)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants