fastlane-plugin-developer_device_platform 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a99f79e05b0a9a125921c472bd1edac41dc27c87dac082aeef067c99a84586d3
4
+ data.tar.gz: 7e1751408cf3527c2b1ab83d8f66680137904a0acee6d82b4946c37b9b4a9ac1
5
+ SHA512:
6
+ metadata.gz: d8e4599ff167590dc18b132e314e79e7ad7f9d4333f94c86999655e440898c89ceac4494185d780cc5763ad44e37f589ba6a12a821f452a7eaed05a1235f12a8
7
+ data.tar.gz: dacea5f0bc9b4b5596fa33bb75b799a525a355d3636f704a9733d97abed96fb3e7494ca3c552b65ec6773ba074668d9572c06fc1ac9166081e9f153bc5ec7ea2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ntsk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # fastlane-plugin-developer_device_platform
2
+
3
+ [![fastlane Plugin Badge](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-developer_device_platform)
4
+ [![Test](https://github.com/ntsk/fastlane-plugin-developer_device_platform/actions/workflows/test.yml/badge.svg)](https://github.com/ntsk/fastlane-plugin-developer_device_platform/actions/workflows/test.yml)
5
+
6
+ Run Android instrumentation tests on [Google Cloud Developer Device Platform](https://docs.cloud.google.com/developer-device-platform) (Device Run) from fastlane.
7
+
8
+ Firebase Test Lab is [deprecated and shuts down on September 30, 2027](https://firebase.google.com/docs/test-lab/migrate-faq). Developer Device Platform is its successor on Google Cloud. This plugin is a drop-in replacement for [fastlane-plugin-firebase_test_lab_android](https://github.com/cats-oss/fastlane-plugin-firebase_test_lab_android) built on `gcloud beta device-run`.
9
+
10
+ ## What the action does
11
+
12
+ 1. Uploads the app and test APKs to Cloud Storage and submits a Device Run session (one job per device).
13
+ 2. Waits for the session to complete, streaming gcloud's progress to the fastlane log.
14
+ 3. Reads the full session report, prints a per-job result table and the Cloud Console link.
15
+ 4. Optionally downloads the artifacts (`junit.xml`, `logcat.txt`, `video.mp4`, ...) to a local directory.
16
+ 5. Fails the lane unless the session result is `PASSED` (configurable).
17
+
18
+ ## Prerequisites
19
+
20
+ - [Google Cloud CLI](https://cloud.google.com/sdk/docs/install) on the machine running fastlane (`gcloud components update` if it predates Device Run).
21
+ - A Google Cloud project with billing enabled and the APIs turned on:
22
+
23
+ ```sh
24
+ gcloud services enable devicerun.googleapis.com devicestreaming.googleapis.com testing.googleapis.com
25
+ ```
26
+
27
+ - The account running the tests needs `roles/devicerun.admin` and `roles/serviceusage.serviceUsageConsumer` on the project (see the [quickstart](https://docs.cloud.google.com/developer-device-platform/quickstart)).
28
+ - Authentication for gcloud. Either log in interactively (`gcloud auth login`) or pass a service account key with `service_account_key_file` (see [Authentication](#authentication)).
29
+
30
+ ## Installation
31
+
32
+ ```sh
33
+ fastlane add_plugin developer_device_platform
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```ruby
39
+ lane :instrumentation_tests do
40
+ gradle(task: "assembleDebug assembleDebugAndroidTest")
41
+
42
+ developer_device_platform(
43
+ project_id: "my-gcp-project",
44
+ devices: ["shiba-34", "tokay-36"],
45
+ apps: ["app/build/outputs/apk/debug/app-debug.apk"],
46
+ test_apk: "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
47
+ orchestrator_version: "auto",
48
+ instrumentation_timeout: "15m",
49
+ video: "on-failure",
50
+ download_dir: "build/device-run"
51
+ )
52
+ end
53
+ ```
54
+
55
+ Find device IDs with `gcloud beta device-run devices list` (for example `--filter="HARDWARE_TYPE=PHYSICAL AND OS_VERSION=35"`). An ID is `<model>-<api level>`, such as `shiba-34`, or a descriptive name for virtual devices, such as `mediumphone-arm-34`.
56
+
57
+ Inputs may also be `gs://` URIs, in which case nothing is uploaded:
58
+
59
+ ```ruby
60
+ developer_device_platform(
61
+ devices: ["mediumphone-arm-34"],
62
+ apps: ["gs://my-bucket/app-debug.apk"],
63
+ test_apk: "gs://my-bucket/app-debug-androidTest.apk",
64
+ sharding_option: "smart",
65
+ smart_sharding_target_duration: "5m",
66
+ smart_sharding_record_name: "nightly",
67
+ flaky_test_attempts: 2,
68
+ flaky_test_retry_level: "test"
69
+ )
70
+ ```
71
+
72
+ ### Options
73
+
74
+ Options mirror the flags of `gcloud beta device-run sessions submit instrumentation`. Every option can also be set through the environment variable `DEVELOPER_DEVICE_PLATFORM_<OPTION>`.
75
+
76
+ | Option | Type | Description |
77
+ | --- | --- | --- |
78
+ | `devices` | Array | **Required.** Device type IDs. One job is created per device. |
79
+ | `test_apk` | String | **Required.** Instrumentation test APK, local path or `gs://` URI. |
80
+ | `apps` | Array | App files (`.apk`, `.aab`, `.apks`) installed before the test, in order. |
81
+ | `project_id` | String | Google Cloud project. Defaults to the active gcloud configuration. |
82
+ | `service_account_key_file` | String | Service account key JSON used to authenticate gcloud for this action only. |
83
+ | `gcloud_binary` | String | Path to `gcloud`. Default `gcloud`. |
84
+ | `location` | String | Cloud location of the session. Default `global`. |
85
+ | `bucket_name` | String | Bucket for inputs and results. Defaults to `PROJECT_ID-devicerun`, created on first use. |
86
+ | `test_runner_class` | String | Instrumentation runner class. Defaults to the manifest value. |
87
+ | `test_targets` | Array | Targets such as `"class com.example.FooTest"` or `"notPackage com.example.slow"`. |
88
+ | `additional_test_options` | Hash | Options passed to the runner, e.g. `{ clearPackageData: "true" }`. |
89
+ | `other_files_to_push` | Hash | Files pushed before the test, `{ "local/or/gs/path" => "/sdcard/dest" }`. |
90
+ | `paths_to_pull` | Array | Device paths pulled after the test. |
91
+ | `instrumentation_timeout` | String | `1m` to `3h`. Default `5m`. |
92
+ | `orchestrator_version` | String | Android Test Orchestrator version or `auto`. Unset means no orchestrator. |
93
+ | `labels` | Hash | Labels attached to the session. |
94
+ | `locale` | String | Device locale, e.g. `ja-JP`. |
95
+ | `orientation` | String | `portrait` or `landscape`. |
96
+ | `coordinates` | String | Mock GPS location `latitude,longitude`. |
97
+ | `video` | String | `always` or `on-failure`. |
98
+ | `dumpsys` | String | `always` or `on-failure`. |
99
+ | `bugreport` | String | `always` or `on-failure`. |
100
+ | `sharding_option` | String | `uniform` or `smart`. |
101
+ | `uniform_sharding_count` | Integer | Shard count. Required with `uniform`. |
102
+ | `smart_sharding_target_duration` | String | Target duration per shard, `2m` to `1h`. Required with `smart`. |
103
+ | `smart_sharding_record_name` | String | Timing record name without extension. Required with `smart`. |
104
+ | `smart_sharding_max_shard_count` | Integer | Maximum smart shards. `0` uses the system limit. |
105
+ | `flaky_test_attempts` | Integer | Attempts per shard. Default `1`. |
106
+ | `flaky_test_parallel_retry` | Boolean | Retry in parallel instead of sequentially. |
107
+ | `flaky_test_retry_level` | String | `shard` or `test`. |
108
+ | `async` | Boolean | Return the session ID right after submission. Default `false`. |
109
+ | `download_dir` | String | Local directory to download the session artifacts into. |
110
+ | `fail_on_failure` | Boolean | Fail the lane when the result is not `PASSED`. Default `true`. |
111
+
112
+ ### Return value and lane context
113
+
114
+ The action returns a `Fastlane::DeveloperDevicePlatform::Session` (or the session ID when `async` is true) and sets:
115
+
116
+ | Lane context key | Value |
117
+ | --- | --- |
118
+ | `DEVELOPER_DEVICE_PLATFORM_SESSION_ID` | Session ID |
119
+ | `DEVELOPER_DEVICE_PLATFORM_RESULT` | `PASSED`, `FAILED`, `ERROR`, `TIMED_OUT` or `CANCELLED` |
120
+ | `DEVELOPER_DEVICE_PLATFORM_OUTPUT_DIRECTORY` | `gs://BUCKET/automation/sessions/SESSION_ID` |
121
+ | `DEVELOPER_DEVICE_PLATFORM_CONSOLE_URL` | Cloud Console link to the artifacts |
122
+ | `DEVELOPER_DEVICE_PLATFORM_DOWNLOAD_DIR` | Local artifact directory when `download_dir` is set |
123
+
124
+ ```ruby
125
+ session = developer_device_platform(devices: ["shiba-34"], test_apk: "test.apk", fail_on_failure: false)
126
+ session.jobs.each { |job| puts "#{job.display_name}: #{job.result} #{job.message}" }
127
+ ```
128
+
129
+ ### Results
130
+
131
+ Artifacts land in `gs://BUCKET/automation/sessions/SESSION_ID/job-NNN/`: `merged_junit.xml` per job, and `junit.xml`, `instrumentation.log`, `logcat.txt`, `video.mp4` and pulled files per execution. With `download_dir` the same tree is copied to `<download_dir>/<SESSION_ID>/`, so JUnit reporters can pick up `**/merged_junit.xml`.
132
+
133
+ ### Authentication
134
+
135
+ On CI, the simplest setup is a service account key passed through `service_account_key_file`. The plugin hands it to gcloud via `CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE`, so the global gcloud configuration is left untouched. If your CI already authenticates gcloud (for example with Workload Identity Federation through `google-github-actions/auth`), omit the option and the active credentials are used.
136
+
137
+ Device Run reads inputs and writes results through its own service agent (`service-PROJECT_NUMBER@gcp-sa-devicerun.iam.gserviceaccount.com`). When you point `bucket_name` at a bucket in another project, grant that agent `roles/storage.objectViewer` on it (see [Share storage](https://docs.cloud.google.com/developer-device-platform/device-run/share-storage)).
138
+
139
+ ## Migrating from fastlane-plugin-firebase_test_lab_android
140
+
141
+ | firebase_test_lab_android | developer_device_platform |
142
+ | --- | --- |
143
+ | `project_id` | `project_id` |
144
+ | `gcloud_service_key_file` | `service_account_key_file` |
145
+ | `type: "instrumentation"` | Only instrumentation tests are supported by Device Run. Robo tests have no equivalent. |
146
+ | `devices: [{ model:, version:, locale:, orientation: }]` | `devices: ["<model>-<version>"]`, plus `locale` and `orientation` applied to every device |
147
+ | `app_apk` / `app_test_apk` | `apps: [app_apk]` / `test_apk` |
148
+ | `use_orchestrator: true` | `orchestrator_version: "auto"` |
149
+ | `timeout` | `instrumentation_timeout` |
150
+ | `firebase_test_lab_results_bucket` | `bucket_name` |
151
+ | `firebase_test_lab_results_dir` | Not supported. Results are grouped by session ID. |
152
+ | `download_dir` | `download_dir` |
153
+ | `extra_options` | The corresponding typed option (`test_targets`, `additional_test_options`, `video`, ...) |
154
+ | `slack_url`, `github_*` | Not included. Use `slack` or GitHub actions with the lane context values above. |
155
+
156
+ ## Development
157
+
158
+ ```sh
159
+ bundle install
160
+ bundle exec rake # rspec + rubocop
161
+ bundle exec fastlane action developer_device_platform
162
+ ```
163
+
164
+ The gcloud integration is exercised against a fake `gcloud` executable in `spec/fixtures`, so the test suite needs neither credentials nor network access.
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,161 @@
1
+ require 'fastlane/action'
2
+ require_relative '../helper/device_run_client'
3
+ require_relative '../helper/gcloud'
4
+ require_relative '../helper/instrumentation_command'
5
+ require_relative '../helper/options'
6
+ require_relative '../helper/session_summary'
7
+
8
+ module Fastlane
9
+ module Actions
10
+ module SharedValues
11
+ DEVELOPER_DEVICE_PLATFORM_SESSION_ID = :DEVELOPER_DEVICE_PLATFORM_SESSION_ID
12
+ DEVELOPER_DEVICE_PLATFORM_RESULT = :DEVELOPER_DEVICE_PLATFORM_RESULT
13
+ DEVELOPER_DEVICE_PLATFORM_OUTPUT_DIRECTORY = :DEVELOPER_DEVICE_PLATFORM_OUTPUT_DIRECTORY
14
+ DEVELOPER_DEVICE_PLATFORM_CONSOLE_URL = :DEVELOPER_DEVICE_PLATFORM_CONSOLE_URL
15
+ DEVELOPER_DEVICE_PLATFORM_DOWNLOAD_DIR = :DEVELOPER_DEVICE_PLATFORM_DOWNLOAD_DIR
16
+ end
17
+
18
+ class DeveloperDevicePlatformAction < Action
19
+ COMMAND_OPTIONS = DeveloperDevicePlatform::InstrumentationCommand::FLAGS.keys
20
+ RECOVERABLE_ERRORS = [
21
+ DeveloperDevicePlatform::Gcloud::CommandError,
22
+ DeveloperDevicePlatform::DeviceRunClient::Error,
23
+ DeveloperDevicePlatform::Session::ParseError
24
+ ].freeze
25
+
26
+ def self.run(params)
27
+ client = DeveloperDevicePlatform::DeviceRunClient.new(gcloud: build_gcloud(params))
28
+ location = params[:location]
29
+
30
+ UI.message("Submitting instrumentation session to Developer Device Platform...")
31
+ session_id = client.submit(build_command(params)) { |line| UI.message(line) }
32
+ lane_context[SharedValues::DEVELOPER_DEVICE_PLATFORM_SESSION_ID] = session_id
33
+ UI.success("Created Device Run session #{session_id}")
34
+ return session_id if params[:async]
35
+
36
+ UI.message("Waiting for session #{session_id} to complete...")
37
+ client.wait(session_id, location: location) { |line| UI.message(line) }
38
+ session = client.describe(session_id, location: location)
39
+ publish(session)
40
+ download(client, session, params[:download_dir])
41
+ UI.message(DeveloperDevicePlatform::SessionSummary.new(session).to_s)
42
+ conclude(session, fail_on_failure: params[:fail_on_failure])
43
+ rescue *RECOVERABLE_ERRORS => e
44
+ UI.user_error!(e.message)
45
+ end
46
+
47
+ def self.build_gcloud(params)
48
+ DeveloperDevicePlatform::Gcloud.new(
49
+ binary: params[:gcloud_binary],
50
+ project: params[:project_id],
51
+ credential_file: params[:service_account_key_file]
52
+ )
53
+ end
54
+
55
+ def self.build_command(params)
56
+ DeveloperDevicePlatform::InstrumentationCommand.new(**COMMAND_OPTIONS.to_h { |key| [key, params[key]] })
57
+ end
58
+
59
+ def self.publish(session)
60
+ lane_context[SharedValues::DEVELOPER_DEVICE_PLATFORM_RESULT] = session.result
61
+ lane_context[SharedValues::DEVELOPER_DEVICE_PLATFORM_OUTPUT_DIRECTORY] = session.output_directory
62
+ lane_context[SharedValues::DEVELOPER_DEVICE_PLATFORM_CONSOLE_URL] = session.console_url
63
+ end
64
+
65
+ def self.download(client, session, destination)
66
+ return if destination.nil?
67
+
68
+ UI.message("Downloading results of session #{session.id} to #{destination}...")
69
+ local_path = client.download(session, destination) { |line| UI.verbose(line) }
70
+ lane_context[SharedValues::DEVELOPER_DEVICE_PLATFORM_DOWNLOAD_DIR] = local_path
71
+ UI.success("Results downloaded to #{local_path}")
72
+ end
73
+
74
+ def self.conclude(session, fail_on_failure:)
75
+ outcome = "Device Run session #{session.id} finished with result #{session.result}"
76
+ if session.passed?
77
+ UI.success(outcome)
78
+ elsif fail_on_failure
79
+ UI.test_failure!(outcome)
80
+ else
81
+ UI.important(outcome)
82
+ end
83
+ session
84
+ end
85
+
86
+ def self.description
87
+ "Run Android instrumentation tests on Google Cloud Developer Device Platform (Device Run)"
88
+ end
89
+
90
+ def self.details
91
+ [
92
+ "Uploads your app and test APKs to Google Cloud Storage, submits an instrumentation session to Device Run",
93
+ "(the successor of Firebase Test Lab), waits for it to finish and reports the per-job results.",
94
+ "Requires the Google Cloud CLI (`gcloud`) with the Device Run API (devicerun.googleapis.com) enabled on the project.",
95
+ "Options mirror the flags of `gcloud beta device-run sessions submit instrumentation`."
96
+ ].join(" ")
97
+ end
98
+
99
+ def self.authors
100
+ ["ntsk"]
101
+ end
102
+
103
+ def self.return_value
104
+ "The DeveloperDevicePlatform::Session describing the finished session, or the session id when `async` is true"
105
+ end
106
+
107
+ def self.output
108
+ [
109
+ ["DEVELOPER_DEVICE_PLATFORM_SESSION_ID", "ID of the created Device Run session"],
110
+ ["DEVELOPER_DEVICE_PLATFORM_RESULT", "Overall result (PASSED, FAILED, ERROR, TIMED_OUT, CANCELLED)"],
111
+ ["DEVELOPER_DEVICE_PLATFORM_OUTPUT_DIRECTORY", "Cloud Storage directory (gs://...) holding the session artifacts"],
112
+ ["DEVELOPER_DEVICE_PLATFORM_CONSOLE_URL", "Cloud Console URL of the session artifacts"],
113
+ ["DEVELOPER_DEVICE_PLATFORM_DOWNLOAD_DIR", "Local directory the artifacts were downloaded to, when download_dir is set"]
114
+ ]
115
+ end
116
+
117
+ def self.example_code
118
+ [
119
+ <<~EXAMPLE,
120
+ developer_device_platform(
121
+ project_id: "my-gcp-project",
122
+ devices: ["shiba-34", "tokay-36"],
123
+ apps: ["app/build/outputs/apk/debug/app-debug.apk"],
124
+ test_apk: "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
125
+ orchestrator_version: "auto",
126
+ instrumentation_timeout: "15m",
127
+ video: "on-failure",
128
+ download_dir: "build/device-run"
129
+ )
130
+ EXAMPLE
131
+ <<~EXAMPLE
132
+ developer_device_platform(
133
+ devices: ["mediumphone-arm-34"],
134
+ test_apk: "gs://my-bucket/app-debug-androidTest.apk",
135
+ apps: ["gs://my-bucket/app-debug.apk"],
136
+ sharding_option: "smart",
137
+ smart_sharding_target_duration: "5m",
138
+ smart_sharding_record_name: "nightly",
139
+ flaky_test_attempts: 2,
140
+ flaky_test_retry_level: "test"
141
+ )
142
+ EXAMPLE
143
+ ]
144
+ end
145
+
146
+ def self.category
147
+ :testing
148
+ end
149
+
150
+ def self.is_supported?(platform)
151
+ platform == :android
152
+ end
153
+
154
+ def self.available_options
155
+ DeveloperDevicePlatform::Options.all
156
+ end
157
+
158
+ private_class_method :build_gcloud, :build_command, :publish, :download, :conclude
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,50 @@
1
+ require 'fileutils'
2
+ require_relative 'gcloud'
3
+ require_relative 'session'
4
+
5
+ module Fastlane
6
+ module DeveloperDevicePlatform
7
+ class DeviceRunClient
8
+ class Error < StandardError; end
9
+
10
+ SESSION_ANNOUNCEMENT = /Creating session \[([^\]]+)\]/
11
+
12
+ attr_reader :gcloud
13
+
14
+ def initialize(gcloud:)
15
+ @gcloud = gcloud
16
+ end
17
+
18
+ def submit(command, &progress)
19
+ output = device_run("sessions", "submit", "instrumentation", *command.arguments, "--async", &progress)
20
+ output.stderr[SESSION_ANNOUNCEMENT, 1] ||
21
+ raise(Error, "Could not find the session id in gcloud output:\n#{output.stderr}")
22
+ end
23
+
24
+ def wait(session_id, location:, &progress)
25
+ device_run("sessions", "wait", session_id, "--location=#{location}", &progress)
26
+ nil
27
+ end
28
+
29
+ def describe(session_id, location:)
30
+ output = device_run("sessions", "describe", session_id, "--location=#{location}", "--full", "--format=json")
31
+ Session.parse(output.stdout)
32
+ end
33
+
34
+ def download(session, destination, &progress)
35
+ source = session.output_directory
36
+ raise Error, "Session #{session.id} has no output directory to download" if source.nil?
37
+
38
+ FileUtils.mkdir_p(destination)
39
+ gcloud.run("storage", "cp", "--recursive", source, destination, &progress)
40
+ File.join(destination, session.id)
41
+ end
42
+
43
+ private
44
+
45
+ def device_run(*args, &progress)
46
+ gcloud.run("beta", "device-run", *args, &progress)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,61 @@
1
+ require 'open3'
2
+
3
+ module Fastlane
4
+ module DeveloperDevicePlatform
5
+ class Gcloud
6
+ Output = Struct.new(:stdout, :stderr, keyword_init: true)
7
+
8
+ class CommandError < StandardError
9
+ attr_reader :command, :stderr, :exit_status
10
+
11
+ def initialize(command, stderr, exit_status)
12
+ @command = command
13
+ @stderr = stderr
14
+ @exit_status = exit_status
15
+ super("gcloud command failed#{" (exit #{exit_status})" if exit_status}: #{command.join(' ')}\n#{stderr}".rstrip)
16
+ end
17
+ end
18
+
19
+ attr_reader :binary, :project, :credential_file
20
+
21
+ def initialize(binary: "gcloud", project: nil, credential_file: nil)
22
+ @binary = binary
23
+ @project = project
24
+ @credential_file = credential_file
25
+ end
26
+
27
+ def run(*args, &on_stderr_line)
28
+ command = [binary, *args]
29
+ stdout, stderr, status = execute(command, &on_stderr_line)
30
+ raise CommandError.new(command, stderr, status.exitstatus) unless status.success?
31
+
32
+ Output.new(stdout:, stderr:)
33
+ rescue Errno::ENOENT => e
34
+ raise CommandError.new(command, e.message, nil)
35
+ end
36
+
37
+ def environment
38
+ {
39
+ "CLOUDSDK_CORE_DISABLE_PROMPTS" => "1",
40
+ "CLOUDSDK_CORE_PROJECT" => project,
41
+ "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE" => credential_file
42
+ }.compact
43
+ end
44
+
45
+ private
46
+
47
+ def execute(command)
48
+ Open3.popen3(environment, *command) do |stdin, stdout, stderr, waiter|
49
+ stdin.close
50
+ stdout_reader = Thread.new { stdout.read }
51
+ stderr_text = +""
52
+ stderr.each_line do |line|
53
+ stderr_text << line
54
+ yield(line.chomp) if block_given?
55
+ end
56
+ [stdout_reader.value, stderr_text, waiter.value]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,90 @@
1
+ module Fastlane
2
+ module DeveloperDevicePlatform
3
+ class InstrumentationCommand
4
+ module Flags
5
+ ALTERNATE_DELIMITER = "|".freeze
6
+
7
+ module_function
8
+
9
+ def value(flag, value)
10
+ value.nil? ? [] : ["--#{flag}=#{value}"]
11
+ end
12
+
13
+ def list(flag, values)
14
+ values = Array(values).compact
15
+ values.empty? ? [] : ["--#{flag}=#{join(values)}"]
16
+ end
17
+
18
+ def repeated(flag, values)
19
+ Array(values).compact.map { |item| "--#{flag}=#{join([item])}" }
20
+ end
21
+
22
+ def dict(flag, pairs)
23
+ (pairs || {}).map { |key, value| "--#{flag}=#{join(["#{key}=#{value}"])}" }
24
+ end
25
+
26
+ def boolean(flag, value)
27
+ case value
28
+ when true then ["--#{flag}"]
29
+ when false then ["--no-#{flag}"]
30
+ else []
31
+ end
32
+ end
33
+
34
+ def join(items)
35
+ items = items.map(&:to_s)
36
+ return items.join(",") if items.none? { |item| item.include?(",") }
37
+
38
+ "^#{ALTERNATE_DELIMITER}^#{items.join(ALTERNATE_DELIMITER)}"
39
+ end
40
+ end
41
+
42
+ FLAGS = {
43
+ devices: ["device", :list],
44
+ test_apk: ["test", :value],
45
+ apps: ["apps", :list],
46
+ location: ["location", :value],
47
+ bucket_name: ["bucket-name", :value],
48
+ test_runner_class: ["test-runner-class", :value],
49
+ test_targets: ["test-targets", :repeated],
50
+ additional_test_options: ["additional-test-options", :dict],
51
+ other_files_to_push: ["other-files-to-push", :dict],
52
+ paths_to_pull: ["paths-to-pull", :list],
53
+ instrumentation_timeout: ["instrumentation-timeout", :value],
54
+ orchestrator_version: ["orchestrator-version", :value],
55
+ locale: ["locale", :value],
56
+ orientation: ["orientation", :value],
57
+ coordinates: ["coordinates", :value],
58
+ video: ["video", :value],
59
+ dumpsys: ["dumpsys", :value],
60
+ bugreport: ["bugreport", :value],
61
+ sharding_option: ["sharding-option", :value],
62
+ uniform_sharding_count: ["uniform-sharding-count", :value],
63
+ smart_sharding_target_duration: ["smart-sharding-target-duration", :value],
64
+ smart_sharding_record_name: ["smart-sharding-record-name", :value],
65
+ smart_sharding_max_shard_count: ["smart-sharding-max-shard-count", :value],
66
+ flaky_test_attempts: ["flaky-test-attempts", :value],
67
+ flaky_test_parallel_retry: ["flaky-test-parallel-retry", :boolean],
68
+ flaky_test_retry_level: ["flaky-test-retry-level", :value],
69
+ labels: ["labels", :dict]
70
+ }.freeze
71
+
72
+ attr_reader :options
73
+
74
+ def initialize(devices:, test_apk:, **options)
75
+ unknown = options.keys - FLAGS.keys
76
+ raise ArgumentError, "unknown options: #{unknown.join(', ')}" unless unknown.empty?
77
+ raise ArgumentError, "devices must contain at least one device id" if Array(devices).compact.empty?
78
+ raise ArgumentError, "test_apk is required" if test_apk.to_s.empty?
79
+
80
+ @options = { devices: Array(devices), test_apk: test_apk, **options }.freeze
81
+ end
82
+
83
+ def arguments
84
+ FLAGS.flat_map do |key, (flag, kind)|
85
+ Flags.public_send(kind, flag, options[key])
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,122 @@
1
+ require 'fastlane/boolean'
2
+ require 'fastlane_core/configuration/config_item'
3
+ require 'fastlane_core/ui/ui'
4
+
5
+ module Fastlane
6
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
7
+
8
+ module DeveloperDevicePlatform
9
+ module Options
10
+ COLLECTION_MODES = ["always", "on-failure"].freeze
11
+ ORIENTATIONS = ["portrait", "landscape"].freeze
12
+ SHARDING_OPTIONS = ["uniform", "smart"].freeze
13
+ RETRY_LEVELS = ["shard", "test"].freeze
14
+
15
+ module_function
16
+
17
+ def all
18
+ execution_options + device_options + sharding_options + retry_options + behaviour_options
19
+ end
20
+
21
+ def execution_options
22
+ [
23
+ option(:gcloud_binary, "Path to the gcloud executable", default_value: "gcloud"),
24
+ option(:project_id, "Google Cloud project ID (defaults to the active gcloud configuration)"),
25
+ option(:service_account_key_file, "Path to a service account key JSON used to authenticate gcloud",
26
+ verify_block: file_exists("service_account_key_file")),
27
+ option(:location, "Cloud location of the session", default_value: "global"),
28
+ option(:devices, "Device type IDs to run on, e.g. [\"shiba-34\"]. One job is created per device",
29
+ type: Array, optional: false, verify_block: non_empty("devices")),
30
+ option(:test_apk, "Path to the instrumentation test APK (local or gs://)",
31
+ optional: false, verify_block: input_exists("test_apk")),
32
+ option(:apps, "Application files (.apk, .aab, .apks) to install before the test, in order (local or gs://)",
33
+ type: Array, verify_block: inputs_exist("apps")),
34
+ option(:bucket_name, "Cloud Storage bucket for inputs and results (defaults to PROJECT_ID-devicerun)"),
35
+ option(:test_runner_class, "Instrumentation test runner class (defaults to the one in the manifest)"),
36
+ option(:test_targets, "Test targets, e.g. [\"class com.example.FooTest\", \"notPackage com.example.slow\"]", type: Array),
37
+ option(:additional_test_options, "Key-value options passed to the instrumentation runner", type: Hash),
38
+ option(:other_files_to_push, "Files to push to the device before the test, as { source => destination }", type: Hash),
39
+ option(:paths_to_pull, "Device paths to pull after the test", type: Array),
40
+ option(:instrumentation_timeout, "Maximum duration of the test run, 1m to 3h (default 5m)"),
41
+ option(:orchestrator_version, "Android Test Orchestrator version, or 'auto' (unset means no orchestrator)"),
42
+ option(:labels, "Key-value labels attached to the session", type: Hash)
43
+ ]
44
+ end
45
+
46
+ def device_options
47
+ [
48
+ option(:locale, "Device locale in language-region form, e.g. ja-JP"),
49
+ enum_option(:orientation, "Device orientation", ORIENTATIONS),
50
+ option(:coordinates, "Mock GPS location as latitude,longitude"),
51
+ enum_option(:video, "When to record a video of the screen", COLLECTION_MODES),
52
+ enum_option(:dumpsys, "When to collect dumpsys output", COLLECTION_MODES),
53
+ enum_option(:bugreport, "When to collect a bugreport", COLLECTION_MODES)
54
+ ]
55
+ end
56
+
57
+ def sharding_options
58
+ [
59
+ enum_option(:sharding_option, "Sharding strategy", SHARDING_OPTIONS),
60
+ option(:uniform_sharding_count, "Number of uniform shards (required with sharding_option 'uniform')", type: Integer),
61
+ option(:smart_sharding_target_duration, "Target duration per shard, 2m to 1h (required with sharding_option 'smart')"),
62
+ option(:smart_sharding_record_name, "Timing record name without extension (required with sharding_option 'smart')"),
63
+ option(:smart_sharding_max_shard_count, "Maximum number of smart shards (0 uses the system limit)", type: Integer)
64
+ ]
65
+ end
66
+
67
+ def retry_options
68
+ [
69
+ option(:flaky_test_attempts, "Maximum execution attempts per shard (default 1)", type: Integer),
70
+ option(:flaky_test_parallel_retry, "Retry failures in parallel instead of sequentially", type: Boolean),
71
+ enum_option(:flaky_test_retry_level, "Retry at the shard or the test level", RETRY_LEVELS)
72
+ ]
73
+ end
74
+
75
+ def behaviour_options
76
+ [
77
+ option(:async, "Return the session id right after submission instead of waiting", type: Boolean, default_value: false),
78
+ option(:download_dir, "Local directory to download the session artifacts into"),
79
+ option(:fail_on_failure, "Fail the lane when the session result is not PASSED", type: Boolean, default_value: true)
80
+ ]
81
+ end
82
+
83
+ def option(key, description, type: String, optional: true, default_value: nil, verify_block: nil)
84
+ FastlaneCore::ConfigItem.new(
85
+ key: key,
86
+ env_name: "DEVELOPER_DEVICE_PLATFORM_#{key.to_s.upcase}",
87
+ description: description,
88
+ type: type,
89
+ optional: optional,
90
+ default_value: default_value,
91
+ verify_block: verify_block
92
+ )
93
+ end
94
+
95
+ def enum_option(key, description, values)
96
+ option(key, "#{description} (#{values.join(' | ')})", verify_block: proc do |value|
97
+ UI.user_error!("#{key} must be one of #{values.join(', ')}, got '#{value}'") unless values.include?(value)
98
+ end)
99
+ end
100
+
101
+ def non_empty(name)
102
+ proc { |value| UI.user_error!("#{name} must contain at least one entry") if value.empty? }
103
+ end
104
+
105
+ def file_exists(name)
106
+ proc { |path| UI.user_error!("Could not find #{name} at path '#{path}'") unless File.exist?(path) }
107
+ end
108
+
109
+ def input_exists(name)
110
+ proc { |path| UI.user_error!("Could not find #{name} at path '#{path}'") unless remote?(path) || File.exist?(path) }
111
+ end
112
+
113
+ def inputs_exist(name)
114
+ proc { |paths| paths.each { |path| input_exists(name).call(path) } }
115
+ end
116
+
117
+ def remote?(path)
118
+ path.start_with?("gs://")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,111 @@
1
+ require 'json'
2
+
3
+ module Fastlane
4
+ module DeveloperDevicePlatform
5
+ class Session
6
+ class ParseError < StandardError; end
7
+
8
+ CONSOLE_STORAGE_BROWSER = "https://console.cloud.google.com/storage/browser/".freeze
9
+
10
+ module Outcome
11
+ def done?
12
+ status == "DONE"
13
+ end
14
+
15
+ def passed?
16
+ result == "PASSED"
17
+ end
18
+ end
19
+
20
+ Execution = Struct.new(:display_name, :status, :result, :message, :output_files, keyword_init: true) do
21
+ include Outcome
22
+ end
23
+
24
+ Job = Struct.new(:display_name, :status, :result, :message, :output_files, :executions, keyword_init: true) do
25
+ include Outcome
26
+ end
27
+
28
+ def self.parse(json)
29
+ new(JSON.parse(json))
30
+ rescue JSON::ParserError => e
31
+ raise ParseError, "Could not parse session JSON: #{e.message}"
32
+ end
33
+
34
+ def initialize(attributes)
35
+ @attributes = attributes
36
+ end
37
+
38
+ def name
39
+ @attributes["name"]
40
+ end
41
+
42
+ def id
43
+ name&.split("/")&.last || report["id"]
44
+ end
45
+
46
+ def status
47
+ report.dig("status", "statusType")
48
+ end
49
+
50
+ def done?
51
+ status == "DONE"
52
+ end
53
+
54
+ def result
55
+ report.dig("result", "resultType")
56
+ end
57
+
58
+ def passed?
59
+ result == "PASSED"
60
+ end
61
+
62
+ def result_message
63
+ report.dig("result", "cause", "summary", "message")
64
+ end
65
+
66
+ def output_directory
67
+ base = @attributes.dig("sessionConfig", "outputDirectoryConfig", "gcsOutputDirectory", "path")
68
+ return nil if base.nil? || id.nil?
69
+
70
+ "#{base.chomp('/')}/#{id}"
71
+ end
72
+
73
+ def console_url
74
+ directory = output_directory
75
+ return nil if directory.nil?
76
+
77
+ "#{CONSOLE_STORAGE_BROWSER}#{directory.delete_prefix('gs://')}/"
78
+ end
79
+
80
+ def jobs
81
+ Array(report["jobReports"]).map do |job|
82
+ Job.new(**report_fields(job), executions: executions_of(job))
83
+ end
84
+ end
85
+
86
+ def output_files
87
+ jobs.flat_map { |job| job.output_files + job.executions.flat_map(&:output_files) }
88
+ end
89
+
90
+ private
91
+
92
+ def report
93
+ @attributes["sessionReport"] || {}
94
+ end
95
+
96
+ def executions_of(job)
97
+ Array(job["executionReports"]).map { |execution| Execution.new(**report_fields(execution)) }
98
+ end
99
+
100
+ def report_fields(node)
101
+ {
102
+ display_name: node["displayName"],
103
+ status: node.dig("status", "statusType"),
104
+ result: node.dig("result", "resultType"),
105
+ message: node.dig("result", "cause", "summary", "message") || Array(node.dig("status", "progressMessages")).last,
106
+ output_files: Array(node["outputFiles"]).filter_map { |file| file.dig("gcsOutputFile", "path") }
107
+ }
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,49 @@
1
+ require 'terminal-table'
2
+ require_relative 'session'
3
+
4
+ module Fastlane
5
+ module DeveloperDevicePlatform
6
+ class SessionSummary
7
+ HEADINGS = ["JOB", "EXECUTION", "RESULT"].freeze
8
+
9
+ attr_reader :session
10
+
11
+ def initialize(session)
12
+ @session = session
13
+ end
14
+
15
+ def rows
16
+ session.jobs.flat_map do |job|
17
+ if job.executions.empty?
18
+ [[job.display_name, "", describe(job)]]
19
+ else
20
+ job.executions.map { |execution| [job.display_name, execution.display_name, describe(execution)] }
21
+ end
22
+ end
23
+ end
24
+
25
+ def to_s
26
+ lines = [headline, session.result_message]
27
+ lines << Terminal::Table.new(headings: HEADINGS, rows: rows).to_s unless rows.empty?
28
+ lines << "Result files: #{session.console_url}" if session.console_url
29
+ lines.compact.join("\n")
30
+ end
31
+
32
+ private
33
+
34
+ def headline
35
+ if session.done?
36
+ "Session #{session.id} finished with result #{session.result}"
37
+ else
38
+ "Session #{session.id} is #{session.status}"
39
+ end
40
+ end
41
+
42
+ def describe(report)
43
+ return report.result if report.passed?
44
+
45
+ [report.result, report.message].compact.join(": ")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module DeveloperDevicePlatform
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ require 'fastlane/plugin/developer_device_platform/version'
2
+
3
+ module Fastlane
4
+ module DeveloperDevicePlatform
5
+ def self.all_classes
6
+ Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))]
7
+ end
8
+ end
9
+ end
10
+
11
+ Fastlane::DeveloperDevicePlatform.all_classes.each do |current|
12
+ require current
13
+ end
metadata ADDED
@@ -0,0 +1,221 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-developer_device_platform
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ntsk
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.4.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 5.0.0
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 2.4.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 5.0.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: fastlane
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 2.239.0
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.239.0
47
+ - !ruby/object:Gem::Dependency
48
+ name: pry
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.14'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.14'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rake
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rspec
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.12'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.12'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec_junit_formatter
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.6'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.6'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.50'
110
+ - - "<"
111
+ - !ruby/object:Gem::Version
112
+ version: '1.51'
113
+ type: :development
114
+ prerelease: false
115
+ version_requirements: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - "~>"
118
+ - !ruby/object:Gem::Version
119
+ version: '1.50'
120
+ - - "<"
121
+ - !ruby/object:Gem::Version
122
+ version: '1.51'
123
+ - !ruby/object:Gem::Dependency
124
+ name: rubocop-ast
125
+ requirement: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: 1.38.0
130
+ type: :development
131
+ prerelease: false
132
+ version_requirements: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: 1.38.0
137
+ - !ruby/object:Gem::Dependency
138
+ name: rubocop-performance
139
+ requirement: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '1.17'
144
+ type: :development
145
+ prerelease: false
146
+ version_requirements: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: '1.17'
151
+ - !ruby/object:Gem::Dependency
152
+ name: rubocop-require_tools
153
+ requirement: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: '0.1'
158
+ type: :development
159
+ prerelease: false
160
+ version_requirements: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - "~>"
163
+ - !ruby/object:Gem::Version
164
+ version: '0.1'
165
+ - !ruby/object:Gem::Dependency
166
+ name: simplecov
167
+ requirement: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - "~>"
170
+ - !ruby/object:Gem::Version
171
+ version: '0.22'
172
+ type: :development
173
+ prerelease: false
174
+ version_requirements: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - "~>"
177
+ - !ruby/object:Gem::Version
178
+ version: '0.22'
179
+ description:
180
+ email: contact@ntsk.jp
181
+ executables: []
182
+ extensions: []
183
+ extra_rdoc_files: []
184
+ files:
185
+ - LICENSE
186
+ - README.md
187
+ - lib/fastlane/plugin/developer_device_platform.rb
188
+ - lib/fastlane/plugin/developer_device_platform/actions/developer_device_platform_action.rb
189
+ - lib/fastlane/plugin/developer_device_platform/helper/device_run_client.rb
190
+ - lib/fastlane/plugin/developer_device_platform/helper/gcloud.rb
191
+ - lib/fastlane/plugin/developer_device_platform/helper/instrumentation_command.rb
192
+ - lib/fastlane/plugin/developer_device_platform/helper/options.rb
193
+ - lib/fastlane/plugin/developer_device_platform/helper/session.rb
194
+ - lib/fastlane/plugin/developer_device_platform/helper/session_summary.rb
195
+ - lib/fastlane/plugin/developer_device_platform/version.rb
196
+ homepage: https://github.com/ntsk/fastlane-plugin-developer_device_platform
197
+ licenses:
198
+ - MIT
199
+ metadata:
200
+ rubygems_mfa_required: 'true'
201
+ post_install_message:
202
+ rdoc_options: []
203
+ require_paths:
204
+ - lib
205
+ required_ruby_version: !ruby/object:Gem::Requirement
206
+ requirements:
207
+ - - ">="
208
+ - !ruby/object:Gem::Version
209
+ version: '3.1'
210
+ required_rubygems_version: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - ">="
213
+ - !ruby/object:Gem::Version
214
+ version: '0'
215
+ requirements: []
216
+ rubygems_version: 3.5.22
217
+ signing_key:
218
+ specification_version: 4
219
+ summary: Run Android instrumentation tests on Google Cloud Developer Device Platform
220
+ (Device Run)
221
+ test_files: []