diff --git a/.github/actions/on_device_tests/action.yaml b/.github/actions/on_device_tests/action.yaml index df6b4bf39d62..17cd7e79c8c3 100644 --- a/.github/actions/on_device_tests/action.yaml +++ b/.github/actions/on_device_tests/action.yaml @@ -1,9 +1,97 @@ -name: On Host Tests -description: Runs on-host tests. +name: On Device Test +description: Runs on-device tests. +inputs: + gcs_results_path: + description: "GCS path for the test results" + required: true + results_dir: + description: "Path to directory where test results are saved." + required: true + test_results_key: + description: "Artifact key used to store test results." + required: true + runs: using: "composite" steps: - - name: Run On-Device Tests + - name: Install Requirements + # TODO (b/388329764) - set up requirements file. + run: | + pip3 install grpcio==1.38.0 grpcio-tools==1.38.0 + shell: bash + - name: Generate gRPC files + run: | + python -m grpc_tools.protoc -I${GITHUB_WORKSPACE}/cobalt/tools/ --python_out=${GITHUB_WORKSPACE}/cobalt/tools/ --grpc_python_out=${GITHUB_WORKSPACE}/cobalt/tools/ ${GITHUB_WORKSPACE}/cobalt/tools/on_device_tests_gateway.proto + shell: bash + - name: Set Up Cloud SDK + uses: isarkis/setup-gcloud@40dce7857b354839efac498d3632050f568090b6 # v1.1.1 + - name: Set env vars + run: | + echo "PROJECT_NAME=$(gcloud config get-value project)" >> $GITHUB_ENV + # Test results and logs + echo "GCS_RESULTS_PATH=gs://cobalt-unittest-storage/results/${{ matrix.name }}/${{ github.run_id }}" >> $GITHUB_ENV + shell: bash + - name: Run Tests on ${{ matrix.platform }} Platform + env: + # TODO(b/382508397): Replace hardcoded list with dynamically generated one. + TEST_TARGETS_JSON_FILE: cobalt/build/testing/targets/${{ matrix.platform }}/test_targets.json + GCS_ARTIFACTS_PATH: /bigstore/${{ env.PROJECT_NAME }}-test-artifacts/${{ github.workflow }}/${{ github.run_number }}/${{ matrix.platform }} + GCS_RESULTS_PATH: ${{ inputs.gcs_results_path }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TRIGGERING_ACTOR: ${{ github.triggering_actor }} + GITHUB_ACTOR_ID: ${{ github.actor_id }} + GITHUB_REPO: ${{ github.repository }} + GITHUB_PR_HEAD_USER_LOGIN: ${{ github.event.pull_request.head.user.login }} + GITHUB_PR_HEAD_USER_ID: ${{ github.event.pull_request.head.user.id }} + GITHUB_COMMIT_AUTHOR_USERNAME: ${{ github.event.commits[0].author.username }} + GITHUB_COMMIT_AUTHOR_EMAIL: ${{ github.event.commits[0].author.email }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GITHUB_WORKFLOW: ${{ github.workflow }} + run: | + set -uxe + python3 -u cobalt/tools/on_device_tests_gateway_client.py \ + --token ${GITHUB_TOKEN} \ + trigger \ + --targets $(cat "${{ env.TEST_TARGETS_JSON_FILE }}" | jq -cr '.test_targets | join(",")') \ + --filter_json_dir "${GITHUB_WORKSPACE}/cobalt/testing/filters/${{ matrix.platform}}" \ + --label github_${GITHUB_PR_NUMBER:-postsubmit} \ + --label builder-${{ matrix.platform }} \ + --label builder_url-${GITHUB_RUN_URL} \ + --label github \ + --label ${GITHUB_EVENT_NAME} \ + --label ${GITHUB_WORKFLOW} \ + --label actor-${GITHUB_ACTOR} \ + --label actor_id-${GITHUB_ACTOR_ID} \ + --label triggering_actor-${GITHUB_TRIGGERING_ACTOR} \ + --label sha-${GITHUB_SHA} \ + --label repository-${GITHUB_REPO} \ + --label author-${GITHUB_PR_HEAD_USER_LOGIN:-$GITHUB_COMMIT_AUTHOR_USERNAME} \ + --label author_id-${GITHUB_PR_HEAD_USER_ID:-$GITHUB_COMMIT_AUTHOR_EMAIL} \ + ${DIMENSION:+"--dimension" "$DIMENSION"} \ + ${ON_DEVICE_TEST_ATTEMPTS:+"--test_attempts" "$ON_DEVICE_TEST_ATTEMPTS"} \ + --gcs_archive_path "${GCS_ARTIFACTS_PATH}" \ + --gcs_result_path "${GCS_RESULTS_PATH}" shell: bash + - name: Download ${{ matrix.platform }} Test Results + if: always() + env: + GCS_RESULTS_PATH: ${{ inputs.gcs_results_path }} + RESULTS_DIR: ${{ inputs.results_dir }} run: | - echo "Nothing yet" + set -uxe + TEST_LOGS="${GITHUB_WORKSPACE}/${RESULTS_DIR}/" + mkdir -p "${TEST_LOGS}" + gsutil cp -r "${GCS_RESULTS_PATH}/" "${TEST_LOGS}" + echo "TEST_LOGS=${TEST_LOGS}" >> $GITHUB_ENV + shell: bash + - name: Archive Test Results + uses: actions/upload-artifact@v3 + if: always() + with: + name: ${{ inputs.test_results_key }} + path: ${{ env.TEST_LOGS }}/ diff --git a/.github/actions/upload_test_artifacts/action.yaml b/.github/actions/upload_test_artifacts/action.yaml index a2a645aea29d..c5a6d9cb033c 100644 --- a/.github/actions/upload_test_artifacts/action.yaml +++ b/.github/actions/upload_test_artifacts/action.yaml @@ -16,22 +16,7 @@ inputs: runs: using: "composite" steps: - - name: Set up Cloud SDK - if: inputs.upload_on_device_test_artifacts == 'true' - uses: isarkis/setup-gcloud@40dce7857b354839efac498d3632050f568090b6 # v1.1.1 - - name: Upload Android Test Artifacts to GCS - if: inputs.upload_on_device_test_artifacts == 'true' - env: - WORKFLOW: ${{ github.workflow }} - run: | - set -eux - project_name=$(gcloud config get-value project) - gsutil cp "${GITHUB_WORKSPACE}/src/out/${{ matrix.platform }}_${{ matrix.config }}/**/*.apk" \ - "gs://${project_name}-test-artifacts/${WORKFLOW}/${GITHUB_RUN_NUMBER}/${{matrix.platform}}/" - shell: bash - - - name: Create On-Host Test Artifacts Archive - if: inputs.upload_on_host_test_artifacts == 'true' + - name: Archive Test Artifacts run: | set -x mkdir ${GITHUB_WORKSPACE}/artifacts @@ -49,3 +34,17 @@ runs: name: ${{ inputs.test_artifacts_key }} path: artifacts/* retention-days: 3 + - name: Set up Cloud SDK + if: inputs.upload_on_device_test_artifacts == 'true' + uses: isarkis/setup-gcloud@40dce7857b354839efac498d3632050f568090b6 # v1.1.1 + - name: Upload Android Test Artifacts to GCS + if: inputs.upload_on_device_test_artifacts == 'true' + env: + WORKFLOW: ${{ github.workflow }} + run: | + set -eux + project_name=$(gcloud config get-value project) + + gsutil cp "${GITHUB_WORKSPACE}/artifacts/*" \ + "gs://${project_name}-test-artifacts/${WORKFLOW}/${GITHUB_RUN_NUMBER}/${{matrix.platform}}/" + shell: bash diff --git a/.github/config/android-arm.json b/.github/config/android-arm.json index 99ef53f97c2b..25aec0c3f489 100644 --- a/.github/config/android-arm.json +++ b/.github/config/android-arm.json @@ -3,6 +3,11 @@ "platforms": [ "android-arm" ], + "test_on_device": true, + "test_dimensions": { + "gtest_device": "sabrina", + "gtest_lab": "maneki" + }, "targets": [ "content_shell", "system_webview_apk", diff --git a/.github/config/chromium_android-arm.json b/.github/config/chromium_android-arm.json index 7e073505527c..50fb7aee1fdb 100644 --- a/.github/config/chromium_android-arm.json +++ b/.github/config/chromium_android-arm.json @@ -4,16 +4,6 @@ "chromium_android-arm" ], "targets": [ - "base_unittests", - "sql_unittests", - "net_unittests", - "url_unittests", - "ipc_tests", - "mojo_unittests", - "gpu_unittests", - "gin_unittests", - "blink_unittests", - "media_unittests", "content_shell", "system_webview_apk", "system_webview_shell_apk" diff --git a/.github/config/chromium_android-arm64.json b/.github/config/chromium_android-arm64.json index 1921fdbbad91..5c5239614c65 100644 --- a/.github/config/chromium_android-arm64.json +++ b/.github/config/chromium_android-arm64.json @@ -4,16 +4,6 @@ "chromium_android-arm64" ], "targets": [ - "base_unittests", - "sql_unittests", - "net_unittests", - "url_unittests", - "ipc_tests", - "mojo_unittests", - "gpu_unittests", - "gin_unittests", - "blink_unittests", - "media_unittests", "content_shell", "system_webview_apk", "system_webview_shell_apk" diff --git a/.github/config/chromium_android-x86.json b/.github/config/chromium_android-x86.json index 8498733285ed..ec99f4fcb9db 100644 --- a/.github/config/chromium_android-x86.json +++ b/.github/config/chromium_android-x86.json @@ -4,16 +4,6 @@ "chromium_android-x86" ], "targets": [ - "base_unittests", - "sql_unittests", - "net_unittests", - "url_unittests", - "ipc_tests", - "mojo_unittests", - "gpu_unittests", - "gin_unittests", - "blink_unittests", - "media_unittests", "content_shell", "system_webview_apk", "system_webview_shell_apk" diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d521ed65099f..d23f8b1a10c7 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -145,7 +145,7 @@ jobs: matrix: platform: ${{ fromJson(needs.initialize.outputs.platforms) }} include: ${{ fromJson(needs.initialize.outputs.includes) }} - config: [devel, qa, gold] + config: [devel] container: ${{ needs.docker-build-image.outputs.docker_tag }} env: TEST_ARTIFACTS_KEY: ${{ matrix.platform }}_${{ matrix.name }}_test_artifacts @@ -183,9 +183,47 @@ jobs: upload_on_host_test_artifacts: ${{ matrix.config == 'devel' && needs.initialize.outputs.test_on_host }} upload_on_device_test_artifacts: ${{ matrix.config == 'devel' && needs.initialize.outputs.test_on_device }} - test: + # Runs on-device integration and unit tests. + on-device-test: + needs: [initialize, build] + # Run ODT when on_device label is applied on PR. + # Also, run ODT on push and schedule if not explicitly disabled via repo vars. + if: needs.initialize.outputs.test_on_device == 'true' && + ( + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'on_device')) || + (vars.RUN_ODT_TESTS_ON_NIGHTLY != 'False' && (inputs.nightly == 'true' || github.event_name == 'schedule')) || + (vars.RUN_ODT_TESTS_ON_POSTSUBMIT != 'False' && github.event_name == 'push') + ) + runs-on: [self-hosted, odt-runner] + name: ${{ matrix.name }}_on_device + permissions: {} + strategy: + fail-fast: false + matrix: + platform: ${{ fromJson(needs.initialize.outputs.platforms) }} + include: ${{ fromJson(needs.initialize.outputs.includes) }} + config: [devel] + env: + TEST_RESULTS_DIR: ${{ matrix.name }}_test_results + TEST_RESULTS_KEY: ${{ matrix.platform }}_${{ matrix.name }}_test_results + steps: + - name: Checkout + uses: kaidokert/checkout@v3.5.999 + timeout-minutes: 30 + with: + fetch-depth: 1 + persist-credentials: false + - name: Run On-Device Tests (${{ matrix.shard }}) + id: on-device-tests + uses: ./.github/actions/on_device_tests + with: + test_results_key: ${{ env.TEST_RESULTS_KEY }} + gcs_results_path: gs://cobalt-unittest-storage/results/${{ matrix.name }}/${{ github.run_id }} + results_dir: ${{ env.TEST_RESULTS_DIR }} + + on-host-test: needs: [initialize, docker-build-image, build] - if: needs.initialize.outputs.test_on_host == 'true' || needs.initialize.outputs.test_on_device == 'true' + if: needs.initialize.outputs.test_on_host == 'true' permissions: {} # TODO(b/372303096): Should have dedicated runner? runs-on: [self-hosted, chrobalt-linux-runner] @@ -209,7 +247,7 @@ jobs: path: src - name: Run On-Host Tests id: on-host-tests - if: always() && needs.initialize.outputs.test_on_host == 'true' + if: always() uses: ./src/.github/actions/on_host_tests with: test_artifacts_key: ${{ env.TEST_ARTIFACTS_KEY }} @@ -217,12 +255,12 @@ jobs: num_gtest_shards: ${{ needs.initialize.outputs.num_gtest_shards }} test-upload: - needs: [initialize, docker-build-image, build, test] + needs: [initialize, docker-build-image, build, on-host-test, on-device-test] if: always() && - ( - needs.initialize.outputs.test_on_host == 'true' || - needs.initialize.outputs.test_on_device == 'true' - ) + ( + needs.initialize.outputs.test_on_host == 'true' || + needs.initialize.outputs.test_on_device == 'true' + ) permissions: {} runs-on: [self-hosted, chrobalt-linux-runner] name: ${{ matrix.name }}_tests_upload @@ -249,12 +287,12 @@ jobs: datadog_api_key: ${{ secrets.datadog_api_key }} continue-on-error: true + validate-test-result: - needs: [initialize, docker-build-image, build, test] + needs: [initialize, docker-build-image, build, on-host-test] if: always() && ( - needs.initialize.outputs.test_on_host == 'true' || - needs.initialize.outputs.test_on_device == 'true' + needs.initialize.outputs.test_on_host == 'true' ) permissions: {} runs-on: ubuntu-latest diff --git a/cobalt/build/archive_test_artifacts.py b/cobalt/build/archive_test_artifacts.py index 711f7bb2734c..a2e5e75b625a 100755 --- a/cobalt/build/archive_test_artifacts.py +++ b/cobalt/build/archive_test_artifacts.py @@ -17,6 +17,7 @@ import argparse import json import os +import shutil import subprocess import tempfile from typing import List @@ -35,7 +36,7 @@ def _make_tar(archive_path: str, file_list: str): def create_archive(targets: List[str], source_dir: str, destination_dir: str, - platform: str, combine: bool): + platform: str, uber_archive: bool): """Main logic. Collects runtime dependencies from the source directory for each target.""" # TODO(b/382508397): Remove when dynamically generated. @@ -72,16 +73,27 @@ def create_archive(targets: List[str], source_dir: str, destination_dir: str, } deps |= target_deps - if not combine: + if not uber_archive: output_path = os.path.join(destination_dir, f'{target_name}_deps.tar.gz') _make_tar(output_path, deps) - if combine: + if uber_archive: output_path = os.path.join(destination_dir, 'test_artifacts.tar.gz') _make_tar(output_path, deps) -if __name__ == '__main__': +def copy_apks(targets: List[str], source_dir: str, destination_dir: str): + """Copies the target APKs from the source directory to the destination. + The path to the APK in the source directory (assumed here to be the out + directory) is defined in build/config/android/rules.gni + """ + for target in targets: + _, target_name = target.split(':') + apk_path = f'{source_dir}/{target_name}_apk/{target_name}-debug.apk' + shutil.copy2(apk_path, destination_dir) + + +def main(): parser = argparse.ArgumentParser() parser.add_argument( '-s', @@ -103,9 +115,17 @@ def create_archive(targets: List[str], source_dir: str, destination_dir: str, '--targets', required=True, type=lambda arg: arg.split(','), - help='The targets to package, comma-separated. Must be fully qualified ' - 'for android.') + help='The targets to package, comma-separated. Must be fully qualified, ' + 'e.g. path/to:target.') args = parser.parse_args() + uber_archive = args.platform.startswith('linux') create_archive(args.targets, args.source_dir, args.destination_dir, - args.platform, args.platform.startswith('linux')) + args.platform, uber_archive) + + if args.platform.startswith('android'): + copy_apks(args.targets, args.source_dir, args.destination_dir) + + +if __name__ == '__main__': + main() diff --git a/cobalt/testing/filters/android-arm/base_unittests_filter.json b/cobalt/testing/filters/android-arm/base_unittests_filter.json new file mode 100644 index 000000000000..fb7886542530 --- /dev/null +++ b/cobalt/testing/filters/android-arm/base_unittests_filter.json @@ -0,0 +1,6 @@ +{ + "failing_tests": [ + "BreakIteratorTest.BreakCharacter", + "ValuesUtilTest.FilePath" + ] +} diff --git a/cobalt/tools/on_device_tests_gateway.proto b/cobalt/tools/on_device_tests_gateway.proto new file mode 100644 index 000000000000..49f6cdb49917 --- /dev/null +++ b/cobalt/tools/on_device_tests_gateway.proto @@ -0,0 +1,69 @@ +// Copyright 2022 The Cobalt Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package on_device_tests_gateway; + +// Interface exported by the server. +service on_device_tests_gateway { + // A dumb proxy RPC service that passes user defined command line options + // to the on-device tests gateway and streams back output in real time. + rpc exec_command(OnDeviceTestsCommand) + returns (stream OnDeviceTestsResponse) {} + + // A dumb proxy RPC service that passes user defined command line options + // to the on-device tests gateway and streams back output in real time. + rpc exec_watch_command(OnDeviceTestsWatchCommand) + returns (stream OnDeviceTestsResponse) {} +} + +// Working directory and command line arguments to be passed to the gateway. +message OnDeviceTestsCommand { + string token = 2; + repeated string labels = 8; + repeated TestRequest test_requests = 24; +} + +message TestRequest { + // Args picked up by MH, e.g. "name1=value", "name2=value. + repeated string test_args = 1; + // Args sent to test binary/apk, e.g. "--arg1", "--arg2=value". + repeated string test_cmd_args = 2; + // Files to send to device, e.g. "build_apk=/bigstore/bucket/test.apk", + // "test_apk=/bigstore/bucket/test.apk", + repeated string files = 3; + // e.g. "gcs_result_path=gs://some/gcs/path" + repeated string params = 4; + // "sabrina" or "boreal" + string device_type = 5; + // "shared" or "maneki" + string device_pool = 6; +} + +// Working directory and command line arguments to be passed to the gateway. +message OnDeviceTestsWatchCommand { + // Next ID: 6 + string workdir = 1; + string token = 2; + string session_id = 3; + string change_id = 4; + bool dry_run = 5; +} + +// Response from the on-device tests. +message OnDeviceTestsResponse { + // Next ID: 2 + string response = 1; +} diff --git a/cobalt/tools/on_device_tests_gateway_client.py b/cobalt/tools/on_device_tests_gateway_client.py new file mode 100644 index 000000000000..a3695a1fc689 --- /dev/null +++ b/cobalt/tools/on_device_tests_gateway_client.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +# +# Copyright 2022 The Cobalt Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""gRPC On-device Tests Gateway client.""" + +import argparse +import json +import logging +import sys +from typing import List + +import grpc +import on_device_tests_gateway_pb2 +import on_device_tests_gateway_pb2_grpc + +_WORK_DIR = '/on_device_tests_gateway' + +# For local testing, set: _ON_DEVICE_TESTS_GATEWAY_SERVICE_HOST = ('localhost') +_ON_DEVICE_TESTS_GATEWAY_SERVICE_HOST = ( + 'on-device-tests-gateway-service.on-device-tests.svc.cluster.local') +_ON_DEVICE_TESTS_GATEWAY_SERVICE_PORT = '50052' + +# These paths are hardcoded in various places. DO NOT CHANGE! +_DIR_ON_DEVICE = '/sdcard/Download' +_DEPS_ARCHIVE = '/sdcard/chromium_tests_root/deps.tar.gz' + + +class OnDeviceTestsGatewayClient(): + """On-device tests Gateway Client class.""" + + def __init__(self): + self.channel = grpc.insecure_channel( + target=f'{_ON_DEVICE_TESTS_GATEWAY_SERVICE_HOST}:{_ON_DEVICE_TESTS_GATEWAY_SERVICE_PORT}', # pylint:disable=line-too-long + # These options need to match server settings. + options=[('grpc.keepalive_time_ms', 10000), + ('grpc.keepalive_timeout_ms', 5000), + ('grpc.keepalive_permit_without_calls', 1), + ('grpc.http2.max_pings_without_data', 0), + ('grpc.http2.min_time_between_pings_ms', 10000), + ('grpc.http2.min_ping_interval_without_data_ms', 5000)]) + self.stub = on_device_tests_gateway_pb2_grpc.on_device_tests_gatewayStub( + self.channel) + + def run_trigger_command(self, token: str, labels: List[str], test_requests): + """Calls On-Device Tests service and passing given parameters to it. + + Args: + args (Namespace): Arguments passed in command line. + test_requests (list): A list of test requests. + """ + for response_line in self.stub.exec_command( + on_device_tests_gateway_pb2.OnDeviceTestsCommand( + token=token, + labels=labels, + test_requests=test_requests, + )): + + print(response_line.response) + + def run_watch_command(self, token: str, session_id: str): + """Calls On-Device Tests watch service and passing given parameters to it. + + Args: + args (Namespace): Arguments passed in command line. + """ + for response_line in self.stub.exec_watch_command( + on_device_tests_gateway_pb2.OnDeviceTestsWatchCommand( + token=token, + session_id=session_id, + )): + + print(response_line.response) + + +def _read_json_config(filename): + """Reads and parses data from a JSON configuration file. + + Args: + filename: The name of the JSON configuration file. + + Returns: + A list of dictionaries, where each dictionary represents a test + configuration. + """ + try: + with open(filename, 'r', encoding='utf-8') as f: + return json.load(f) + except FileNotFoundError: + print(f" Config file '{filename}' not found.") + raise + except json.JSONDecodeError: + print(f" Invalid JSON format in '{filename}'.") + raise + + +def _get_gtest_filters(filter_json_dir, gtest_target): + """Retrieves gtest filters for a given target. + + Args: + filter_json_dir: Directory containing filter JSON files. + gtest_target: The name of the gtest target. + + Returns: + A string containing the gtest filters. + """ + gtest_filters = '*' + filter_json_file = f'{filter_json_dir}/{gtest_target}_filter.json' + print(f' gtest_filter_json_file = {filter_json_file}') + filter_data = _read_json_config(filter_json_file) + if filter_data: + print(f' Loaded filter data: {filter_data}') + failing_tests = ':'.join(filter_data.get('failing_tests', [])) + if failing_tests: + gtest_filters += ':-' + failing_tests + print(f' gtest_filters = {gtest_filters}') + else: + print(' This gtest_target does not have gtest_filters specified') + return gtest_filters + + +def _process_test_requests(args): + """Processes test requests from the given arguments. + + Constructs a list of test requests based on the provided arguments, + including test arguments, command arguments, files, parameters, + and device information. + + Args: + args: The parsed command-line arguments. + + Returns: + A list of test request dictionaries. + """ + test_requests = [] + + for gtest_target in args.targets.split(','): + print(f' Processing gtest_target: {gtest_target}') + + tests_args = [ + f'job_timeout_secs={args.job_timeout_secs}', + f'test_timeout_secs={args.test_timeout_secs}', + f'start_timeout_secs={args.start_timeout_secs}' + ] + if args.test_attempts: + tests_args.append(f'test_attempts={args.test_attempts}') + if args.dimension: + tests_args += [f'dimension_{dimension}' for dimension in args.dimension] + + gtest_filter = _get_gtest_filters(args.filter_json_dir, gtest_target) + command_line_args = ' '.join([ + f'--gtest_output=xml:{_DIR_ON_DEVICE}/{gtest_target}_result.xml', + f'--gtest_filter={gtest_filter}', + ]) + test_cmd_args = [f'command_line_args={command_line_args}'] + + files = [ + f'test_apk={args.gcs_archive_path}/{gtest_target}-debug.apk', + f'build_apk={args.gcs_archive_path}/{gtest_target}-debug.apk', + f'test_runtime_deps={args.gcs_archive_path}/{gtest_target}_deps.tar.gz', + ] + + params = [] + if args.gcs_result_path: + params.append(f'gcs_result_path={args.gcs_result_path}') + params += [ + f'push_files=test_runtime_deps:{_DEPS_ARCHIVE}', + f'gtest_xml_file_on_device={_DIR_ON_DEVICE}/{gtest_target}_result.xml', + f'gcs_result_filename={gtest_target}_result.xml', + f'gcs_log_filename={gtest_target}_log.txt' + ] + + # TODO(oxv): Figure out how to get dimensions from config to here. + device_type = 'sabrina' + device_pool = 'maneki' + + test_requests.append({ + 'test_args': tests_args, + 'test_cmd_args': test_cmd_args, + 'files': files, + 'params': params, + 'device_type': device_type, + 'device_pool': device_pool, + }) + return test_requests + + +def main() -> int: + """Main routine for the on-device tests gateway client.""" + + logging.basicConfig( + level=logging.INFO, format='[%(filename)s:%(lineno)s] %(message)s') + print('Starting main routine') + + parser = argparse.ArgumentParser( + description='Client for interacting with the On-Device Tests gateway.', + epilog=('Example:' + 'python3 -u cobalt/tools/on_device_tests_gateway_client.py' + '--platform_json "${GITHUB_WORKSPACE}/src/.github/config/' + '${{ matrix.platform}}.json"' + '--filter_json_dir "${GITHUB_WORKSPACE}/src/cobalt/testing/' + '${{ matrix.platform}}"' + '--token ${GITHUB_TOKEN}' + '--label builder-${{ matrix.platform }}' + '--label builder_url-${GITHUB_RUN_URL}' + '--label github' + '--label ${GITHUB_EVENT_NAME}' + '--label ${GITHUB_WORKFLOW}' + '--label actor-${GITHUB_ACTOR}' + '--label actor_id-${GITHUB_ACTOR_ID}' + '--label triggering_actor-${GITHUB_TRIGGERING_ACTOR}' + '--label sha-${GITHUB_SHA}' + '--label repository-${GITHUB_REPO}' + '--label author-${GITHUB_PR_HEAD_USER_LOGIN:-' + '$GITHUB_COMMIT_AUTHOR_USERNAME}' + '--label author_id-${GITHUB_PR_HEAD_USER_ID:-' + '$GITHUB_COMMIT_AUTHOR_EMAIL}' + '--dimension host_name=regex:maneki-mhserver-05.*' + '${DIMENSION:+"--dimension" "$DIMENSION"}' + '${ON_DEVICE_TEST_ATTEMPTS:+"--test_attempts" ' + '"$ON_DEVICE_TEST_ATTEMPTS"}' + '--gcs_archive_path "${GCS_ARTIFACTS_PATH}"' + '--gcs_result_path "${GCS_RESULTS_PATH}"' + 'trigger'), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # Authentication + parser.add_argument( + '-t', + '--token', + type=str, + required=True, + help='On Device Tests authentication token', + ) + subparsers = parser.add_subparsers( + dest='action', help='On-Device tests commands', required=True) + + # Trigger command + trigger_parser = subparsers.add_parser( + 'trigger', + help='Trigger On-Device tests', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + # Group trigger arguments + trigger_args = trigger_parser.add_argument_group('Trigger Arguments') + trigger_parser.add_argument( + '--targets', + type=str, + required=True, + help='List of targets to test, comma separated. Must be fully qualified ' + 'ninja target.', + ) + trigger_parser.add_argument( + '--filter_json_dir', + type=str, + required=True, + help='Directory containing filter JSON files for test selection.', + ) + trigger_parser.add_argument( + '-l', + '--label', + type=str, + action='append', + help='Additional labels to assign to the test.', + ) + trigger_parser.add_argument( + '--dimension', + type=str, + action='append', + help='On-Device Tests dimension used to select a device. Must have the ' + 'following form: =. E.G. "release_version=regex:10.*"', + ) + trigger_parser.add_argument( + '--test_attempts', + type=str, + default='1', + help='The maximum number of times a test can retry.', + ) + trigger_args.add_argument( + '-a', + '--gcs_archive_path', + type=str, + required=True, + help='Path to Chrobalt archive to be tested. Must be on GCS.', + ) + trigger_parser.add_argument( + '--gcs_result_path', + type=str, + help='GCS URL where test result files should be uploaded.', + ) + trigger_parser.add_argument( + '--job_timeout_secs', + type=str, + default='1800', + help='Timeout in seconds for the job (default: 1800 seconds).', + ) + trigger_parser.add_argument( + '--test_timeout_secs', + type=str, + default='1800', + help='Timeout in seconds for the test (default: 1800 seconds).', + ) + trigger_parser.add_argument( + '--start_timeout_secs', + type=str, + default='180', + help='Timeout in seconds for the test to start (default: 180 seconds).', + ) + + # Watch command + watch_parser = subparsers.add_parser( + 'watch', help='Watch a previously triggered On-Device test') + watch_parser.add_argument( + 'session_id', + type=str, + help=('Session ID of a previously triggered Mobile Harness test. ' + 'The test will be watched until it completes.'), + ) + + args = parser.parse_args() + test_requests = _process_test_requests(args) + + client = OnDeviceTestsGatewayClient() + try: + if args.action == 'trigger': + client.run_trigger_command(args.token, args.label, test_requests) + else: + client.run_watch_command(args.token, args.session_id) + except grpc.RpcError as e: + logging.exception('gRPC error occurred:') # Log the full traceback + return e.code().value # Return the error code + + return 0 # Indicate successful execution + + +if __name__ == '__main__': + sys.exit(main())