From cb4be9835a1edfeab5263156ab1397d9071c41b1 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 17 Aug 2026 11:26:22 -0400 Subject: [PATCH] feat(e2e): enforce platform capability tags in local runs --- e2e/scripts/run_maestro | 33 +++++++++++ e2e/test/run_maestro_test.rb | 109 +++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 e2e/test/run_maestro_test.rb diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro index 5760d43c6..9e34eacae 100755 --- a/e2e/scripts/run_maestro +++ b/e2e/scripts/run_maestro @@ -29,6 +29,39 @@ MAESTRO="$("$E2E_ROOT/scripts/maestro_bin")" # than passed. e2e/lib/e2e_matrix_to_browserstack_run_plan.rb derives it the same way for CI. CONTROL_LINK="${APP_ID}://e2e" +# Remove excluded tags from includes so Maestro receives unambiguous filters. +exclude_tag() { + local tag="$1" + + INCLUDE_TAGS="$(printf '%s' "$INCLUDE_TAGS" | tr ',' '\n' | { grep -vx "$tag" || true; } | paste -sd, -)" + + case ",${EXCLUDE_TAGS}," in + *",${tag},"*) ;; + *) EXCLUDE_TAGS="${EXCLUDE_TAGS:+${EXCLUDE_TAGS},}${tag}" ;; + esac +} + +REQUESTED_TAGS="$INCLUDE_TAGS" + +# Every local target executes the shared tests folder. A platform capability tag marks +# a test that cannot run on the other platform, so derive the mandatory exclusion from +# the platform instead of repeating it in four wrappers. +case "$PLATFORM" in + ios) exclude_tag "android-only" ;; + android) exclude_tag "ios-only" ;; + *) + echo "run_maestro: platform must be ios or android, got '${PLATFORM}'" >&2 + exit 1 + ;; +esac + +# An empty include list means "run everything", so a caller whose requested tags were +# all excluded must run nothing instead of falling through to the whole suite. +if [ -n "$REQUESTED_TAGS" ] && [ -z "$INCLUDE_TAGS" ]; then + echo "run_maestro: every requested tag is excluded on this run, so nothing runs" >&2 + exit 0 +fi + MAESTRO_ARGS=(--platform "$PLATFORM" test --config config.yaml) TEST_DIRECTORIES=(tests/shared) TEST_FILES=() diff --git a/e2e/test/run_maestro_test.rb b/e2e/test/run_maestro_test.rb new file mode 100644 index 000000000..32f437a66 --- /dev/null +++ b/e2e/test/run_maestro_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "fileutils" +require "minitest/autorun" +require "open3" +require "tmpdir" + +class RunMaestroTest < Minitest::Test + E2E_ROOT = File.expand_path("..", __dir__) + SCRIPT = File.join(E2E_ROOT, "scripts", "run_maestro") + MAESTRO_VERSION = File.read(File.join(E2E_ROOT, ".maestro-version")).strip + + # run_maestro resolves the pinned Maestro binary through maestro_bin. Put the stub + # at that expected path so the test records the invocation without driving a device. + # + # run_maestro takes six positional arguments and the last one, the test namespace, is + # mandatory. These cases only exercise tag filtering, so the helper pads the optional + # tag arguments and supplies a namespace rather than repeating both at every call. + DEFAULT_TEST_NAMESPACE = "swift" + + def run_script(*args) + args = args.dup + args << "" while args.length < 5 + args << DEFAULT_TEST_NAMESPACE while args.length < 6 + + Dir.mktmpdir do |dir| + args_file = File.join(dir, "maestro-args") + maestro = File.join(dir, MAESTRO_VERSION, "bin", "maestro") + FileUtils.mkdir_p(File.dirname(maestro)) + File.write(maestro, <<~SH) + #!/usr/bin/env bash + printf '%s\n' "$@" > "$MAESTRO_ARGS_FILE" + SH + FileUtils.chmod(0o755, maestro) + + env = { + "MAESTRO_VERSIONS_ROOT" => dir, + "MAESTRO_ARGS_FILE" => args_file, + "E2E_CUSTOMER_ACCOUNT_EMAIL" => "maestro@example.com", + "E2E_CUSTOMER_ACCOUNT_CODE" => "000000" + } + stdout, stderr, status = Open3.capture3(env, SCRIPT, *args) + + { + stdout: stdout, + stderr: stderr, + status: status, + maestro_args: File.exist?(args_file) ? File.readlines(args_file, chomp: true) : nil + } + end + end + + def flag_value(args, flag) + index = args.index(flag) + index && args.fetch(index + 1) + end + + def test_ios_excludes_android_only + result = run_script("ios", "app.id", "ready") + + assert_predicate result.fetch(:status), :success? + assert_equal "android-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + end + + def test_android_excludes_ios_only + result = run_script("android", "app.id", "ready") + + assert_predicate result.fetch(:status), :success? + assert_equal "ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + end + + def test_caller_exclusions_are_preserved + result = run_script("ios", "app.id", "ready", "", "slow") + + assert_equal "slow,android-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + end + + def test_mandatory_exclusion_is_not_duplicated + result = run_script("android", "app.id", "ready", "", "ios-only") + + assert_equal "ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + end + + def test_incompatible_tag_leaves_compatible_requested_tags + result = run_script("android", "app.id", "ready", "ios-only,checkout") + + assert_predicate result.fetch(:status), :success? + assert_equal "checkout", flag_value(result.fetch(:maestro_args), "--include-tags") + assert_equal "ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + end + + # Maestro treats an empty include list as "run everything". If the caller explicitly + # requests only a capability this platform excludes, the runner must no-op instead. + def test_incompatible_only_request_runs_nothing + result = run_script("android", "app.id", "ready", "ios-only") + + assert_predicate result.fetch(:status), :success? + assert_nil result.fetch(:maestro_args) + assert_includes result.fetch(:stderr), "nothing runs" + end + + def test_unknown_platform_fails_before_maestro + result = run_script("windows", "app.id", "ready") + + refute_predicate result.fetch(:status), :success? + assert_nil result.fetch(:maestro_args) + assert_includes result.fetch(:stderr), "platform must be ios or android" + end +end