Initial code submit for Open HST. am: 25850f04a1 am: d73fe15626 am: 2d60bbc8e8
Original change: https://googleplex-android-review.googlesource.com/c/platform/tools/test/openhst/+/11890425
Change-Id: I06b9f6fa806027494e116b402d985a19708cce57
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4016bab
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,87 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+.PHONY: help start
+
+SHELL := /bin/bash
+SRC_DIR ?= .
+DST_DIR ?= .
+PROTOC_DIR ?= .
+PROTO_SRC_FILE ?= stress_test.proto
+
+ifeq ($(OS),Windows_NT)
+ detected_OS := Windows
+ USER_NAME ?=
+else
+ detected_OS := $(shell uname)
+ USER_NAME ?= $(shell whoami)
+ ENV_PATH ?= $(shell pwd)
+endif
+
+.DEFAULT: help
+help:
+ @echo "make start"
+ @echo " prepare development environment, use only once"
+ @echo "make proto-compile"
+ @echo " compile protubuf"
+ @echo "make clean"
+ @echo " delete test result and cache directories"
+
+start:
+ifeq ($(detected_OS),Windows)
+ @echo "please install python3 and pytyon3-pip manually"
+ py -m pip install --upgrade pip
+ py -m pip install --user virtualenv
+ python -m venv .\env
+endif
+ifeq ($(detected_OS),Darwin)
+ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
+ sudo chown -R $(USER_NAME) /usr/local/bin /usr/local/etc /usr/local/sbin /usr/local/share
+ chmod u+w /usr/local/bin /usr/local/etc /usr/local/sbin /usr/local/share
+ brew install python3 protobuf sox
+ python3 -m pip install --user virtualenv
+ python3 -m venv env
+endif
+ifeq ($(detected_OS),Linux)
+ sudo apt-get install python3 sox
+ sudo apt install python3-pip
+ python3 -m pip install --user --upgrade pip
+ python3 -m pip install --user virtualenv
+ sudo apt-get install python3-venv
+ python3 -m venv env
+ sudo apt install protobuf-compiler
+endif
+
+proto-compile: ${PROTO_SRC_FILE}
+ifeq ($(detected_OS),Windows)
+ifndef PROTOC_DIR
+ @echo "Error! Please download protoc.exe from https://github.com/google/protobuf/releases/ and set PROTOC_DIR accordingly"
+else
+ $(PROTOC_DIR)/protoc.exe -I=${SRC_DIR} --python_out=${DST_DIR} ${SRC_DIR}/${PROTO_SRC_FILE}
+endif
+else
+ protoc -I=${SRC_DIR} --python_out=${DST_DIR} ${SRC_DIR}/${PROTO_SRC_FILE}
+endif
+
+clean:
+ifeq ($(detected_OS),Windows)
+ @for /d %%x in (dsp_*) do rd /s /q "%%x"
+ @for /d %%x in (enroll_*) do rd /s /q "%%x"
+ @for /d %%x in (__pycache*) do rd /s /q "%%x"
+else
+ @rm -rf __pycache__
+ @rm -rf dsp_*
+ @rm -rf enroll*
+endif
+ @echo "cleanning completed"
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..7259ddc
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,5 @@
+# Default maintainers and code reviewers:
+jschung@google.com
+wonil@google.com
+kwangun@google.com
+mokani@google.com
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c4031cd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+# OpenHST : Open sourced Hotword Stress Test tool
+
+Hotword stress test tool is a python script to measure the Google Hotword
+performance on Android devices with simple test set up. The goal of OpenHST is
+making Google Hotword Stress Test tool as an open source project so that Android
+Partners can easily adapt on their development.
+
+## Overview
+
+![Hotsord Stress Test concept diagram](docs/image/HST_concept_diagram.png)
+
+## Prerequsite
+
+Before setting up your HST test environment, please make sure to meet following
+preconditions.
+
+* DSP hotword capability on test device (android handset)
+* ADB connection between the test server and android handset
+* Internet connection on android handset
+* No screen lock setting on android handset
+* Python 3.x installed test machine (Linux, Mac, Windows)
+* Install GNU make(3.81 above) on test machine (Linux, Mac, Windows)
+
+## Test environment setup
+
+Please follow below steps to set up HST on your test machine and make sure that
+no error popped up during the installation.
+
+__Linux & Mac__
+
+1. make start
+1. make proto-compile
+1. source env/bin/activate
+1. ./start_venv.sh
+
+__Windows__
+
+1. make start
+1. make proto-compile
+1. .\env\Scripts\activate
+1. .\start_venv.bat
+1. pip3 install -r requirements.txt
+
+## Documentation
+
+Read our [detailed documentation](docs/OpenHST.pdf) to learn how to run the
+Hotword Stress Test.
+
+## Contacts
+
+Join our [mailing list](https://groups.google.com/g/openhst) for discussions and
+announcements.
diff --git a/docs/OpenHST.pdf b/docs/OpenHST.pdf
new file mode 100644
index 0000000..2b1cb8f
--- /dev/null
+++ b/docs/OpenHST.pdf
Binary files differ
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..22b241c
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,29 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement (CLA). You (or your employer) retain the copyright to your
+contribution; this simply gives us permission to use and redistribute your
+contributions as part of the project. Head over to
+<https://cla.developers.google.com/> to see your current agreements on file or
+to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
+
+## Community Guidelines
+
+This project follows
+[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
diff --git a/docs/image/HST_concept_diagram.png b/docs/image/HST_concept_diagram.png
new file mode 100644
index 0000000..5b62a38
--- /dev/null
+++ b/docs/image/HST_concept_diagram.png
Binary files differ
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a963e06
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+google==2.0.3
+protobuf==3.11.3
+protobuf-compiler==1.0.20
+absl-py==0.9.0
+pexpect==4.8.0
diff --git a/resources/device_config.common.ascii_proto b/resources/device_config.common.ascii_proto
new file mode 100644
index 0000000..0d4ae8a
--- /dev/null
+++ b/resources/device_config.common.ascii_proto
@@ -0,0 +1,102 @@
+file_to_watch {
+ source: "LOGCAT"
+ destination: "%(device)s_logcat.txt"
+ repeats_output_on_open: true
+ time_stamp {
+ type: DATE
+ regex: "^(\\d+-\\d+ \\d+:\\d+:\\d+.\\d+)"
+ date_format: "%m-%d %H:%M:%S.%f"
+ }
+}
+
+file_to_watch {
+ source: "/dev/kmsg"
+ destination: "%(device)s_kmsg.txt"
+ repeats_output_on_open: true
+ time_stamp {
+ type: MONOTONIC
+ regex: "^\\d+,\\d+,(\\d+)"
+ monotonic_to_seconds_multiplier: 1e-6
+ }
+}
+
+file_to_move {
+ source: "/sdcard/captured_dsp_audio.wav"
+ destination: "%(device)s/%(iteration)06d_captured_dsp_audio.wav"
+}
+
+event {
+ source: "LOGCAT"
+ name: "software_hotword"
+ regex: "MicroDetectionWorker: #onHotwordDetected"
+}
+
+event {
+ source: "LOGCAT"
+ name: "vis_software_hotword"
+ regex: "GsaVoiceInteractionSrv: onHotwordDetected"
+}
+
+event {
+ source: "LOGCAT"
+ name: "dsp_false_accept"
+ regex: "Software didn't trigger but DSP did"
+}
+
+event {
+ source: "LOGCAT"
+ name: "speaker_id_rejected"
+ regex: ": Speaker Verification failed"
+ regex: "Software based speaker id triggered: false"
+}
+
+event {
+ source: "LOGCAT"
+ name: "logcat_iteration"
+ regex: "STRESS_TEST: Iteration \d+ complete"
+}
+
+event {
+ source: "LOGCAT"
+ name: "assistant_started"
+ regex: "START.*com\.google\.android\.googlequicksearchbox.*opa\.OpaActivity.*"
+}
+
+event {
+ source: "LOGCAT"
+ name: "aohd_hotword_detected"
+ regex: "AlwaysOnHotwordDetector: onDetected"
+}
+
+setup_command: "shell setprop log.tag.AlwaysOnHotwordDetector DEBUG"
+setup_command: "shell setprop log.tag.ClockworkHomeGoogle DEBUG"
+setup_command: "shell setprop log.tag.DSPMicrophoneIS DEBUG"
+setup_command: "shell setprop log.tag.DSPMicrophoneInputStrea DEBUG"
+setup_command: "shell setprop log.tag.GsaVoiceInteractionSrv DEBUG"
+setup_command: "shell setprop log.tag.HotwordAudioProvider DEBUG"
+setup_command: "shell setprop log.tag.HotwordConfig DEBUG"
+setup_command: "shell setprop log.tag.HotwordConfigController DEBUG"
+setup_command: "shell setprop log.tag.HotwordDetector DEBUG"
+setup_command: "shell setprop log.tag.HotwordHelper DEBUG"
+setup_command: "shell setprop log.tag.HotwordRecognitionEngn DEBUG"
+setup_command: "shell setprop log.tag.HotwordRecognitionRnr DEBUG"
+setup_command: "shell setprop log.tag.HotwordSettingsCntrlr DEBUG"
+setup_command: "shell setprop log.tag.HotwordState DEBUG"
+setup_command: "shell setprop log.tag.HotwordWorker DEBUG"
+setup_command: "shell setprop log.tag.HotwordWorkerImpl DEBUG"
+setup_command: "shell setprop log.tag.MicroDetectionState DEBUG"
+setup_command: "shell setprop log.tag.MicroRecognitionEngine DEBUG"
+setup_command: "shell setprop log.tag.SWHotwordRecognizer DEBUG"
+setup_command: "shell setprop log.tag.SoundTrigger DEBUG"
+setup_command: "shell setprop log.tag.SoundTriggerHelper DEBUG"
+setup_command: "shell setprop log.tag.SoundTriggerTest DEBUG"
+setup_command: "shell setprop log.tag.VISHotwordAdapter DEBUG"
+setup_command: "shell setprop log.tag.WearVIS DEBUG"
+setup_command: "shell setprop log.tag.MicroDetectionWorker DEBUG"
+setup_command: "shell setprop log.tag.MicroDetectionWrkImpl DEBUG"
+setup_command: "shell setprop log.tag.MicroDetector DEBUG"
+setup_command: "shell setprop log.tag.MicroRecognitionEngine DEBUG"
+setup_command: "shell setprop log.tag.MicroRecognitionRnrImpl DEBUG"
+setup_command: "logcat -P ''"
+
+tag_to_suppress: "GoogleApiClientConnected"
diff --git a/resources/heyg-us-female.wav b/resources/heyg-us-female.wav
new file mode 100644
index 0000000..e431ed7
--- /dev/null
+++ b/resources/heyg-us-female.wav
Binary files differ
diff --git a/resources/heyg-us-male.wav b/resources/heyg-us-male.wav
new file mode 100644
index 0000000..c190b61
--- /dev/null
+++ b/resources/heyg-us-male.wav
Binary files differ
diff --git a/resources/okg-us-female.wav b/resources/okg-us-female.wav
new file mode 100644
index 0000000..5b3ba0f
--- /dev/null
+++ b/resources/okg-us-female.wav
Binary files differ
diff --git a/resources/okg-us-male.wav b/resources/okg-us-male.wav
new file mode 100644
index 0000000..74c9c1b
--- /dev/null
+++ b/resources/okg-us-male.wav
Binary files differ
diff --git a/resources/stress_test.dsp_monitoring_on_idle.ascii_proto b/resources/stress_test.dsp_monitoring_on_idle.ascii_proto
new file mode 100644
index 0000000..a1d490f
--- /dev/null
+++ b/resources/stress_test.dsp_monitoring_on_idle.ascii_proto
@@ -0,0 +1,29 @@
+description: "Let device in idle and check the DSP wakeup counts"
+ "Stop Testing if DSP detects hotword over 30 times"
+
+step {
+ command: "shell dumpsys battery unplug"
+ delay_after: 1
+}
+step {
+ command: "shell dumpsys deviceidle force-idle"
+ delay_after: 59
+}
+
+expected_result {
+ aohd_hotword_detected : 0
+ assistant_started : 0
+ dsp_false_accept : 0
+ speaker_id_rejected : 0
+ logcat_iteration : 1
+ software_hotword : 0
+ vis_software_hotword : 0
+}
+
+event {
+ name: "device_crashed"
+ condition: "aohd_hotword_detected >= 30"
+ action: "BUGREPORT"
+ action: "NOTIFY"
+ action: "REMOVE_DEVICE"
+}
diff --git a/resources/stress_test.dsp_trigger_and_screen_off.ascii_proto b/resources/stress_test.dsp_trigger_and_screen_off.ascii_proto
new file mode 100644
index 0000000..47241fb
--- /dev/null
+++ b/resources/stress_test.dsp_trigger_and_screen_off.ascii_proto
@@ -0,0 +1,43 @@
+description: "Switches between the homescreen and screen off, launching a "
+ "voice search when the DSP should be enabled. The search is "
+ "also just of OK Google."
+
+step {
+ audio_file : "okg-us-female.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 3
+}
+step {
+ command: "shell input keyevent 3"
+ delay_after: 1
+}
+step {
+ command: "shell input keyevent 26"
+ delay_after: 1
+}
+step {
+ command: "shell dumpsys battery unplug"
+ delay_after: 1
+}
+step {
+ command: "shell dumpsys deviceidle force-idle"
+ delay_after: 10
+}
+
+expected_result {
+ aohd_hotword_detected : 1
+ assistant_started : 1
+ dsp_false_accept : 0
+ logcat_iteration : 1
+ software_hotword : 1
+ speaker_id_rejected : 0
+ vis_software_hotword : 1
+}
+
+event {
+ name: "device_crashed"
+ condition: "iterations_since_aohd_hotword_detected >= 30"
+ "or iterations_since_assistant_started >= 30"
+ action: "BUGREPORT"
+ action: "REMOVE_DEVICE"
+}
diff --git a/resources/stress_test.dsp_trigger_on_homescreen.ascii_proto b/resources/stress_test.dsp_trigger_on_homescreen.ascii_proto
new file mode 100644
index 0000000..2fef6be
--- /dev/null
+++ b/resources/stress_test.dsp_trigger_on_homescreen.ascii_proto
@@ -0,0 +1,32 @@
+description: "Stress test DSP hotword on HomeScreen. User must be enrolled for "
+ "DSP hotword"
+
+step {
+ audio_file : "okg-us-female.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 3
+}
+
+step {
+ command: "shell input keyevent 3"
+ delay_after : 3
+}
+
+expected_result {
+ aohd_hotword_detected : 1
+ assistant_started : 1
+ dsp_false_accept : 0
+ logcat_iteration : 1
+ software_hotword : 1
+ speaker_id_rejected : 0
+ vis_software_hotword : 1
+}
+
+event {
+ name: "device_crashed"
+ condition: "iterations_since_aohd_hotword_detected >= 30 "
+ "or iterations_since_assistant_started >= 30"
+ action: "BUGREPORT"
+ action: "NOTIFY"
+ action: "REMOVE_DEVICE"
+}
diff --git a/resources/stress_test.dsp_trigger_sw_rejection.ascii_proto b/resources/stress_test.dsp_trigger_sw_rejection.ascii_proto
new file mode 100644
index 0000000..10a230f
--- /dev/null
+++ b/resources/stress_test.dsp_trigger_sw_rejection.ascii_proto
@@ -0,0 +1,60 @@
+description: "Test that repeats itself"
+
+step {
+ audio_file : "okg-us-female.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 3
+}
+step {
+ command: "shell input keyevent 3"
+ delay_after: 1
+}
+step {
+ command: "shell input keyevent 26"
+ delay_after: 2
+}
+step {
+ command: "shell dumpsys battery unplug"
+ delay_after: 1
+}
+step {
+ command: "shell dumpsys deviceidle force-idle"
+ delay_after: 5
+}
+step {
+ audio_file : "okg-us-male.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 3
+}
+step {
+ command: "shell input keyevent 3"
+ delay_after: 1
+}
+step {
+ command: "shell input keyevent 26"
+ delay_after: 2
+}
+step {
+ command: "shell dumpsys battery unplug"
+ delay_after: 1
+}
+step {
+ command: "shell dumpsys deviceidle force-idle"
+ delay_after: 5
+}
+
+expected_result {
+ aohd_hotword_detected : 2
+ assistant_started : 1
+ dsp_false_accept : 0
+ logcat_iteration : 1
+ software_hotword : 2
+ speaker_id_rejected : 1
+ vis_software_hotword : 2
+}
+
+event {
+ name: "sync_word_not_present"
+ condition: "sync_word_not_present == 1 and iterations_since_sync_word_not_present == 0"
+ action: "NOTIFY"
+}
diff --git a/resources/stress_test.enroll.ascii_proto b/resources/stress_test.enroll.ascii_proto
new file mode 100644
index 0000000..0c7e635
--- /dev/null
+++ b/resources/stress_test.enroll.ascii_proto
@@ -0,0 +1,35 @@
+description: "Simple script that just plays OK Google repeatedly to enroll"
+
+step {
+ audio_file : "okg-us-female.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 2
+}
+
+step {
+ audio_file : "okg-us-female.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 2
+}
+
+step {
+ audio_file : "heyg-us-female.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 2
+}
+
+step {
+ audio_file : "heyg-us-female.wav"
+ audio_file_sample_rate : 24000
+ delay_after : 2
+}
+
+expected_result {
+ aohd_hotword_detected : 0
+ assistant_started : 0
+ dsp_false_accept : 0
+ logcat_iteration : 1
+ software_hotword : 0
+ speaker_id_rejected : 0
+ vis_software_hotword : 0
+}
diff --git a/start_venv.sh b/start_venv.sh
new file mode 100644
index 0000000..cc2af8e
--- /dev/null
+++ b/start_venv.sh
@@ -0,0 +1,19 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+#!/bin/bash
+python -m pip install wheel
+python -m pip install requests
+python -m pip install --upgrade requests
+python -m pip install -r requirements.txt
diff --git a/stress_test.proto b/stress_test.proto
new file mode 100644
index 0000000..3ca2e84
--- /dev/null
+++ b/stress_test.proto
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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 stress_test;
+
+option optimize_for = CODE_SIZE;
+
+message LoggingEventConfig {
+ // The process name that this event can be found on, or LOGCAT to come from
+ // 'adb logcat'.
+ string source = 1;
+ // The name of the event to trigger.
+ string name = 2;
+ // A list of regexes that can be used to detect the specified event.
+ repeated string regex = 3;
+}
+
+message TimeStampConfig {
+ enum TimeStampType {
+ UNKNOWN = 0;
+ DATE = 1;
+ MONOTONIC = 2;
+ }
+ // If the type is DATE, then we assume it can be parsed by a python datetime.
+ // MONOTONIC sources are from the device's boot time.
+ TimeStampType type = 3;
+ // The regex to extract the string containing the timestamp.
+ string regex = 4;
+ // The format string to pass to the python datetime constructor for the
+ // matched regex.
+ string date_format = 5;
+ // The multiplier used on the parsed monotonic time to convert it to seconds.
+ double monotonic_to_seconds_multiplier = 6;
+}
+
+message FileConfig {
+ // The file that should be cat-ed on device to get information. Will be used
+ // by LoggingEventConfigs.
+ string source = 1;
+ // The filepath to save the output of this file on the local machine. If
+ // blank, will be discarded.
+ string destination = 2;
+ // The information needed to extract timestamps from the config.
+ TimeStampConfig time_stamp = 3;
+ // True if the file spits out a bit of the buffer that it did previously when
+ // reopened (like the logcat does).
+ bool repeats_output_on_open = 4;
+}
+
+message ProcessConfig {
+ // The command to run to produce logging output.
+ string command = 1;
+ // The name of the process to be used in LoggingEventConfigs.
+ string name = 2;
+ // The filepath to save the output of this process on the local machine. If
+ // blank, will be discarded.
+ string destination = 3;
+ // The information needed to extract timestamps from the config.
+ TimeStampConfig time_stamp = 4;
+ // Restart the process if it crashes randomly.
+ bool restart = 5;
+ // True if the file spits out a bit of the buffer that it did previously when
+ // reopened (like the logcat does).
+ bool repeats_output_on_open = 6;
+}
+
+message DeviceConfig {
+ // Commands to run once at the beginning of the test for the device.
+ repeated string setup_command = 1;
+ // Files that should have their contents monitored for events.
+ repeated FileConfig file_to_watch = 2;
+ // Files that should be moved to the local machine after every iteration of
+ // the stress test.
+ repeated FileConfig file_to_move = 3;
+ // All events that can be triggered by this device.
+ repeated LoggingEventConfig event = 5;
+ // Any processes that should be monitored for events.
+ repeated ProcessConfig daemon_process = 6;
+ // Include any other device definitions before processing the contents of this
+ // one. Note that if duplicate entries exist for the repeated elements (based
+ // on the name), only the "latest" in the include chain will be used.
+ repeated string include = 7;
+ // A list of tags that should be squelched from the logcat - useful for super
+ // spammy irrelevant logcat tags.
+ repeated string tag_to_suppress = 8;
+ // Either a TestEventConfig without a condition (but a name) that allows extra
+ // actions to take place when a specific test event occurs, or a standard test
+ // event that will be invoked regardless of which test is running.
+ repeated TestEventConfig test_event = 9;
+}
+
+message TestStep {
+ // Path to raw audio file to play during this test step.
+ string audio_file = 1;
+ // Sample rate of audio file.
+ int32 audio_file_sample_rate = 2;
+ // Number of channels in audio file.
+ int32 audio_file_num_channels = 3;
+ // The sox format of the audio file.
+ string audio_file_format = 4;
+ // Command to run on this test step.
+ string command = 5;
+ // How many seconds to wait before executing the command/playing the sound in
+ // this step.
+ float delay_before = 6;
+ // How many seconds to wait after executing the command/playing the sound in
+ // this step.
+ float delay_after = 7;
+}
+
+message ExpectedResult {
+ int32 aohd_hotword_detected = 1;
+ int32 assistant_started = 2;
+ int32 dsp_false_accept = 3;
+ int32 logcat_iteration = 4;
+ int32 software_hotword = 5;
+ int32 speaker_id_rejected = 6;
+ int32 vis_software_hotword = 7;
+}
+
+message TestEventConfig {
+ // The name of the event. (Used for readability/allowing devices to add custom
+ // actions when an event of a specific name occurs).
+ string name = 1;
+ // An eval condition that should only be true when the actions in this event
+ // should be executed.
+ string condition = 2;
+ // List of actions to take when the condition is satisfied. BUGREPORT, NOTIFY,
+ // REMOVE_DEVICE and ABORT are all special values that take a bugreport, send
+ // an email, remove the device from the test or stops stress testing
+ // respectively.
+ repeated string action = 3;
+}
+
+message StressTestConfig {
+ // Description of what the test does.
+ string description = 1;
+ // List of steps to take in sequence repeatedly to do the stress test.
+ repeated TestStep step = 2;
+ // Events that trigger special actions on the device when the condition is
+ // satisfied.
+ repeated TestEventConfig event = 3;
+ // Commands that should be run at the beginning of the test to setup the
+ // device.
+ repeated string setup_command = 4;
+ // Definition of Expected test result
+ repeated ExpectedResult expected_result = 5;
+}
+
+message EventLogDetails {
+ int32 iteration = 1;
+ string name = 2;
+}
+
+message EventLog {
+ repeated EventLogDetails event = 1;
+}
+
+message DeviceIdentifier {
+ string device_type = 1;
+ string serial_number = 2;
+}
+
+message StressTestInfo {
+ // Metadata info describing the devices involved in the stress test, and a bit
+ // about what test was actually run.
+ string test_name = 1;
+ string test_description = 2;
+ string uuid = 3;
+ repeated DeviceIdentifier device = 4;
+}
diff --git a/stress_test.py b/stress_test.py
new file mode 100644
index 0000000..1e9bfad
--- /dev/null
+++ b/stress_test.py
@@ -0,0 +1,1049 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+"""Stress test utility for repeating actions repeatedly on android devices.
+
+Configures multiple devices to simultaneously run through the same set of
+actions over and over, while keeping logs from various sources. Primarily
+designed for playing audio to the devices and scanning their log output for
+events, while running other adb commands in between.
+"""
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import datetime
+from email import encoders
+from email.mime import text
+import email.mime.base as base
+import email.mime.multipart as multipart
+import logging
+import mimetypes
+import os
+import platform
+import re
+import shlex
+import signal
+import smtplib
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import uuid
+import wave
+from absl import app
+from absl import flags
+import pexpect
+import queue
+import stress_test_common
+import stress_test_pb2
+from google.protobuf import text_format
+
+_SUMMARY_LINES = "-" * 73
+
+if sys.platform.startswith("win"):
+ pexpect = None
+
+_SUMMARY_COLUMNS = (
+ "| Event Type | Event Count | Consecutive no event |")
+_SUMMARY_COL_FORMATT = "|%-25.25s|% 22d|% 22d|"
+
+FLAGS = flags.FLAGS
+flags.DEFINE_string("notification_address", "",
+ "Email address where to send notification events. Will "
+ "default to $USER@google.com if not provided. No emails "
+ "will be sent if suppress_notification_emails is True.")
+flags.DEFINE_bool("suppress_notification_emails", False,
+ "Prevents emails from being sent as notifications if True.")
+flags.DEFINE_string("test_name", None,
+ "Name of stress test to run. For example, if you set this "
+ "to 'dsp_trigger_sw_rejection', the stress test in "
+ "'stress_test.dsp_trigger_sw_rejection.ascii_proto' will "
+ "be loaded and executed.")
+# flags.mark_flag_as_required("test_name")
+flags.DEFINE_string("output_root", "./",
+ "Path where directory should be generated containing all "
+ "logs from devices and moved files.")
+flags.DEFINE_integer("num_iterations", None,
+ "If set to a positive number, the number of iterations of "
+ "the stress test to run. Otherwise, the test runs "
+ "forever.")
+flags.DEFINE_list("devices", [],
+ "Serial numbers of devices that should be included in the "
+ "stress test. If empty, all devices will be used.")
+flags.DEFINE_integer("print_summary_every_n", 10,
+ "Prints the summary to the log file every n iterations.")
+
+flags.DEFINE_string("email_sender_address", "",
+ "Account to use for sending notification emails.")
+flags.DEFINE_string("email_sender_password", "",
+ "Password to use for notification email account.")
+flags.DEFINE_string("email_smtp_server", "smtp.gmail.com",
+ "SMTP server to use for sending notification emails.")
+flags.DEFINE_integer("email_smtp_port", 465,
+ "Port to use for the notification SMTP server.")
+flags.DEFINE_integer("device_settle_time", 5,
+ "Time to wait for devices to settle.")
+flags.DEFINE_bool("use_sox", platform.system() != "Windows",
+ "Use sox for playback, otherwise, attempt to use platform "
+ "specific features.")
+flags.DEFINE_bool("attach_bugreport", True,
+ "Attach bugreport to email if test failed.")
+flags.DEFINE_bool("delete_data_dir", False,
+ "If true, code will delete all the files generated by this "
+ "test at the end.")
+
+if platform.system().startswith("CYGWIN"):
+ FLAGS.device_settle_time = 30
+
+
+def QueueWorker(worker_queue):
+ while True:
+ work = worker_queue.get()
+ try:
+ work()
+ except: # pylint:disable=bare-except
+ logging.exception("Exception in worker queue - task remains uncompleted.")
+ worker_queue.task_done()
+
+
+def SendNotificationEmail(subject, body, bugreport=None):
+ """Sends an email with the specified subject and body.
+
+ Also attach bugreport if bugreport location is provided as argument
+
+ Args:
+ subject: Subject of the email.
+ body: Body of the email.
+ bugreport: If provided, it will be attach to the email.
+ """
+ if FLAGS.suppress_notification_emails:
+ logging.info("Email with subject '%s' has been suppressed", subject)
+ return
+ try:
+ # Assemble the message to send.
+ recpient_address = FLAGS.notification_address
+ message = multipart.MIMEMultipart("alternative")
+ message["From"] = "Stress Test on %s" % socket.gethostname()
+ message["To"] = recpient_address
+ message["Subject"] = subject
+ message.attach(text.MIMEText(body, "plain"))
+ message.attach(text.MIMEText("<pre>%s</pre>" % body, "html"))
+
+ if FLAGS.attach_bugreport and bugreport:
+ # buildozer: disable=unused-variable
+ ctype, _ = mimetypes.guess_type(bugreport)
+ maintype, subtype = ctype.split("/", 1)
+ with open(bugreport, "rb") as fp:
+ att = base.MIMEBase(maintype, subtype)
+ att.set_payload(fp.read())
+ encoders.encode_base64(att)
+ att.add_header("Content-Disposition", "attachment", filename=bugreport)
+ message.attach(att)
+
+ # Send the message from our special account.
+ server = smtplib.SMTP_SSL(FLAGS.email_smtp_server, FLAGS.email_smtp_port)
+ server.login(FLAGS.email_sender_address, FLAGS.email_sender_password)
+ server.sendmail(FLAGS.email_sender_address, recpient_address,
+ message.as_string())
+ server.quit()
+ logging.info("Email with subject '%s' has been sent", subject)
+ except: # pylint:disable=bare-except
+ logging.exception("Failed to send notification email")
+
+
+class ProcessLogger(threading.Thread):
+
+ class EventScanner(object):
+
+ def __init__(self, name, process_name, regexes):
+ """Struct to store the data about an event.
+
+ Args:
+ name: Name of event.
+ process_name: Name of the process that is being logged.
+ regexes: An iteratable of regex strings that indicate an event has
+ happened.
+ """
+
+ self.name = name
+ self.process_name = process_name
+ self.searches = [re.compile(regex).search for regex in regexes]
+ self.count = 0
+
+ def ScanForEvent(self, line, lock=None):
+ """Checks the line for matches. If found, updates the internal counter."""
+
+ for search in self.searches:
+ if search(line.decode("utf-8")):
+ # Grab the lock (if provided), update the counter, and release it.
+ if lock: lock.acquire()
+ self.count += 1
+ if lock: lock.release()
+ logging.info("Event '%s' detected on %s", self.name,
+ self.process_name)
+
+ def __init__(self, name, command, output, events,
+ restart_process, repeats_output_when_opened):
+ """Threaded class that monitors processes for events, and logs output.
+
+ Args:
+ name: The name of the process being logged.
+ command: A list of arguments to be passed to the subprocess to execute.
+ output: Name of output file to write process stdout to. If blank or None,
+ will not be generated.
+ events: An iterable of LoggingEventConfigs to look for in the output.
+ restart_process: Restart the process if it terminates by itself. This
+ should typically be true, but false for processes that only should be
+ run once and have their output logged.
+ repeats_output_when_opened: Set to true if the process will repeat the
+ output of a previous call when it is restarted. This will prevent
+ duplicate lines from being logged.
+ """
+ super(ProcessLogger, self).__init__()
+ self.name = name
+ self.command = command
+ self.restart_process = restart_process
+ self.repeats_output_when_opened = repeats_output_when_opened
+ self.process = None
+ self.lock = threading.Lock()
+ self.looking = False
+
+ # Compile the list of regexes that we're supposed to be looking for.
+ self.events = []
+ for event in events:
+ self.events.append(ProcessLogger.EventScanner(event.name, self.name,
+ event.regex))
+
+ if output:
+ stress_test_common.MakeDirsIfNeeded(os.path.dirname(output))
+ self.output_fp = open(output, "w")
+ logging.info("Logging device info to %s", output)
+ else:
+ self.output_fp = None
+
+ def GetEventCountsSinceLastCall(self):
+ """Returns the counts of all events since this method was last called."""
+ event_map = {}
+ self.lock.acquire()
+ for event in self.events:
+ event_map[event.name] = event.count
+ event.count = 0
+ self.lock.release()
+ return event_map
+
+ def run(self):
+ last_line = None
+ should_log = True
+ first_run = True
+ self.lock.acquire()
+ last_run_time = 0
+ while self.restart_process:
+ self.lock.release()
+ if not first_run:
+ logging.info("Restarting process %s", "".join(str(self.command)))
+ time_since_last_run = datetime.datetime.now() - last_run_time
+ if time_since_last_run.total_seconds() < 1.0:
+ needed_delay = 1.0 - time_since_last_run.total_seconds()
+ logging.info("Delaying for %.2f seconds", needed_delay)
+ time.sleep(needed_delay)
+ else:
+ first_run = False
+
+ try:
+ if pexpect:
+ self.process = pexpect.spawn(" ".join(self.command), timeout=None)
+ output_source = self.process
+ else:
+ self.process = subprocess.Popen(self.command, stdout=subprocess.PIPE)
+ output_source = self.process.stdout
+ last_run_time = datetime.datetime.now()
+ for line in output_source:
+ # If the process we're logging likes to repeat its output, we need to
+ # look for the last line we saw before we start doing anything with
+ # these lines anymore.
+ if self.repeats_output_when_opened:
+ if not should_log:
+ if last_line == line:
+ should_log = True
+ continue
+
+ if self.output_fp:
+ self.output_fp.write(line.decode("utf-8").rstrip())
+ self.output_fp.write("\n")
+
+ # Loop through all events we're watching for, to see if they occur on
+ # this line. If they do, update the fact that we've seen this event.
+ for event in self.events:
+ if self.looking:
+ event.ScanForEvent(line, lock=self.lock)
+ last_line = line
+ except: # pylint:disable=bare-except
+ logging.exception("Exception encountered running process")
+ finally:
+ if pexpect:
+ self.process.terminate()
+ else:
+ self.process.send_signal(signal.SIGTERM)
+ should_log = False
+ self.lock.acquire()
+ self.lock.release()
+ if pexpect:
+ if self.process.exitstatus is not None:
+ logging.info("Process finished - exit code %d", self.process.exitstatus)
+ else:
+ logging.info("Process finished - signal code %d",
+ self.process.signalstatus)
+ else:
+ if self.process.returncode is not None:
+ logging.info("Process finished - return code %d",
+ self.process.returncode)
+ else:
+ logging.info("Process finished - no return code")
+
+ def StopLogging(self):
+ if self.process:
+ self.lock.acquire()
+ self.restart_process = False
+ self.lock.release()
+
+ if pexpect:
+ self.process.kill(signal.SIGHUP)
+ self.process.kill(signal.SIGINT)
+ else:
+ self.process.send_signal(signal.SIGTERM)
+
+
+class Device(object):
+
+ SECONDS_TO_SLEEP_DURING_ROOT = 0.5
+
+ def __init__(self, serial_number, output_root, test_events, expected_result):
+ """Responsible for monitoring a specific device, and pulling files from it.
+
+ The actual work of the constructor will be handled asynchronously, you must
+ call WaitForTasks() before using the device.
+
+ Args:
+ serial_number: The device serial number.
+ output_root: The directory where to output log files/anything pulled from
+ the device.
+ test_events: The events (with conditions) that come from the StressTest
+ that should be evaluated at every iteration, along with a list of
+ actions to take when one of these events occur. For example, if there
+ have not been any detected hotword triggers, a bugreport can be
+ generated.
+ expected_result: Expected event count to pass the test.
+ """
+ self.serial_number = serial_number
+ self.output_root = output_root
+ self.cmd_string_replacements = {}
+ self.iteration = 0
+ self.cmd_string_replacements["iteration"] = 0
+ self.cmd_string_replacements["serial_number"] = serial_number
+ self.cmd_string_replacements["output_root"] = output_root
+ self.name = None
+ self.process_loggers = []
+ self.event_log = stress_test_pb2.EventLog()
+ self.cnt_per_iteration = expected_result
+
+ # Prepare the work queue, and offload the rest of the init into it.
+ self.work_queue = queue.Queue()
+ self.worker = threading.Thread(target=QueueWorker, args=[self.work_queue])
+ self.worker.daemon = True
+ self.worker.name = self.name
+ self.worker.start()
+ self.abort_requested = False
+ self.remove_device = False
+ self.test_events = test_events
+
+ self.work_queue.put(self.__init_async__)
+
+ def __init_async__(self):
+ # Get the device type, and append it to the serial number.
+ self.device_type = self.Command(["shell", "getprop",
+ "ro.product.name"]).strip().decode("utf-8")
+ self.name = "%s_%s" % (self.device_type, self.serial_number)
+ self.worker.name = self.name
+ self.cmd_string_replacements["device"] = self.name
+ logging.info("Setting up device %s", self.name)
+
+ config = stress_test_common.LoadDeviceConfig(self.device_type,
+ self.serial_number)
+
+ # Get the device ready.
+ self.Root()
+
+ # Run any setup commands.
+ for cmd in config.setup_command:
+ result = self.Command(
+ shlex.split(cmd % self.cmd_string_replacements)).strip()
+ if result:
+ for line in result.splitlines():
+ logging.info(line)
+
+ self.files_to_move = config.file_to_move
+
+ self.event_names = set([event.name for event in config.event])
+ self.event_counter = {name: 0 for name in self.event_names}
+ self.iterations_since_event = {name: 0 for name in self.event_names}
+
+ for file_to_watch in config.file_to_watch:
+ # Are there any events that match up with this file?
+ events = [x for x in config.event if x.source == file_to_watch.source]
+
+ if file_to_watch.source == "LOGCAT":
+ command = [
+ "adb", "-s", self.serial_number, "logcat", "-v", "usec", ""
+ ]
+ command.extend(["%s:S" % tag for tag in config.tag_to_suppress])
+ name = "logcat_" + self.serial_number
+ else:
+ command = [
+ "adb", "-s", self.serial_number, "shell",
+ "while : ; do cat %s 2>&1; done" % file_to_watch.source
+ ]
+ name = "%s_%s" % (os.path.basename(
+ file_to_watch.source), self.serial_number)
+
+ process_logger = ProcessLogger(
+ name, command, os.path.join(
+ self.output_root,
+ file_to_watch.destination % self.cmd_string_replacements),
+ events, True, file_to_watch.repeats_output_on_open)
+ self.process_loggers.append(process_logger)
+ process_logger.start()
+
+ # Add any of the background processes.
+ for daemon_process in config.daemon_process:
+ # Are there any events that match up with this file?
+ events = [x for x in config.event if x.source == daemon_process.name]
+ command = shlex.split(
+ daemon_process.command % self.cmd_string_replacements)
+ if daemon_process.destination:
+ output = os.path.join(
+ self.output_root,
+ daemon_process.destination % self.cmd_string_replacements)
+ else:
+ output = None
+ name = "%s_%s" % (daemon_process.name, self.serial_number)
+ process_logger = ProcessLogger(name, command, output, events,
+ daemon_process.restart,
+ daemon_process.repeats_output_on_open)
+ self.process_loggers.append(process_logger)
+ process_logger.start()
+
+ # Build up the list of events we can actually process.
+ self.__UpdateEventCounters(number_of_iterations=0)
+ test_events = self.test_events
+ self.test_events = []
+ for event in test_events:
+ try:
+ eval(event.condition, # pylint:disable=eval-used
+ {"__builtins__": None}, self.__ValuesInEval())
+ self.test_events.append(event)
+ except Exception as err: # pylint:disable=broad-except
+ logging.error("Test event %s is not compatible with %s", event.name,
+ self.name)
+ logging.error(str(err))
+ # Make sure that device specific events don't have conditions.
+ self.device_events = []
+ for event in config.test_event:
+ if not event.name:
+ logging.error("Device %s test event is missing a name", self.name)
+ continue
+ if event.condition:
+ self.test_events.append(event)
+ else:
+ self.device_events.append(event)
+
+ def StartLookingForEvents(self):
+ """Starts all child ProcessLoggers to start looking for events."""
+ for process_logger in self.process_loggers:
+ process_logger.looking = True
+
+ def __ValuesInEval(self):
+ values_in_eval = {key: value for key, value
+ in list(self.event_counter.items())}
+ for key, value in list(self.iterations_since_event.items()):
+ values_in_eval["iterations_since_%s" % key] = value
+ return values_in_eval
+
+ def __GetExpectedEventCount(self, event):
+ if event == "logcat_iteration":
+ return -1
+ try:
+ event_cnt = getattr(self.cnt_per_iteration, event)
+ except AttributeError:
+ event_cnt = -1
+ logging.exception("%s is not an attribute of expected_result", event)
+ return event_cnt
+
+ def __UpdateEventCounters(self, number_of_iterations=1):
+ # Update the event counters
+ visited_events = set()
+ error_log = []
+ for process_logger in self.process_loggers:
+ events = process_logger.GetEventCountsSinceLastCall()
+ for event, count in list(events.items()):
+ # Print log when there is any missed event
+ expected_count = self.__GetExpectedEventCount(event)
+
+ if expected_count > 0:
+ if count > expected_count * number_of_iterations:
+ logging.info(
+ "[STRESS_TEST] In iteration %d, got duplicated %s : %d",
+ self.iteration, self.name, count)
+ logging.info("[STRESS_TEST] Will count only : %d",
+ expected_count * number_of_iterations)
+ count = expected_count * number_of_iterations
+
+ if count:
+ self.event_counter[event] += count
+ visited_events.add(event)
+
+ if expected_count >= 0:
+ if expected_count * number_of_iterations != count:
+ error_log.append(
+ _SUMMARY_COL_FORMATT %
+ (event, count, expected_count * number_of_iterations))
+
+ # Go clear all the events that weren't consecutive.
+ for event in self.iterations_since_event:
+ if event in visited_events:
+ self.iterations_since_event[event] = 0
+ else:
+ self.iterations_since_event[event] += number_of_iterations
+
+ if error_log:
+ logging.info(_SUMMARY_LINES)
+ logging.info(" iteration %d : Something wrong in %s.",
+ self.iteration, self.name)
+ logging.info(_SUMMARY_LINES)
+ logging.info(_SUMMARY_COLUMNS)
+ logging.info(_SUMMARY_LINES)
+ for line in error_log:
+ logging.info(line)
+ logging.info(_SUMMARY_LINES)
+
+ def ProcessEvents(self):
+ """Updates the event_counter and iterations_since_event maps."""
+ self.work_queue.put(self.__ProcessEventsAsync)
+
+ def __ProcessEventsAsync(self):
+ # Move any files to the local machine that should be moved.
+ if self.files_to_move:
+ for file_to_move in self.files_to_move:
+ try:
+ self.Command(["pull", file_to_move.source, file_to_move.destination])
+ except: # pylint:disable=bare-except
+ logging.exception("Failed to pull %s", file_to_move.source)
+
+ self.__UpdateEventCounters()
+
+ for event in self.test_events:
+ if eval(event.condition, # pylint:disable=eval-used
+ {"__builtins__": None}, self.__ValuesInEval()):
+ logging.info("Condition has been met for event '%s'", event.name)
+ # Write the updated event log.
+ event_log_details = self.event_log.event.add()
+ event_log_details.iteration = self.iteration
+ event_log_details.name = event.name
+ with open(os.path.join(self.output_root,
+ "%s_event_log.ascii_proto" % self.name),
+ "w") as fp:
+ text_format.PrintMessage(self.event_log, fp)
+
+ # Do whatever other actions that are part of the event.
+ self.__ProcessEventActionQueue(event)
+
+ # Run any device specific actions for this event.
+ for device_event in self.device_events:
+ if device_event.name == event.name:
+ self.__ProcessEventActionQueue(device_event)
+
+ # Set up the next iteration.
+ self.iteration += 1
+ self.cmd_string_replacements["iteration"] = self.iteration
+
+ def __ProcessEventActionQueue(self, event):
+ bugreport = None
+ for action in event.action:
+ if action == "BUGREPORT":
+ bugreport = self.TakeBugReport()
+ elif action.startswith("DUMPSYS "):
+ self.CaptureDumpsys(action[action.find(" ") + 1:])
+ elif action == "NOTIFY":
+ SendNotificationEmail(
+ "%s had event '%s' occur" % (self.name, event.name),
+ "\n".join(["Current Summary:"] + self.GetSummaryLines()), bugreport)
+ elif action == "REMOVE_DEVICE":
+ logging.info("Removing %s from the test", self.serial_number)
+ self.remove_device = True
+ elif action == "ABORT":
+ logging.info("Abort requested")
+ self.abort_requested = True
+ else:
+ action %= self.cmd_string_replacements
+ logging.info("Running command %s on %s", action, self.name)
+ result = self.Command(shlex.split(action)).strip()
+ if result:
+ for line in result.splitlines():
+ logging.info(line)
+
+ def Root(self):
+ self.Command(["root"])
+ time.sleep(Device.SECONDS_TO_SLEEP_DURING_ROOT)
+ self.Command(["wait-for-device"])
+ time.sleep(Device.SECONDS_TO_SLEEP_DURING_ROOT)
+
+ def Stop(self):
+ """Stops all file loggers attached to this device."""
+ for process_logger in self.process_loggers:
+ process_logger.StopLogging()
+ self.process_loggers = []
+
+ def Join(self):
+ for process_logger in self.process_loggers:
+ process_logger.join()
+ self.WaitForTasks()
+
+ def AsyncCommand(self, command, log_output=False):
+ self.work_queue.put(
+ lambda: self.__AsyncCommand(command, log_output=log_output))
+
+ def __AsyncCommand(self, command, log_output=False):
+ result = self.Command(command).strip()
+ if result and log_output:
+ for line in result.splitlines():
+ logging.info(line.decode("utf-8"))
+
+ def Command(self, command):
+ """Runs the provided command on this device."""
+ if command[0] in {"bugreport", "root", "wait-for-device", "shell",
+ "logcat"}:
+ return subprocess.check_output(
+ ["adb", "-s", self.serial_number] + command)
+ elif command[0] == "DUMPSYS":
+ self.CaptureDumpsys(command[1])
+ return ""
+ elif command[0] == "pull":
+ try:
+ files = subprocess.check_output(
+ ["adb", "-s", self.serial_number, "shell", "ls", command[1]]
+ ).strip().splitlines()
+ except subprocess.CalledProcessError:
+ return ""
+ if len(files) == 1 and "No such file or directory" in files[0]:
+ return ""
+ for source_file in files:
+ destination = os.path.join(self.output_root,
+ command[2] % self.cmd_string_replacements)
+ stress_test_common.MakeDirsIfNeeded(os.path.dirname(destination))
+ logging.info("Moving %s from %s to %s", source_file, self.name,
+ destination)
+ subprocess.check_output(["adb", "-s", self.serial_number, "pull",
+ source_file, destination])
+ if FLAGS.delete_data_dir:
+ subprocess.check_output([
+ "adb", "-s", self.serial_number, "shell", "rm", "-rf", source_file
+ ])
+ return ""
+ else:
+ return subprocess.check_output(command)
+
+ def TakeBugReport(self):
+ logging.info("Capturing bugreport on %s", self.name)
+ bugreport = os.path.join(self.output_root,
+ "%s_bugreport_iteration_%06d.zip" %
+ (self.name, self.iteration))
+ sdk = int(self.Command(
+ ["shell", "getprop", "ro.build.version.sdk"]).strip())
+ if sdk >= 24: # SDK 24 = Android N
+ with open(bugreport, "w") as bugreport_fp:
+ bugreport_fp.write(self.Command(["bugreport", bugreport]))
+ else:
+ bugreport_txt = os.path.join(self.output_root,
+ "%s_bugreport_iteration_%06d.txt" %
+ (self.name, self.iteration))
+ with open(bugreport_txt, "w") as bugreport_fp:
+ bugreport_fp.write(self.Command(["bugreport"]))
+ self.Command(["zip", bugreport, bugreport_txt])
+
+ self.Command(["pull", "/data/anr/traces.txt",
+ "%s_traces_iteration_%06d.txt" % (self.name, self.iteration)])
+ self.Command(["pull", "/data/anr/traces.txt.bugreport",
+ "%s_traces_iteration_%06d.txt.bugreport" % (self.name,
+ self.iteration)])
+ return bugreport
+
+ def CaptureDumpsys(self, dumpsys_unit):
+ logging.info("Taking dumpsys %s on %s", dumpsys_unit, self.name)
+ stress_test_common.MakeDirsIfNeeded(os.path.join(self.output_root,
+ self.name))
+ with open(os.path.join(self.output_root, self.name,
+ "%s_%06d.txt" % (dumpsys_unit, self.iteration)),
+ "w") as dumpsys_fp:
+ dumpsys_fp.write(self.Command(["shell", "dumpsys", dumpsys_unit]))
+
+ def WaitForTasks(self):
+ self.work_queue.join()
+
+ def GetSummaryLines(self):
+ lines = [
+ "Device {}".format(self.name),
+ _SUMMARY_LINES, _SUMMARY_COLUMNS, _SUMMARY_LINES
+ ]
+ for event, count in sorted(self.event_counter.items()):
+ lines.append(_SUMMARY_COL_FORMATT % (
+ event, count, self.iterations_since_event[event]))
+ lines.append(_SUMMARY_LINES)
+ return lines
+
+
+def RunAsyncCommand(devices, command):
+ """Helper function for running async commands on many devices."""
+ for device in devices:
+ device.AsyncCommand(command)
+ for device in devices:
+ device.WaitForTasks()
+
+
+class StressTest(object):
+ """Manages dispatching commands to devices/playing audio and events."""
+
+ def __init__(self, output_root, test_name):
+ self.output_root = output_root
+ self.devices = []
+ self.test_name = test_name
+ config = stress_test_pb2.StressTestConfig()
+ config_contents = stress_test_common.GetResourceContents(
+ os.path.join(stress_test_common.RESOURCE_DIR,
+ "stress_test.%s.ascii_proto" % test_name))
+ text_format.Merge(config_contents, config)
+ self.events = config.event
+ self.setup_commands = config.setup_command
+ self.steps = config.step
+ self.audio_tempfiles = {}
+ self.uuid = str(uuid.uuid4())
+ self.expected_result = None
+ self.iteration = 0
+ if config.expected_result:
+ self.expected_result = config.expected_result[0]
+
+ # Place all the audio files into temp files.
+ for step in self.steps:
+ if step.audio_file and step.audio_file not in self.audio_tempfiles:
+ # We can't delete the temp file on windows, since it gets nuked too
+ # early.
+ audio_tempfile = tempfile.NamedTemporaryFile(
+ delete=(platform.system() != "Windows"),
+ dir="." if platform.system().startswith("CYGWIN") else None
+ )
+ if platform.system().startswith("CYGWIN"):
+ audio_tempfile.name = os.path.basename(audio_tempfile.name)
+ self.audio_tempfiles[step.audio_file] = audio_tempfile
+ if FLAGS.use_sox:
+ # Write out the raw PCM samples as a wave file.
+ audio_tempfile.write(
+ stress_test_common.GetResourceContents(step.audio_file))
+ else:
+ # Make a temporary wave file for playout if we can't use sox.
+ wavefile = wave.open(audio_tempfile, "wb")
+ if step.audio_file_sample_rate <= 0:
+ step.audio_file_sample_rate = 16000
+ wavefile.setframerate(step.audio_file_sample_rate)
+ if step.audio_file_num_channels <= 0:
+ step.audio_file_num_channels = 1
+ wavefile.setnchannels(step.audio_file_num_channels)
+ if not step.audio_file_format:
+ wavefile.setsampwidth(2)
+ elif step.audio_file_format == "s8":
+ wavefile.setsampwidth(1)
+ elif step.audio_file_format == "s16":
+ wavefile.setsampwidth(2)
+ elif step.audio_file_format == "s32":
+ wavefile.setsampwidth(4)
+ else:
+ raise RuntimeError(
+ "Unsupported wave file format for %s" % step.audio_file)
+ wavefile.writeframes(stress_test_common.GetResourceContents(
+ step.audio_file))
+ wavefile.close()
+ audio_tempfile.flush()
+
+ if platform.system() == "Windows":
+ audio_tempfile.close()
+
+ # Create all the devices that are attached to this machine.
+ for serial_number in self.GetActiveSerialNumbers():
+ self.devices.append(
+ Device(serial_number, output_root, self.events, self.expected_result))
+ if not self.devices:
+ raise app.UsageError("No devices connected")
+
+ self.devices.sort(key=lambda x: x.name)
+
+ # Make sure every device is done with their work for setup.
+ for device in self.devices:
+ device.WaitForTasks()
+
+ # Write out the info meta-data proto. Useful for doing analysis of the logs
+ # after the stress test has completed.
+ stress_test_info = stress_test_pb2.StressTestInfo()
+ stress_test_info.test_name = self.test_name
+ stress_test_info.test_description = config.description
+ stress_test_info.uuid = self.uuid
+ for device in self.devices:
+ device_pb = stress_test_info.device.add()
+ device_pb.device_type = device.device_type
+ device_pb.serial_number = device.serial_number
+
+ text_format.PrintMessage(stress_test_info, open(os.path.join(
+ self.output_root, "stress_test_info.ascii_proto"), "w"))
+
+ def GetActiveSerialNumbers(self):
+ serial_numbers = []
+ for line in sorted(
+ subprocess.check_output(["adb", "devices"]).splitlines()):
+ if line.endswith(b"device"):
+ serial_number = line.split()[0].strip()
+ if FLAGS.devices and serial_number not in FLAGS.devices:
+ continue
+ serial_numbers.append(serial_number.decode("utf-8"))
+ return serial_numbers
+
+ def Start(self):
+ logging.info("Waiting for devices to settle")
+ time.sleep(5)
+ # Make a copy of the device list, as we'll be modifying this actual list.
+ devices = list(self.devices)
+ dropped_devices = []
+
+ # If we have any setup commands, run them.
+ for command in self.setup_commands:
+ logging.info("Running command %s", command)
+ # Can't use the async command helper function since we need to get at
+ # the device cmd_string_replacements.
+ for device in devices:
+ device.AsyncCommand(
+ shlex.split(command % device.cmd_string_replacements),
+ log_output=True)
+ for device in devices:
+ device.WaitForTasks()
+
+ for device in devices:
+ device.StartLookingForEvents()
+ device.AsyncCommand(["shell", "log", "-t", "STRESS_TEST",
+ "Starting {%s} TZ=$(getprop persist.sys.timezone) "
+ "YEAR=$(date +%%Y)" % self.uuid], True)
+ self.iteration = 0
+ while True:
+ logging.info("Starting iteration %d", self.iteration)
+ # Perform all the actions specified in the test.
+ RunAsyncCommand(devices, [
+ "shell", "log", "-t", "STRESS_TEST",
+ "Performing iteration %d $(head -n 3 "
+ "/proc/timer_list | tail -n 1)" % self.iteration
+ ])
+
+ for step in self.steps:
+ if step.delay_before:
+ logging.info("Waiting for %.2f seconds", step.delay_before)
+ time.sleep(step.delay_before)
+
+ if step.audio_file:
+ logging.info("Playing %s", step.audio_file)
+ RunAsyncCommand(devices, ["shell", "log", "-t", "STRESS_TEST",
+ "Playing %s" % step.audio_file])
+
+ if FLAGS.use_sox:
+ subprocess.check_call(["sox", "-q",
+ self.audio_tempfiles[step.audio_file].name,
+ "-d"])
+ elif platform.system() == "Windows":
+ import winsound # pylint:disable=g-import-not-at-top
+ winsound.PlaySound(self.audio_tempfiles[step.audio_file].name,
+ winsound.SND_FILENAME | winsound.SND_NODEFAULT)
+ else:
+ raise app.RuntimeError("Unsupported platform for audio playback")
+
+ if step.command:
+ logging.info("Running command %s", step.command)
+ # Can't use the async command helper function since we need to get at
+ # the device cmd_string_replacements.
+ for device in devices:
+ device.AsyncCommand(
+ shlex.split(step.command % device.cmd_string_replacements),
+ log_output=True)
+ for device in devices:
+ device.WaitForTasks()
+
+ if step.delay_after:
+ logging.info("Waiting for %.2f seconds", step.delay_after)
+ time.sleep(step.delay_after)
+
+ RunAsyncCommand(devices, [
+ "shell", "log", "-t", "STRESS_TEST",
+ "Iteration %d complete $(head -n 3 "
+ "/proc/timer_list | tail -n 1)" % self.iteration
+ ])
+ self.iteration += 1
+
+ # TODO(somebody): Sometimes the logcat seems to get stuck and buffers for
+ # a bit. This throws off the event counts, so we should probably add some
+ # synchronization rules before we trigger any events.
+
+ # Go through each device, update the event counter, and see if we need to
+ # trigger any events.
+ devices_to_remove = []
+ abort_requested = False
+ active_devices = self.GetActiveSerialNumbers()
+ for device in devices:
+ if device.serial_number in active_devices:
+ device.ProcessEvents()
+ else:
+ logging.error("Dropped device %s", device.name)
+ SendNotificationEmail(
+ "Dropped device %s" % device.name,
+ "Device %s is not longer present in the system" % device.name)
+ dropped_devices.append(device)
+ devices_to_remove.append(device)
+
+ # Check to see if any of the dropped devices have come back. If yes, grab
+ # a bug report.
+ for device in dropped_devices:
+ if device.serial_number in active_devices:
+ logging.info("Device %s reappeared", device.name)
+ device.Root()
+ device.TakeBugReport()
+
+ dropped_devices = [d for d in dropped_devices
+ if d.serial_number not in active_devices]
+
+ for device in devices:
+ device.WaitForTasks()
+ if device.remove_device:
+ devices_to_remove.append(device)
+ if device.abort_requested:
+ abort_requested = True
+
+ # Remove devices from our list of things to monitor if they've been marked
+ # for deletion.
+ if devices_to_remove:
+ for device in devices_to_remove:
+ device.Stop()
+ devices = [d for d in devices if d not in devices_to_remove]
+
+ # Print out the iteration summary.
+ if self.iteration % FLAGS.print_summary_every_n == 0:
+ for line in self.GetSummaryLines():
+ logging.info(line)
+
+ # See if we need to break out of the outer loop.
+ if abort_requested or not devices:
+ break
+ if FLAGS.num_iterations:
+ if self.iteration >= FLAGS.num_iterations:
+ logging.info("Completed full iteration : %d", self.iteration)
+ break
+ SendNotificationEmail(
+ "Stress test %s completed" % (FLAGS.test_name),
+ "\n".join(["Summary:"] + self.GetSummaryLines()))
+
+ def Stop(self):
+ logging.debug("Stopping devices")
+ for device in self.devices:
+ device.Stop()
+ for device in self.devices:
+ device.Join()
+
+ def GetSummaryLines(self):
+ lines = [
+ _SUMMARY_LINES,
+ "Conducted %d iterations out of %d" %
+ (self.iteration, FLAGS.num_iterations),
+ _SUMMARY_LINES
+ ]
+ for device in self.devices:
+ lines.extend(device.GetSummaryLines())
+ lines.append(_SUMMARY_LINES)
+ return lines
+
+
+def main(unused_argv):
+ # Check to make sure that there are no other instances of ADB running - if
+ # there are, print a warning and wait a bit for them to see it and decide if
+ # they want to keep running, knowing that logs may be invalid.
+ try:
+ if "adb" in subprocess.check_output(["ps", "-ale"]).decode("utf-8"):
+ print("It looks like there are other instances of adb running. If these "
+ "other instances are also cating log files, you will not be "
+ "capturing everything in this stress test (so logs will be "
+ "invalid).")
+ print("Continuing in 3...", end=" ")
+ sys.stdout.flush()
+ for i in [2, 1, 0]:
+ time.sleep(1)
+ if i:
+ print("%d..." % i, end=" ")
+ else:
+ print("")
+ sys.stdout.flush()
+ except OSError:
+ print("Unexpected error:", sys.exc_info()[0])
+ if sys.platform.startswith("win"):
+ pass
+ else:
+ raise
+
+ # Make the base output directory.
+ output_root = os.path.join(FLAGS.output_root, "%s_%s" % (
+ FLAGS.test_name, datetime.datetime.now().strftime("%Y%m%d_%H%M%S")))
+ # output_root = os.path.join(FLAGS.output_root, FLAGS.test_name)
+ stress_test_common.MakeDirsIfNeeded(output_root)
+
+ # Set up logging.
+ formatter = logging.Formatter(
+ "%(levelname)-1.1s %(asctime)s [%(threadName)-16.16s] %(message)s")
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+
+ file_handler = logging.FileHandler(os.path.join(output_root,
+ "stress_test.log"))
+ file_handler.setFormatter(formatter)
+ root_logger.addHandler(file_handler)
+
+ console_handler = logging.StreamHandler()
+ console_handler.setFormatter(formatter)
+ root_logger.addHandler(console_handler)
+
+ stress_test = StressTest(output_root, FLAGS.test_name)
+ try:
+ stress_test.Start()
+ finally:
+ logging.info("Stopping device logging threads")
+ stress_test.Stop()
+ for line in stress_test.GetSummaryLines():
+ logging.info(line)
+ if FLAGS.delete_data_dir:
+ print("Deleting Data Dir")
+ subprocess.check_output(["rm", "-r", "-f", output_root])
+
+
+if __name__ == "__main__":
+ app.run(main)
diff --git a/stress_test_common.py b/stress_test_common.py
new file mode 100644
index 0000000..6bc7baa
--- /dev/null
+++ b/stress_test_common.py
@@ -0,0 +1,104 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+"""Common functions for the stress tester."""
+
+import logging
+import os
+
+from absl import flags
+import stress_test_pb2
+from google.protobuf import text_format
+
+FLAGS = flags.FLAGS
+
+flags.DEFINE_string("resource_path", None,
+ "Optional override path where to grab resources from. By "
+ "default, resources are grabbed from "
+ "stress_test_common.RESOURCE_DIR, specifying this flag "
+ "will instead result in first looking in this path before "
+ "the module defined resource directory.")
+
+RESOURCE_DIR = "resources/"
+
+
+def MakeDirsIfNeeded(path):
+ """Helper function to create all the directories on a path."""
+ if not os.path.isdir(path):
+ os.makedirs(path)
+
+
+def GetResourceContents(resource_name):
+ """Gets a string containing the named resource."""
+ # Look in the resource override folder first (just go with the basename to
+ # find the file, rather than the full path).
+ if FLAGS.resource_path:
+ path = os.path.join(FLAGS.resource_path, os.path.basename(resource_name))
+ if os.path.exists(path):
+ return open(path, "rb").read()
+
+ # If the full path exists, grab that, otherwise fall back to the basename.
+ if os.path.exists(resource_name):
+ return open(resource_name, "rb").read()
+ return open(os.path.join(RESOURCE_DIR, os.path.basename(resource_name)),
+ "rb").read()
+
+
+def LoadDeviceConfig(device_type, serial_number):
+ """Assembles a DeviceConfig proto following all includes, or the default."""
+
+ config = stress_test_pb2.DeviceConfig()
+ text_format.Merge(GetResourceContents(
+ os.path.join(RESOURCE_DIR, "device_config.common.ascii_proto")), config)
+ def RecursiveIncludeToConfig(resource_prefix, print_error):
+ """Load configurations recursively."""
+ try:
+ new_config = stress_test_pb2.DeviceConfig()
+ text_format.Merge(GetResourceContents(
+ os.path.join(RESOURCE_DIR,
+ "device_config.%s.ascii_proto" % resource_prefix)),
+ new_config)
+ for include_name in new_config.include:
+ # If we've managed to import this level properly, then we should print
+ # out any errors if we hit them on the included files.
+ RecursiveIncludeToConfig(include_name, print_error=True)
+ config.MergeFrom(new_config)
+ except IOError as err:
+ if print_error:
+ logging.error(str(err))
+
+ RecursiveIncludeToConfig(device_type, print_error=True)
+ RecursiveIncludeToConfig(serial_number, print_error=False)
+
+ def TakeOnlyLatestFromRepeatedField(message, field, key):
+ """Take only the latest version."""
+ old_list = list(getattr(message, field))
+ message.ClearField(field)
+ new_list = []
+ for i in range(len(old_list) - 1, -1, -1):
+ element = old_list[i]
+ if not any([getattr(x, key) == getattr(element, key)
+ for x in old_list[i + 1:]]):
+ new_list.append(element)
+ getattr(message, field).extend(reversed(new_list))
+
+ # We actually need to do a bit of post-processing on the proto - we only want
+ # to take the latest version for each (that way people can override stuff if
+ # they want)
+ TakeOnlyLatestFromRepeatedField(config, "file_to_watch", "source")
+ TakeOnlyLatestFromRepeatedField(config, "file_to_move", "source")
+ TakeOnlyLatestFromRepeatedField(config, "event", "name")
+ TakeOnlyLatestFromRepeatedField(config, "daemon_process", "name")
+
+ return config