From 1d8d93c8ebea5ffc912ae6da6a789e6ec35b5783 Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Sun, 8 Oct 2023 18:17:50 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E5=BD=92=E6=A1=A3=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0xdevice=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/BUILD.gn | 17 + xdevice/LICENSE | 177 ++ xdevice/OAT.xml | 62 + xdevice/README.md | 226 +++ xdevice/README_zh.md | 333 ++++ xdevice/config/acts.json | 22 + xdevice/config/ssts.json | 20 + xdevice/config/user_config.xml | 63 + xdevice/docs/Command_Run.md | 216 +++ xdevice/docs/Device_Auto_Upgrade_Guide.md | 311 ++++ xdevice/figures/icon-caution.gif | Bin 0 -> 580 bytes xdevice/figures/icon-danger.gif | Bin 0 -> 580 bytes xdevice/figures/icon-note.gif | Bin 0 -> 394 bytes xdevice/figures/icon-notice.gif | Bin 0 -> 406 bytes xdevice/figures/icon-tip.gif | Bin 0 -> 253 bytes xdevice/figures/icon-warning.gif | Bin 0 -> 580 bytes xdevice/figures/reset_device.png | Bin 0 -> 121064 bytes xdevice/figures/upgrade_1.png | Bin 0 -> 38072 bytes xdevice/figures/upgrade_2.png | Bin 0 -> 36891 bytes xdevice/figures/upgrade_3.png | Bin 0 -> 41506 bytes xdevice/figures/upgrade_4.png | Bin 0 -> 78725 bytes xdevice/figures/upgrade_5.png | Bin 0 -> 77261 bytes xdevice/figures/upgrade_6.png | Bin 0 -> 26462 bytes xdevice/lite/BUILD.gn | 17 + xdevice/plugins/__init__.py | 0 xdevice/plugins/aosp/__init__.py | 4 + xdevice/plugins/aosp/constants.py | 39 + xdevice/plugins/aosp/environment/__init__.py | 0 xdevice/plugins/aosp/environment/device.py | 536 ++++++ xdevice/plugins/aosp/environment/dmlib.py | 1131 ++++++++++++ xdevice/plugins/aosp/managers/__init__.py | 0 .../plugins/aosp/managers/manager_device.py | 406 +++++ xdevice/plugins/aosp/setup.py | 51 + xdevice/plugins/aosp/testkit/__init__.py | 0 xdevice/plugins/aosp/testkit/kit.py | 121 ++ xdevice/plugins/ios/__init__.py | 0 xdevice/plugins/ios/constants.py | 42 + xdevice/plugins/ios/environment/__init__.py | 0 xdevice/plugins/ios/environment/device.py | 344 ++++ xdevice/plugins/ios/environment/dmlib.py | 347 ++++ xdevice/plugins/ios/managers/__init__.py | 0 .../plugins/ios/managers/manager_device.py | 407 +++++ xdevice/plugins/ios/setup.py | 51 + xdevice/plugins/ios/testkit/__init__.py | 0 xdevice/plugins/ios/testkit/kit.py | 291 +++ xdevice/plugins/ohos/setup.py | 71 + xdevice/plugins/ohos/src/ohos/__init__.py | 17 + xdevice/plugins/ohos/src/ohos/constants.py | 83 + .../plugins/ohos/src/ohos/drivers/__init__.py | 17 + .../plugins/ohos/src/ohos/drivers/arkuix.py | 358 ++++ .../plugins/ohos/src/ohos/drivers/drivers.py | 1047 +++++++++++ .../ohos/src/ohos/drivers/drivers_lite.py | 1203 +++++++++++++ .../ohos/src/ohos/drivers/openharmony.py | 1263 +++++++++++++ .../ohos/src/ohos/environment/__init__.py | 17 + .../ohos/src/ohos/environment/device.py | 1085 +++++++++++ .../ohos/src/ohos/environment/device_lite.py | 556 ++++++ .../ohos/src/ohos/environment/dmlib.py | 1183 ++++++++++++ .../ohos/src/ohos/environment/dmlib_lite.py | 305 ++++ .../ohos/src/ohos/environment/emulator.py | 57 + xdevice/plugins/ohos/src/ohos/exception.py | 95 + .../ohos/src/ohos/executor/__init__.py | 17 + .../ohos/src/ohos/executor/listener.py | 102 ++ .../ohos/src/ohos/managers/__init__.py | 17 + .../ohos/src/ohos/managers/manager_device.py | 395 ++++ .../ohos/src/ohos/managers/manager_lite.py | 143 ++ .../plugins/ohos/src/ohos/parser/__init__.py | 17 + .../plugins/ohos/src/ohos/parser/parser.py | 1599 +++++++++++++++++ .../ohos/src/ohos/parser/parser_lite.py | 1092 +++++++++++ .../plugins/ohos/src/ohos/testkit/__init__.py | 17 + xdevice/plugins/ohos/src/ohos/testkit/kit.py | 1147 ++++++++++++ .../plugins/ohos/src/ohos/testkit/kit_lite.py | 755 ++++++++ xdevice/run.bat | 67 + xdevice/run.sh | 63 + xdevice/setup.py | 59 + xdevice/src/xdevice.egg-info/PKG-INFO | 10 + xdevice/src/xdevice.egg-info/SOURCES.txt | 50 + .../src/xdevice.egg-info/dependency_links.txt | 1 + xdevice/src/xdevice.egg-info/entry_points.txt | 4 + xdevice/src/xdevice.egg-info/not-zip-safe | 1 + xdevice/src/xdevice.egg-info/top_level.txt | 1 + xdevice/src/xdevice/__init__.py | 271 +++ xdevice/src/xdevice/__main__.py | 46 + xdevice/src/xdevice/_core/__init__.py | 17 + xdevice/src/xdevice/_core/command/__init__.py | 17 + xdevice/src/xdevice/_core/command/console.py | 925 ++++++++++ xdevice/src/xdevice/_core/common.py | 44 + xdevice/src/xdevice/_core/config/__init__.py | 17 + .../xdevice/_core/config/config_manager.py | 300 ++++ .../xdevice/_core/config/resource_manager.py | 166 ++ xdevice/src/xdevice/_core/constants.py | 345 ++++ xdevice/src/xdevice/_core/driver/__init__.py | 17 + .../src/xdevice/_core/driver/parser_lite.py | 93 + .../src/xdevice/_core/environment/__init__.py | 17 + .../_core/environment/device_monitor.py | 121 ++ .../xdevice/_core/environment/device_state.py | 124 ++ .../src/xdevice/_core/environment/env_pool.py | 416 +++++ .../xdevice/_core/environment/manager_env.py | 301 ++++ xdevice/src/xdevice/_core/exception.py | 149 ++ .../src/xdevice/_core/executor/__init__.py | 17 + .../src/xdevice/_core/executor/concurrent.py | 693 +++++++ .../src/xdevice/_core/executor/listener.py | 434 +++++ xdevice/src/xdevice/_core/executor/request.py | 269 +++ .../src/xdevice/_core/executor/scheduler.py | 1309 ++++++++++++++ xdevice/src/xdevice/_core/executor/source.py | 518 ++++++ xdevice/src/xdevice/_core/interface.py | 378 ++++ xdevice/src/xdevice/_core/logger.py | 500 ++++++ xdevice/src/xdevice/_core/plugin.py | 212 +++ xdevice/src/xdevice/_core/report/__init__.py | 25 + xdevice/src/xdevice/_core/report/__main__.py | 62 + xdevice/src/xdevice/_core/report/encrypt.py | 201 +++ .../xdevice/_core/report/reporter_helper.py | 1025 +++++++++++ .../xdevice/_core/report/result_reporter.py | 673 +++++++ .../xdevice/_core/report/suite_reporter.py | 380 ++++ .../_core/resource/config/user_config.xml | 63 + .../_core/resource/template/report.html | 471 +++++ .../src/xdevice/_core/resource/version.txt | 1 + xdevice/src/xdevice/_core/testkit/__init__.py | 17 + .../src/xdevice/_core/testkit/json_parser.py | 122 ++ xdevice/src/xdevice/_core/testkit/kit.py | 316 ++++ xdevice/src/xdevice/_core/utils.py | 738 ++++++++ xdevice/src/xdevice/variables.py | 177 ++ 121 files changed, 30135 insertions(+) create mode 100644 xdevice/BUILD.gn create mode 100644 xdevice/LICENSE create mode 100644 xdevice/OAT.xml create mode 100644 xdevice/README.md create mode 100644 xdevice/README_zh.md create mode 100644 xdevice/config/acts.json create mode 100644 xdevice/config/ssts.json create mode 100644 xdevice/config/user_config.xml create mode 100644 xdevice/docs/Command_Run.md create mode 100644 xdevice/docs/Device_Auto_Upgrade_Guide.md create mode 100644 xdevice/figures/icon-caution.gif create mode 100644 xdevice/figures/icon-danger.gif create mode 100644 xdevice/figures/icon-note.gif create mode 100644 xdevice/figures/icon-notice.gif create mode 100644 xdevice/figures/icon-tip.gif create mode 100644 xdevice/figures/icon-warning.gif create mode 100644 xdevice/figures/reset_device.png create mode 100644 xdevice/figures/upgrade_1.png create mode 100644 xdevice/figures/upgrade_2.png create mode 100644 xdevice/figures/upgrade_3.png create mode 100644 xdevice/figures/upgrade_4.png create mode 100644 xdevice/figures/upgrade_5.png create mode 100644 xdevice/figures/upgrade_6.png create mode 100644 xdevice/lite/BUILD.gn create mode 100644 xdevice/plugins/__init__.py create mode 100644 xdevice/plugins/aosp/__init__.py create mode 100644 xdevice/plugins/aosp/constants.py create mode 100644 xdevice/plugins/aosp/environment/__init__.py create mode 100644 xdevice/plugins/aosp/environment/device.py create mode 100644 xdevice/plugins/aosp/environment/dmlib.py create mode 100644 xdevice/plugins/aosp/managers/__init__.py create mode 100644 xdevice/plugins/aosp/managers/manager_device.py create mode 100644 xdevice/plugins/aosp/setup.py create mode 100644 xdevice/plugins/aosp/testkit/__init__.py create mode 100644 xdevice/plugins/aosp/testkit/kit.py create mode 100644 xdevice/plugins/ios/__init__.py create mode 100644 xdevice/plugins/ios/constants.py create mode 100644 xdevice/plugins/ios/environment/__init__.py create mode 100644 xdevice/plugins/ios/environment/device.py create mode 100644 xdevice/plugins/ios/environment/dmlib.py create mode 100644 xdevice/plugins/ios/managers/__init__.py create mode 100644 xdevice/plugins/ios/managers/manager_device.py create mode 100644 xdevice/plugins/ios/setup.py create mode 100644 xdevice/plugins/ios/testkit/__init__.py create mode 100644 xdevice/plugins/ios/testkit/kit.py create mode 100644 xdevice/plugins/ohos/setup.py create mode 100644 xdevice/plugins/ohos/src/ohos/__init__.py create mode 100644 xdevice/plugins/ohos/src/ohos/constants.py create mode 100644 xdevice/plugins/ohos/src/ohos/drivers/__init__.py create mode 100644 xdevice/plugins/ohos/src/ohos/drivers/arkuix.py create mode 100644 xdevice/plugins/ohos/src/ohos/drivers/drivers.py create mode 100644 xdevice/plugins/ohos/src/ohos/drivers/drivers_lite.py create mode 100644 xdevice/plugins/ohos/src/ohos/drivers/openharmony.py create mode 100644 xdevice/plugins/ohos/src/ohos/environment/__init__.py create mode 100644 xdevice/plugins/ohos/src/ohos/environment/device.py create mode 100644 xdevice/plugins/ohos/src/ohos/environment/device_lite.py create mode 100644 xdevice/plugins/ohos/src/ohos/environment/dmlib.py create mode 100644 xdevice/plugins/ohos/src/ohos/environment/dmlib_lite.py create mode 100644 xdevice/plugins/ohos/src/ohos/environment/emulator.py create mode 100644 xdevice/plugins/ohos/src/ohos/exception.py create mode 100644 xdevice/plugins/ohos/src/ohos/executor/__init__.py create mode 100644 xdevice/plugins/ohos/src/ohos/executor/listener.py create mode 100644 xdevice/plugins/ohos/src/ohos/managers/__init__.py create mode 100644 xdevice/plugins/ohos/src/ohos/managers/manager_device.py create mode 100644 xdevice/plugins/ohos/src/ohos/managers/manager_lite.py create mode 100644 xdevice/plugins/ohos/src/ohos/parser/__init__.py create mode 100644 xdevice/plugins/ohos/src/ohos/parser/parser.py create mode 100644 xdevice/plugins/ohos/src/ohos/parser/parser_lite.py create mode 100644 xdevice/plugins/ohos/src/ohos/testkit/__init__.py create mode 100644 xdevice/plugins/ohos/src/ohos/testkit/kit.py create mode 100644 xdevice/plugins/ohos/src/ohos/testkit/kit_lite.py create mode 100644 xdevice/run.bat create mode 100644 xdevice/run.sh create mode 100644 xdevice/setup.py create mode 100644 xdevice/src/xdevice.egg-info/PKG-INFO create mode 100644 xdevice/src/xdevice.egg-info/SOURCES.txt create mode 100644 xdevice/src/xdevice.egg-info/dependency_links.txt create mode 100644 xdevice/src/xdevice.egg-info/entry_points.txt create mode 100644 xdevice/src/xdevice.egg-info/not-zip-safe create mode 100644 xdevice/src/xdevice.egg-info/top_level.txt create mode 100644 xdevice/src/xdevice/__init__.py create mode 100644 xdevice/src/xdevice/__main__.py create mode 100644 xdevice/src/xdevice/_core/__init__.py create mode 100644 xdevice/src/xdevice/_core/command/__init__.py create mode 100644 xdevice/src/xdevice/_core/command/console.py create mode 100644 xdevice/src/xdevice/_core/common.py create mode 100644 xdevice/src/xdevice/_core/config/__init__.py create mode 100644 xdevice/src/xdevice/_core/config/config_manager.py create mode 100644 xdevice/src/xdevice/_core/config/resource_manager.py create mode 100644 xdevice/src/xdevice/_core/constants.py create mode 100644 xdevice/src/xdevice/_core/driver/__init__.py create mode 100644 xdevice/src/xdevice/_core/driver/parser_lite.py create mode 100644 xdevice/src/xdevice/_core/environment/__init__.py create mode 100644 xdevice/src/xdevice/_core/environment/device_monitor.py create mode 100644 xdevice/src/xdevice/_core/environment/device_state.py create mode 100644 xdevice/src/xdevice/_core/environment/env_pool.py create mode 100644 xdevice/src/xdevice/_core/environment/manager_env.py create mode 100644 xdevice/src/xdevice/_core/exception.py create mode 100644 xdevice/src/xdevice/_core/executor/__init__.py create mode 100644 xdevice/src/xdevice/_core/executor/concurrent.py create mode 100644 xdevice/src/xdevice/_core/executor/listener.py create mode 100644 xdevice/src/xdevice/_core/executor/request.py create mode 100644 xdevice/src/xdevice/_core/executor/scheduler.py create mode 100644 xdevice/src/xdevice/_core/executor/source.py create mode 100644 xdevice/src/xdevice/_core/interface.py create mode 100644 xdevice/src/xdevice/_core/logger.py create mode 100644 xdevice/src/xdevice/_core/plugin.py create mode 100644 xdevice/src/xdevice/_core/report/__init__.py create mode 100644 xdevice/src/xdevice/_core/report/__main__.py create mode 100644 xdevice/src/xdevice/_core/report/encrypt.py create mode 100644 xdevice/src/xdevice/_core/report/reporter_helper.py create mode 100644 xdevice/src/xdevice/_core/report/result_reporter.py create mode 100644 xdevice/src/xdevice/_core/report/suite_reporter.py create mode 100644 xdevice/src/xdevice/_core/resource/config/user_config.xml create mode 100644 xdevice/src/xdevice/_core/resource/template/report.html create mode 100644 xdevice/src/xdevice/_core/resource/version.txt create mode 100644 xdevice/src/xdevice/_core/testkit/__init__.py create mode 100644 xdevice/src/xdevice/_core/testkit/json_parser.py create mode 100644 xdevice/src/xdevice/_core/testkit/kit.py create mode 100644 xdevice/src/xdevice/_core/utils.py create mode 100644 xdevice/src/xdevice/variables.py diff --git a/xdevice/BUILD.gn b/xdevice/BUILD.gn new file mode 100644 index 0000000..dc9998e --- /dev/null +++ b/xdevice/BUILD.gn @@ -0,0 +1,17 @@ +# Copyright (c) 2020 Huawei Device Co., Ltd. +# 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. +import("//test/xts/tools/lite/build/suite_lite.gni") + +deploy_suite("xdevice") { + suite_name = "acts,hits,ssts" +} diff --git a/xdevice/LICENSE b/xdevice/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/xdevice/LICENSE @@ -0,0 +1,177 @@ + + 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 diff --git a/xdevice/OAT.xml b/xdevice/OAT.xml new file mode 100644 index 0000000..ebcaf24 --- /dev/null +++ b/xdevice/OAT.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/xdevice/README.md b/xdevice/README.md new file mode 100644 index 0000000..fc76bf7 --- /dev/null +++ b/xdevice/README.md @@ -0,0 +1,226 @@ +# XDevice + +- [Introduction](#section15701932113019) +- [Directory Structure](#section1791423143211) +- [Constraints](#section118067583303) +- [Usage](#section2036431583) +- [Repositories Involved](#section260848241) + +## Introduction + +XDevice, a core module of the OpenHarmony test framework, provides services on which test case execution depends. + +XDevice consists of the following sub-modules: + +- **command**: enables command-based interactions between users and the test platform. It parses and processes user commands. +- **config**: sets test framework configurations and provides different configuration options for the serial port connection and USB connection modes. +- **driver**: functions as a test case executor, which defines main test steps, such as test case distribution, execution, and result collection. +- **report**: parses test results and generates test reports. +- **scheduler**: schedules various test case executors in the test framework. +- **environment**: configures the test framework environment, enabling device discovery and device management. +- **testkit**: provides test tools to implement JSON parsing, network file mounting, etc. +- **resource**: provides the device connection configuration file and report template definitions. + +## Directory Structure + +``` +xdevice +├── config # XDevice configuration +│ ├── user_config.xml # XDevice environment configuration +├── src # Source code +│ ├── xdevice +├── plugins # XDevice plugins +│ ├── ohos # OpenHarmony plugins +| ├── src # OpenHarmony plugins source code +│ └── setup.py # Installation script of the plugins +``` + +## Constraints + +The environment requirements for using this module are as follows: + +- Python version: 3.7.5 or later +- pySerial version: 3.3 or later +- Paramiko version: 2.7.1 or later +- RSA version: 4.0 or later + +## Usage + +- **Installing XDevice** + 1. Go to the installation directory of XDevice. + 2. Open the console window and run the following command: + + ``` + python setup.py install + ``` + + +- **Installing the extension** + 1. Go to the installation directory of the XDevice extension. + 2. Open the console and run the following command: + + ``` + python setup.py install + ``` + + +- **Modifying the user\_config.xml file** + + Configure information about your environment in the **user\_config.xml** file. + + **1. Configure the environment.** + + - For devices that support hdc connection, refer to the following note to configure the environment. + + >![](figures/icon-note.gif) **NOTE:** + >**ip/port**: IP address and port of a remote device. By default, the parameter is left blank, indicating that the local device \(IP address: 127.0.0.1; port: the one used for hdc startup\) is used as the test device. + >**sn**: SN of the test devices specified for command execution. If this parameter is set to **SN1**, only device SN1 can execute the subsequent **run** commands. In this case, other devices are set as **Ignored** and not involved in the command execution. You can run the **list devices** command and check the value of **Allocation** to view the **sn** values. You can set multiple SNs and separate each two of them with a semicolon \(;\). + + - For devices that support serial port connection, refer to the following note to configure the environment. + + >![](figures/icon-note.gif) **NOTE:** + >**type**: device connection mode. The **com** mode indicates that the device is connected through the serial port. + >**label**: device type, for example, **wifiiot** + >**serial**: serial port + >- **serial/com**: serial port for local connection, for example, **COM20** + >- **serial/type**: serial port type. The value can be **cmd** \(serial port for test case execution\) or **deploy** \(serial port for system upgrade\). + > For the open-source project, the **cmd** and **deploy** serial ports are the same, and their **com** values are the same too. + > **serial/baud\_rate, data\_bits, stop\_bits** and **timeout**: serial port parameters. You can use the default values. + + + **2. Set the test case directory.** + + **dir**: test case directory + + **3. Mount the NFS.** + + >![](figures/icon-note.gif) **NOTE:** + >**server**: NFS mounting configuration. Set the value to **NfsServer**. + >**server/ip**: IP address of the mounting environment + >**server/port**: port number of the mounting environment + >**server/username**: user name for logging in to the server + >**server/password**: password for logging in to the server + >**server/dir**: external mount path + >**server/remote**: whether the NFS server and the XDevice executor are deployed on different devices. If yes, set this parameter to **true**. Otherwise, set it to **false**. + +- **Specify the task type.** +- **Start the test framework.** +- **Execute test commands.** + + Test framework commands can be classified into three groups: **help**, **list**, and **run**. Among them, **run** commands are most commonly used in the instruction sequence. + + **help** + + Queries help information about test framework commands. + + ``` + help: + use help to get information. + usage: + run: Display a list of supported run command. + list: Display a list of supported device and task record. + Examples: + help run + help list + ``` + + >![](figures/icon-note.gif) **NOTE:** + >**help run**: displays the description of **run** commands. + >**help list**: displays the description of **list** commands. + + **list** + + Displays device information and related task information. + + ``` + list: + This command is used to display device list and task record. + usage: + list + list history + list + Introduction: + list: display device list + list history: display history record of a serial of tasks + list : display history record about task what contains specific id + Examples: + list + list history + list 6e****90 + ``` + + >![](figures/icon-note.gif) **NOTE:** + >**list**: displays device information. + >**list history**: displays historical task information. + >**list **: displays historical information about tasks with specified IDs. + + **run** + + Executes test tasks. + + ``` + run: + This command is used to execute the selected testcases. + It includes a series of processes such as use case compilation, execution, and result collection. + usage: run [-l TESTLIST [TESTLIST ...] | -tf TESTFILE + [TESTFILE ...]] [-tc TESTCASE] [-c CONFIG] [-sn DEVICE_SN] + [-rp REPORT_PATH [REPORT_PATH ...]] + [-respath RESOURCE_PATH [RESOURCE_PATH ...]] + [-tcpath TESTCASES_PATH [TESTCASES_PATH ...]] + [-ta TESTARGS [TESTARGS ...]] [-pt] + [-env TEST_ENVIRONMENT [TEST_ENVIRONMENT ...]] + [-e EXECTYPE] [-t [TESTTYPE [TESTTYPE ...]]] + [-td TESTDRIVER] [-tl TESTLEVEL] [-bv BUILD_VARIANT] + [-cov COVERAGE] [--retry RETRY] [--session SESSION] + [--dryrun] [--reboot-per-module] [--check-device] + [--repeat REPEAT] + action task + Specify tests to run. + positional arguments: + action Specify action + task Specify task name,such as "ssts", "acts", "hits" + ``` + + >![](figures/icon-note.gif) **NOTE:** + >The structure of a basic **run** command is as follows: + >``` + >run [task name] -l module1;moudle2 + >``` + >**task name**: task type. This parameter is optional. Generally, the value is **ssts**, **acts**, or **hits**. + >**-l**: test cases to execute. Use semicolons \(;\) to separate each two test cases. + >**module**: module to test. Generally, there is a **.json** file of the module in the **testcases** directory. + >In addition, other parameters can be attached to this command as constraints. Common parameters are as follows: + >**-sn**: specifies the devices for test case execution. If this parameter is set to **SN1**, only device SN1 executes the test cases. + >**-c**: specifies a new **user\_config.xml** file. + >**-rp**: indicates the path where the report is generated. The default directory is **xxx/xdevice/reports**. Priority of a specified directory is higher than that of the default one. + >**-tcpath**: indicates the environment directory, which is **xxx/xdevice/testcases** by default. Priority of a specified directory is higher than that of the default one. + >**-respath**: indicates the test suite directory, which is **xxx/xdevice/resource** by default. Priority of a specified directory is higher than that of the default one. + >**--reboot-per-module**: restarts the device before test case execution. + +- **View the execution result.** + + After executing the **run** commands, the test framework displays the corresponding logs on the console, and generates the execution report in the directory specified by the **-rp** parameter. If the parameter is not set, the report will be generated in the default directory. + + ``` + Structure of the report directory (the default or the specified one) + ├── result # Test case execution results of the module + │ ├── module name.xml + │ ├── ... ... + │ + ├── log # Running logs of devices and tasks + │ ├── device 1.log + │ ├── ... ... + │ ├── task.log + ├── summary_report.html # Visual report + ├── summary_report.html # Statistical report + └── ... ... + ``` + + +## Repositories Involved + +[testing subsystem](https://gitee.com/openharmony/docs/blob/master/en/readme/test.md) + +**test\_xdevice** + +[test\_developertest](https://gitee.com/openharmony/test_developertest/blob/master/README.md) diff --git a/xdevice/README_zh.md b/xdevice/README_zh.md new file mode 100644 index 0000000..bccb49e --- /dev/null +++ b/xdevice/README_zh.md @@ -0,0 +1,333 @@ +# xdevice +- [xdevice](#xdevice组件) + - [简介](#简介) + - [目录](#目录) + - [约束](#约束) + - [使用](#使用) + - [相关资料](#相关资料) + - [相关仓](#相关仓) + +## 简介 +xdevice是OpenHarmony中为测试框架的核心组件,提供用例执行所依赖的相关服务。 + +xdevice主要包括以下几个主要模块: + +- command,用户与测试平台命令行交互模块,提供用户输入命令解析,命令处理。 +- config,测试框架配置模块,提供测试平台串口连接方式和USB连接方式的不同配置选项。 +- driver,测试用例执行器,提供测试用例分发,执行,结果收集等主要测试步骤定义。 +- report,测试报告模块,提供测试结果解析和测试报告生成。 +- scheduler,测试框架调度模块,提供不同类型的测试执行器调度的调度功能。 +- environment,测试框架的环境配置模块,提供设备发现,设备管理的功能。 +- testkit,测试框架工具模块,提供json解析,网络文件挂载等操作。 +- resource,测试框架资源模块,提供设备连接配置文件和报告模板定义。 + + +## 目录 +``` +xdevice +├── config # xdevice组件配置 +│ ├── user_config.xml # xdevice环境配置 +├── src # 组件源码目录 +│ ├── xdevice +├── plugins # xdevice扩展模块 +| |—— ohos # openharmony测试驱动插件 +│ ├── src # 扩展模块源码 +│ └── setup.py # ohos扩展模块安装脚本 +| |--devicetest # devicetest测试驱动插件 +| └── setup.py # deviectest扩展模块安装脚本 +``` + + +## 约束 +运行环境要求: + +- python版本>=3.7.5 +- pyserial>=3.3 +- paramiko>=2.7.1 +- rsa>=4.0 + +## 使用 +- **安装xdevice** + + 1. 打开xdevice安装目录。 + + 2. 打开控制台,执行如下命令: + ``` + python setup.py install + ``` + +- **安装ohos扩展模块** + + 1. 打开plugins\ohos安装目录。 + + 2. 打开控制台,执行如下命令: + ``` + python setup.py install + ``` + +- **修改user\_config.xml** + + user\_config.xml是框架提供的用户配置文件,用户可以根据自身环境信息配置相关内容,具体介绍如下: + + 1. **environment环境相关配置** + + 以下列出三种device配置。 + + ```xml + + + + + + + + + + + + cmd + 115200 + 8 + 1 + 20 + + + + deploy + 115200 + + + + + + + + cmd + 115200 + 8 + 1 + 1 + + + + + + + + ``` + + 2. **测试用例目录设置** + + 以下为testcase标签内容及作用。 + + ```xml + + + + + + + + + + + + + + + ``` + + 3. **资源目录设置** + + 以下为resource标签内容及作用。 + + ```xml + + + + + ``` + + 4. **日志打印等级设置** + + 以下为loglevel标签内容及作用。 + + ```xml + + INFO + ``` + + +- **选定任务类型** + + 设备执行的测试支撑套件是由测试配置文件所指定。 + + 每类XTS测试套都有一个json格式的测试配置文件,主要配置了需要使用的kits(测试支撑套件)等信息,执行预制和清理操作。 + + 以下为某个测试支撑套件的json配置文件样例。 + + ```json + { + //测试支撑套件描述 + "description":"Configuration for acecshi Tests", + + //指定执行当前测试支撑套件的设备 + //environment设置为可选,如不设置,将从框架中注册的设备中选择一个符合的空闲设备执行用例 + "environment":{ + "type":"device", + "label":"wifiiot" + }, + + //指定设备执行的驱动 + "driver":{ + "type":"OHJSUnitTest", + "test-timeout":"700000", + "bundle-name":"com.open.harmony.acetestfive", + "package-name":"com.open.harmony.acetestfive", + "shell-timeout":"700000", + }, + + //kit的作用是为了支撑测试执行活动 + "kits":[ + { + "type":"ShellKit", + "run-command":[ + "remount", + "mkdir /data/data/resource" + ], + "teardown-command":[ + "remount", + "rm -rf /data/data/resource" + ] + } + ] + } + ``` + + +- **启动框架** + + 可以通过以下几种方式启动框架 + - Linux系统可以运行根目录下的run.sh文件 + - Windows系统可以运行根目录下的run.bat文件 + - Linux和Windows系统皆可运行项目目录下的src\xdevice\\\_\__main___.py文件 + +- **执行指令** + + 框架指令可以分为三组:help、list、run。在指令序列中,以run为最常用的执行指令。 + + 1. **help** + + 输入help指令可以查询框架指令帮助信息。 + + ```bash + help: + use help to get information. + usage: + run: Display a list of supported run command. + list: Display a list of supported device and task record. + Examples: + help run + help list + ``` + + 说明: help run:展示run指令相关说明 help list:展示 list指令相关说明。 + + 2. **list** + + list指令用来展示设备和相关的任务信息。 + + ```bash + list: + This command is used to display device list and task record. + usage: + list + list history + list + Introduction: + list: display device list + list history: display history record of a serial of tasks + list : display history record about task what contains specific id + Examples: + list + list history + list 6e****90 + ``` + + 说明: list: 展示设备信息 list history: 展示任务历史信息 list : 展示特定id的任务其历史信息。 + + 3. **run** + + run指令主要用于执行测试任务。 + + ```bash + run: + This command is used to execute the selected testcases. + It includes a series of processes such as use case compilation, execution, and result collection. + usage: run [-l TESTLIST [TESTLIST ...] | -tf TESTFILE + [TESTFILE ...]] [-tc TESTCASE] [-c CONFIG] [-sn DEVICE_SN] + [-rp REPORT_PATH [REPORT_PATH ...]] + [-respath RESOURCE_PATH [RESOURCE_PATH ...]] + [-tcpath TESTCASES_PATH [TESTCASES_PATH ...]] + [-ta TESTARGS [TESTARGS ...]] [-pt] + [-env TEST_ENVIRONMENT [TEST_ENVIRONMENT ...]] + [-e EXECTYPE] [-t [TESTTYPE [TESTTYPE ...]]] + [-td TESTDRIVER] [-tl TESTLEVEL] [-bv BUILD_VARIANT] + [-cov COVERAGE] [--retry RETRY] [--session SESSION] + [--dryrun] [--reboot-per-module] [--check-device] + [--repeat REPEAT] + action task + Specify tests to run. + positional arguments: + action Specify action + task Specify task name,such as "ssts", "acts", "hits" + ``` + + run常用指令基本使用方式如下。 + + | xDevice命令 | 功能 | 示例 | + | :---------------------- | :----------------------------------------------------------- | :----------------------------------------------------------- | + | run xts | 运行所有指定类型的xts模块,如acts,hits,ssts等 | run acts | + | run -l XXX | 运行指定测试套。如有多个测试套,测试套之间以分号分隔 | run -l ActsWifiServiceTest;ActsLwipTest(testcase目录下的测试套名称) | + | run -sn | 指定运行设备sn号,多个sn号之间以分号分隔 | run acts -sn 10.11.133.22:12345
run acts -sn 2222122;22321321 | + | run -rp | 指定报告生成路径,默认报告生成在项目根目录下的reports文件夹,以时间戳或任务id建立子目录 | run acts -rp /XXXX/XXX | + | run -respath | 指定测试资源路径,默认为项目根目录下的resource文件夹 | run -respath /XXX/XXX/XXX | + | run -tcpath | 指定测试用例路径,默认为项目根目录下的testcases文件夹 | run -tcpath /XXX/XXX/XXX | + | run - ta | 指定模块运行参数,可以指定运行测试套中的某个用例,多个用例之间以逗号分隔,目前只支持hits | run hits -ta size:large
run hits -l XXXTest -ta class:XXXX(类名)#XXXXX(方法名) | + | run --retry | 重新运行上次失败的测试用例 | run --retry --session 2022-12-13-12-21-11(report任务报告目录) | + | run --reboot-per-module | 执行前先重启设备 | run --reboot-per-module -l XXXX | + + +- **查看执行结果** + + 框架执行run指令,控制台会输出对应的log打印,还会生成对应的执行结果报告。如果使用了-rp参数指定报告路径,那么报告就会生成在指定的路径下。否则报告会存放在默认目录。 + + ``` + 当前报告目录(默认目录/指定目录) + ├── result(模块执行结果存放目录) + │ ├── <模块名>.xml + │ ├── ... ... + │ + ├── log (设备和任务运行log存放目录) + │ ├── <设备1>.log + │ ├── ... ... + │ ├── <任务>.log + ├── summary_report.html(测试任务可视化报告) + ├── summary_report.html(测试任务数据化报告) + ├── detail_report.html(详细执行用例结果可视化报告) + ├── failures_report.html(失败用例可视化报告,无失败用例时不生成) + ├── summary.ini(记录测试类型,使用的设备,开始时间和结束时间等信息) + ├── task_info.record(记录执行命令,失败用例等清单信息) + ├── XXX.zip(对上述文件进行压缩得到的文件) + ├── summary_report.hash(对压缩文件进行SHA256加密得到的文件) + └── ... ... + ``` + +## 相关仓 + + [测试子系统](https://gitee.com/openharmony/docs/blob/master/zh-cn/readme/%E6%B5%8B%E8%AF%95%E5%AD%90%E7%B3%BB%E7%BB%9F.md) + + **test\_xdevice** + + [test\_developertest](https://gitee.com/openharmony/test_developertest/blob/master/README_zh.md) diff --git a/xdevice/config/acts.json b/xdevice/config/acts.json new file mode 100644 index 0000000..62e67cf --- /dev/null +++ b/xdevice/config/acts.json @@ -0,0 +1,22 @@ +{ + "description": "Config for acts test suites", + "kits": [ + { + "type": "QueryKit", + "server": "NfsServer", + "mount": [ + { + "source": "resource/tools/query.bin", + "target": "/test_root/tools" + } + ], + "query" : "/test_root/tools/query.bin" + }, + { + "type": "RootFsKit", + "command": "./bin/checksum /bin", + "hash_file_name": "checksum.hash", + "device_label": "ipcamera" + } + ] +} diff --git a/xdevice/config/ssts.json b/xdevice/config/ssts.json new file mode 100644 index 0000000..c0e3951 --- /dev/null +++ b/xdevice/config/ssts.json @@ -0,0 +1,20 @@ +{ + "description": "Runs a STS plan from a pre-existing STS installation", + "kits": [ + { + "type": "QueryKit", + "server": "NfsServer", + "mount": [ + { + "source": "resource/tools/query.bin", + "target": "/test_root/tools" + } + ], + "query" : "/test_root/tools/query.bin", + "properties": { + "version": "", + "spt": "" + } + } + ] +} \ No newline at end of file diff --git a/xdevice/config/user_config.xml b/xdevice/config/user_config.xml new file mode 100644 index 0000000..61b89cb --- /dev/null +++ b/xdevice/config/user_config.xml @@ -0,0 +1,63 @@ + + + + + + + + cmd + 115200 + 8 + 1 + 20 + + + + deploy + 115200 + + + + + + cmd + 115200 + 8 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + INFO + \ No newline at end of file diff --git a/xdevice/docs/Command_Run.md b/xdevice/docs/Command_Run.md new file mode 100644 index 0000000..f25de2e --- /dev/null +++ b/xdevice/docs/Command_Run.md @@ -0,0 +1,216 @@ +# run命令 +**run命令比较复杂,包含多种选项。框架在解析run命令的组合后,根据命令运行测试套。run命令涉及的选项可分为执行类选项和约束类选项。** + +## 1.执行类选项 +> 执行选项是与下文约束选项相对的概念。执行选项可以和run命令进行组合,形成一条有效的运行命令,从而将测试套运行起来。约束选项,则用于约束测试套运行时的行为,光有约束选项,执行框架是无法正确运行的。 + + - run + + 运行所有执行类型的xts测试套,如acts,hits,ssts等 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | run xts测试套名 | 运行所有执行类型的测试套 | run acts | + + - run -l + + 运行指定的测试套。长选项为"run --testlist"。后面的之为测试套配置文件列表。命令执行后,框架会在testcases目录下找到对应的"测试套名.json",然后解析执行。 + 如下所示,用户输入 ACtsWifiTest 和 ActLwipTest两个模块,要求框架执行。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | run -l 测试套1;测试套2 | 测试套之间以分号分隔 | run -l ActsWifiTest;ActsLwipTest | + + - run -tf + + 指定测试套文件。长选项表示为"run --testfile"。 + + 如下所示,用户指定了test/resoucre/test.txt文件作为模块选项的来源文件,框架将读取这个文件中的内容,解析后执行。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | run -tf 测试套文本路径 | 用户可以指定一个测试套文件让框架来执行 | run -tf test/resoucre/test.txt | + + + - run -tc + + 指定测试用例。长选项表示为"run --testcase"。只支持devicetest类型的python用例 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | run -tc 测试用例文件名(无后缀) | 只支持devicetest类型的python用例 | run -tc XXX | + + + - run --retry + + 重新运行上一次的任务或者指定session的失败用例,重新生成测试报告。 + + 如下所示,实例输入了一个session id,那么框架将在报告路径下找到这个包含这个session id的目录,从smmary_report.html中解析出失败用例,重新运行。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | run --retry [--session session路径] | 如不指定session则重新运行上次失败的用例。否则,执行session中的失败用例 | run --retry
run --retry --session 2022-10-12-12-12-12 | + + +## 2.约束选项 + +> 约束选项,可以用于修饰执行选项也可以修饰约束选项。单独的约束选项和run命令的组合是无法被框架理解和执行的。 + + - -sn + + 通过设置参数的值来指定运行的设备 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -sn 设备唯一标识号 | 参数后的值为:sn号或id:port的字符格式。多个设备之间以分号分隔。 | run acts -sn 10.117.22.3:123
run acts -sn 12321412;123213123 | + + - -rp + + 指定报告生成路径。长选项表示为"--reportpath"。默认会在项目的reports文件夹下用时间戳或任务id建立子目录。 + + 如下所示,示例将报告生成路径进行了更改。本次执行任务的报告将生成在指定目录下。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -rp 指定路径 | 使用指定的路径将体态默认的报告生成路径 | run acts -rp /suites/hits/resport/XXXX | + + - -respath + + 指定测试所需要的资源路径。长选项表示为"--resourcepath"。如果设置了此参数,框架在加载资源时,会在指定目录下查找。 + + 如下所示,示例设置了对应的资源路径。那么任务后续将在此目录下读取对应的资源进行操作。 + + | 格式 | 使用说明 | 实例 | + | :---------------------- | :----------------------------------------------------------- | :------------------------------------------ | + | -respath 指定的资源路径 | 资源目录默认为项目下的resoucre。如果用户设置了此参数,则将资源目录设为指定文件夹 | run acts -respath /suites/hits/res/resuorce | + + - -ta + + 指定测试套运行参数,约束测试套在运行时的行为。长选项表示为"--targets"。-ta后的参数最终会被框架获取、解析、拼接成命令。 + + 如下所示,-ta后的值将被框架读到,并指定模块后续行为。 + + 以下可用参数只对OHJS驱动有效 + + | 可用参数 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | class | 可以指定运行测试套中的指定用例,多个用例间以逗号分隔。 | run -l SoundTriggerTest -ta class:android.harware.SoundTriggerTest#testKey,解释:只运行SoundTriggerTest测试套下的testKey用例,SoundTriggerTest中其他用例均不执行 | + | notClass | 指定不允许测试套中的哪些用例 | run -l SoundTriggerTest -ta notClass:android.harware.SoundTriggerTest#testKey,解释:除了SoundTriggerTest测试套下的testKey用例,SoundTriggerTest中其他用例均执行 | + | stress | 指定测试套的运行次数 | run -l SoundTriggerTest -ta stress:100,解释:将测试套SoundTriggerTest运行100次 | + | level | 用例级别,可选参数:"0","1","2","3" | run -l SoundTriggerTest -ta level:1,解释:指定测试套SoundTriggerTest的用例级别为1 | + | size | 用例粒度,可选参数:"small","medium","large" | run -l SoundTriggerTest -ta size:small,解释:指定测试套SoundTriggerTest的用例粒度为small | + | testType | 用例测试类型,可选参数:"function","performance","reliability","security" | run -l SoundTriggerTest -ta testType:function,解释:指定测试套SoundTriggerTest的测试用例类型为function | + + - -pt + + 指定-ta选项后的值的解析方式。长选项表示为"--passthrough"。需要配合-ta使用。-ta选项后的值,框架默认他是以组合方式存在的,多个组合之间以分号进行分隔。组合中如果存在多个元素,则元素之间以逗号进行分隔。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -pt true/false | 如果指定为true,则-ta参数的值会被框架整体作为一个字符串来解析。如果为false,则会按照默认的方式解析 | run hits -ta size:large -pt false | + + - -env + + 指定配置文件内容。长选项表示为"-- environment"。用户设置了配置文件内容后,框架将不再读取config/user_config.xml,而是解析指定的xml字符串内容。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -env xml字符串 | Xml字符串必须符合user_config.xml规范。并且各个层级之间不允许存在换行符 | run -l XXXTest -env xxx | + + - -c + + 指定当前任务的user_config.xml所在路径。长选项表示为"--config"。参数为一段有效路径。同-env命令有些相似,不过-env命令是将整个xml内容输入。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -c 包含user_config.xml的路径 | 框架将优先从指定路径中去读取user_config.xml | run -l XXXTest -c xxx | + + - -t + + 指定当前任务的测试类型。长选项表示为"--testtype"。其值主要使用在可视化报告中,默认为Test。可选类型有UT,MST,ST,PERF,SEC,RELI,DST,All。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -t 类型名 | 如不填写,summary_report.html中默认为Test | run -l XXXTest -t ALL | + + - -td + + 指定当前任务使用的驱动id。长选项表示为"--testdriver"。可填写的内容详见[测试支撑套件配置中的driver类型]()。 + + 如下所示,ANSModuleTest模块使用CppTest作为驱动id。框架会使用CppTest的对应驱动执行任务。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -td 驱动id | 驱动id必须是框架提供的类型字符串 | run -l ANSModuleTest -td CppTest | + + - -tcpath + + 指定用例测试用例路径。长选项表示为"--testcasespath"。框架默认使用项目下的testcase文件夹作为用例路径,如果指定了用例路径,则在指定路径下查找测试用例。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | -tcpath 用例路径 | | run -l XXXTest -tcpath D:/xxxx/xxxx | + + - --session + + 指定运行session id下的内容。约束选项,需配合--retry使用。 + + 如下所示,示例中指定了session id。框架在执行retry操作时,会在报告路径下寻找对应的文件进行解析,获取到失败的测试用例列表,然后重新执行。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | --session sessionID | 重新执行指定sessionid中的失败用例 | run --retry --session 2022-12-11-12-11-22 | + + - --dryrun + + 列举上次失败的测试用例选项。约束参数,需配合--retry使用。结果集打印分成几大部分。 + + > Session id:框架记录的上次任务的Session编号 + + > Command:上次任务使用的命令 + + > ReportPath:上次任务报告路径 + + > CasesInfo:上次任务的用例选项,包括模块(Module)、测试套(TestSuit)、测试用例(TestCase) + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | run --retry --dryrun | 固定用法。用于获取上次的失败用例的详细信息。结果分列显示在控制台上 | run --retry --dryrun | + + - --reboot-per-module + + 指定执行本次任务的模块前是否重启设备。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | --reboot-per-module | 直接在命令后输入执行项名即可 | run -l ANSTest --reboot-per-module | + + - --check-device + + 验证设备。 + + 如果设备不一致,则会出现错误"does not meet the requirement"。 + + | 格式 | 使用说明 | 实例 | + | :--- | :--- | :--- | + | --check-device | 验证ssts.json里properties的spt与实际运行的设备是否一致 | run ssts -l XXXTest --check-device | + + - --repeat + + 重复执行次数 + + | 格式 | 使用说明 | 实例 | + | :------- | :--------------------------------------- | :-------------------------------------------------- | + | --repeat | 在--repeat后空格,输入需要重复执行的次数 | run ssts -l XXX --repeat 3,表示重发运行XXX测试套3次 | + + - -tl + + 长选项表示为"--testlevel",此参数为保留选项,目前框架没有使用到 + + - -cov + + 长选项表示为"--coverage",此参数为保留选项,目前框架没有使用到 + + - -bv + + 长选项表示为"--build_variant",此参数为保留选项,目前框架没有使用到 diff --git a/xdevice/docs/Device_Auto_Upgrade_Guide.md b/xdevice/docs/Device_Auto_Upgrade_Guide.md new file mode 100644 index 0000000..92693d7 --- /dev/null +++ b/xdevice/docs/Device_Auto_Upgrade_Guide.md @@ -0,0 +1,311 @@ +背景 & 现状: + +目前Hi3861使用Hiburn.exe工具对设备进行烧录,烧录完成后重启设备自动执行测试用例,xDevice测试框架将收集测试用例执行数据,将其数据处理、结果解析,生成测试报告。但由于各厂商L0板子与Hi3861烧录方式不尽相同,为了兼容不同厂商也能在xDevice测试框架上执行,测试框架提供了支持调用厂商升级脚本对设备实现烧写和用例执行。 + +前提: + +1. 串口可以实现AT命令(借助其它工具)重启和复位; +2. 刷机工具支持命令自动刷机; + +以下是Hiburn和升级脚本两种烧录方式,请自行选择合适的烧录方式: + +- Hiburn工具烧录(命令行烧写) + +命令行烧录和用例执行步骤: + +1. 设备复位 + +​ AT+RST=20000000 -》复位设备,等待20s在复位,否则复位不成功 + +2. send file + +​ 发送bin文件到设备,在Window环境下,Hiburn.exe支持以命令行的方式调用,调用命令如下: + +```shell +Hiburn.exe params +``` + +​ 命令之间用空格隔开,如果命令带有参数,命令和参数之间作用冒号隔开,示例如下: + +``` +Hiburn.exe -com:20 -bin:C:\test_bin\xxx.bin -signalbaud:115200 +``` + +Hiburn.exe烧写命令参数 + +| 命令 | 参数 | 说明 | +| ------------ |------------------|------------------------------------------------| +| -com | x | PC端的串口号(例如1) | +| -bin | path\upgrade.bin | 固件包upgrade.bin的绝对路径,固件包的名称和文件类型根据各产品实际情况可能有所不同 | +| - signalbaud | 115200 | RomBoot下传输固件包时的串口波特率,默认为 115200bit/s | + +3. 重启设备 + +​ 与串口建立连接后发送[239, 190, 173, 222, 12, 0, 135, 120, 0, 0, 97, 148]重启设备,然后开始自动执行用例,将执行数据返回给测试框架。 + +​ Command – Reset对应的AT命令如下 + +```shell +['0xef', '0xbe', '0xad', '0xde', '0xc', '0x0', '0x87','0x78', '0x0', '0x0', '0x61', '0x94'] +``` + +​ 备注:如果开发板支持Hiburn方式烧录,不需要提供升级脚本。 + +- 升级脚本烧写 + +1. 初始化升级类 + +​ 建议:文件名和类名以名字后缀区分不同厂商,如UpGradeDeviceHuawei + +​ 初始化升级类,如UpGradeDeviceXXX + +​ 输入:串口号、波特率、执行文件bin路径、烧录工具名称、其它参数等 + +​ 如下图: + +![upgrade_1](../figures/upgrade_1.png) + +2. 调用方法burn实现烧写 + +​ 该函数主要是对设备进行复位、擦除、烧写设备,烧写成功返回True,失败返回False + +![upgrade_2](../figures/upgrade_2.png) + +​ 1) 函数_reset() + +​ 发送命令让设备复位 + +​ ![upgrade_3](../figures/upgrade_3.png) + +​ 2) 函数_upgrade_device() + +​ 对设备进行烧录 + +![upgrade_4](../figures/upgrade_4.png) + +3. 调用方法reset_device重启设备 + +​ 该函数主要是重启设备,然后自动执行用例,返回用例执行数据给测试框架。 + +![reset_device](../figures/reset_device.png) + +​ 1 ) 返回重启设备的AT命令,由xDevice框架做重启设备的操作 + +![upgrade_6](../figures/upgrade_6.png) + +​ 2) 在不知道AT命令情况下,借助三方工具重启设备,将执行用例的数据返回给xDevice做处理 + +![upgrade_5](../figures/upgrade_5.png) + + + + 升级脚本Demo + +``` +import time +import serial +import re +import subprocess + +PATTERN = re.compile(r'\x1B(\[([0-9]{1,2}(;[0-9]{1,2})*)?m)*') +# 测试套结束标签 +CTEST_END_SIGN = "All the test suites finished" + + +class UpGradeDeviceXXX: + """ + 升级类,类名使用大写字母开头的单词(CapWords)风格命令,其中XXX表示芯片型号或者厂商:如UpGradeDeviceHi3861 + """ + + def __init__(self, serial_port, baund_rate, patch_file, burn_tools, **kwargs): + self.serial_port = serial_port + self.baund_rate = baund_rate + self.patch_file = patch_file + self.burn_tools = burn_tools + self.object_com = None + self.is_open = None + self.arg = kwargs + + def _connect(self): + ''' + 连接串口 + @return: + ''' + try: + if not self.is_open: + self.object_com = serial.Serial(self.serial_port, baudrate=self.baund_rate, timeout=1) + self.is_open = True + except Exception as error_msg: + error = "connect {} serial failed,please make sure this port is not occupied,error is {}".format( + self.serial_port, str(error_msg)) + raise (error) + + def _close(self): + ''' + 关闭串口 + @return: + ''' + try: + if not self.object_com: + return + if self.is_open: + self.object_com.close() + self.is_open = False + except (ConnectionError, Exception) as _: + error_message = "Local device is disconnected abnormally" + raise (error_message) + + def _execute_command_with_time(self, com, command, timeout): + ''' + 执行命令行函数 + @param com: + @param command: + @param timeout: + @return: + ''' + if isinstance(command, str): + command = command.encode("gbk") + if command[-2:] != b"\r\n": + command = command.rstrip() + b'\r\n' + com.write(command) + else: + com.write(command) + return self._read_local_output(com=com, command=command, + timeout=timeout) + + def _read_local_output(self, com=None, command=None, timeout=None): + ''' + 通过串口方式读取设备数据 + @param com:串口 + @param command:执行命令 + @param timeout:函数超时时间 + @return: + ''' + result = None + input_command = command + start = time.time() + while True: + data = com.readline().decode('gbk', errors="ignore") + data = PATTERN.sub("", data) + if isinstance(input_command, list): + if len(data.strip()) > 0: + # 测试日志添加时间戳 + data = "{} {}".format(self._get_current_time(), data) + result = "{}{}".format(result, data.replace("\r", "")) + if re.search(r"\d+\s+Tests\s+\d+\s+Failures\s+\d+\s+Ignored", data): + start = time.time() + if CTEST_END_SIGN in data: + break + if (int(time.time() - int(start))) > timeout: + return result + else: + result = "{}{}".format(result, data.replace("\r", "").replace("\n", "").strip()) + if (int(time.time() - int(start))) > timeout: + return result + + print("result:{}".format(result)) + return result + + def _reset(self): + ''' + 发送AT命令让设备复位 + 根据实际AT命令或者工具实现,如"AT+RST=20000000"是Hi3861芯片的复位命令,该函数开发根据自己业务修改 + @return: + ''' + self._connect() + self._execute_command_with_time(com=self.object_com, command="AT+RST=20000000", timeout=2) + self._close() + + def _upgrade_device(self): + ''' + 烧录设备,根据各自实际情况调用升级工具对设备进行烧录 + @return: 成功返回Ture,失败返回False + ''' + + try: + port_number = re.findall(r'\d+$', self.serial_port) + upgrade_command = "{} -com:{} -bin:{} -signalbaud:{}".format(self.burn_tools, port_number[0], + self.patch_file, self.baund_rate) + return_code, upgrade_result = subprocess.getstatusoutput(upgrade_command) + if return_code == 0: + print("Upgrade device success !") + return False + else: + print("Upgrade device fail !") + return False + except Exception as error: + print("Upgrade device error:{}".format(error)) + return False + + def restart_device(self): + ''' + 重启设备,可以通过AT命令或者三方工具重启设备 + @return: 用例执行数据 + ''' + # 方法1:直接返回AT命令给测试框架,由测试框架重启设备,以下是3861的Reset AT命令 + # RESET_CMD = "0xEF,0xBE,0xAD,0xED,0x0C,0x00,0x87,0x00,0x00,0x61,0x94" + # reset_cmd = [] + # reset_cmd = RESET_CMD.replace(" ", "").split(",") + # reset_cmd = [int(item, 16) for item in reset_cmd] + # return reset_cmd + + # 方法2:在不知道AT命令情况下,可以通过三方工具重启命令,返回用例执行数据,如通过JFlash工具重启设备 + result = None + restart_command = "{} -startapp -hide -exit".format(self.burn_tools) + print("Restart command:{}".format(restart_command)) + print("Reseting device,please wait....") + return_code, out = subprocess.getstatusoutput(restart_command) + if 0 == return_code: + print("Restart device success!!") + # 重启设备后,连接串口,获取用例执行数据 + self._connect() + result = self._execute_command_with_time(com=self.object_com, command=[], timeout=90) + self._close() + else: + print("Restart device fail!!") + return result + + def burn(self): + """ + 烧录接口 + 主要实现设备烧录,有些开发板还需要执行前先执行擦除操作,根据实际业务实现 + :return: + """ + # 1.设备复位 + # 连接串口,发送AT命令复位,各厂商复位命令不同 + self._reset() + # 2.烧写设备 + return self._upgrade_device() + + def _get_current_time(self): + """ + 获取当前时间戳 + :return: + """ + current_time = time.time() + local_time = time.localtime(current_time) + data_head = time.strftime("%Y-%m-%d %H:%M:%S", local_time) + millisecond = (current_time - int(current_time)) * 1000 + return "%s.%03d" % (data_head, millisecond) + + +if __name__ == '__main__': + # 1.初始化升级类 + serial_port = "com8" + baund_rate = 115200 + patch_file = r"C:\test_bin\xxx.bin" + burn_tools = r"D:\DeviceTests\Hiburn.exe" + upgrade = UpGradeDeviceXXX(serial_port=serial_port, + baund_rate=baund_rate, + patch_file=patch_file, + burn_tools=burn_tools) + + # 2.烧写设备 + result = upgrade.burn() + + # 3.重启设备 + result_data = upgrade.restart_device() + print("result_data:{}".format(result_data)) + +``` + diff --git a/xdevice/figures/icon-caution.gif b/xdevice/figures/icon-caution.gif new file mode 100644 index 0000000000000000000000000000000000000000..6e90d7cfc2193e39e10bb58c38d01a23f045d571 GIT binary patch literal 580 zcmV-K0=xZ3Nk%w1VIu$?0Hp~4{QBgqmQ+MG9K51r{QB&)np^||1PlfQ%(86!{`~yv zv{XhUWKt}AZaiE{EOcHp{O-j3`t;<+eEiycJT4p@77X;(jQsMfB$R?oG%6hQ z+MMLZbQBH@)Vg&1^3?qHb(5!%>3r0+`eq=&V&E}0Dypi0000000000 z00000A^8LW000R9EC2ui03!e$000L5z=Uu}ED8YtqjJd<+B}(9bIOb$3-31_h|V>=0A{ z1Hh0#H30>fNT})^fRU_83uewx9oRr{f{Sx1Ml`t)EQ zGkHZ67&~y{W5Jpq4H_WfuLxp*3<7O}GEl;1ESe36fLNs=B0&LQM1Buf(R)qg(BRd`t1OPjI1m_q4 literal 0 HcmV?d00001 diff --git a/xdevice/figures/icon-danger.gif b/xdevice/figures/icon-danger.gif new file mode 100644 index 0000000000000000000000000000000000000000..6e90d7cfc2193e39e10bb58c38d01a23f045d571 GIT binary patch literal 580 zcmV-K0=xZ3Nk%w1VIu$?0Hp~4{QBgqmQ+MG9K51r{QB&)np^||1PlfQ%(86!{`~yv zv{XhUWKt}AZaiE{EOcHp{O-j3`t;<+eEiycJT4p@77X;(jQsMfB$R?oG%6hQ z+MMLZbQBH@)Vg&1^3?qHb(5!%>3r0+`eq=&V&E}0Dypi0000000000 z00000A^8LW000R9EC2ui03!e$000L5z=Uu}ED8YtqjJd<+B}(9bIOb$3-31_h|V>=0A{ z1Hh0#H30>fNT})^fRU_83uewx9oRr{f{Sx1Ml`t)EQ zGkHZ67&~y{W5Jpq4H_WfuLxp*3<7O}GEl;1ESe36fLNs=B0&LQM1Buf(R)qg(BRd`t1OPjI1m_q4 literal 0 HcmV?d00001 diff --git a/xdevice/figures/icon-note.gif b/xdevice/figures/icon-note.gif new file mode 100644 index 0000000000000000000000000000000000000000..6314297e45c1de184204098efd4814d6dc8b1cda GIT binary patch literal 394 zcmZ?wbhEHblx7fPSjxcg=ii?@_wH=jwxy=7CMGH-B`L+l$wfv=#>UF#$gv|VY%C^b zCQFtrnKN(Bo_%|sJbO}7RAORe!otL&qo<>yq_Sq+8Xqqo5h0P3w3Lvb5E(g{p01vl zxR@)KuDH0l^z`+-dH3eaw=XqSH7aTIx{kzVBN;X&hha0dQSgWuiw0NWUvMRmkD|> literal 0 HcmV?d00001 diff --git a/xdevice/figures/icon-notice.gif b/xdevice/figures/icon-notice.gif new file mode 100644 index 0000000000000000000000000000000000000000..86024f61b691400bea99e5b1f506d9d9aef36e27 GIT binary patch literal 406 zcmV;H0crk6Nk%w1VIu$@0J8u9|NsB@_xJDb@8;&_*4Ea}&d#;9wWXz{jEszHYim+c zQaU<1At50E0000000000A^8Le000gEEC2ui03!e%000R7038S%NU)&51O^i-Tu6`s z0)`MFE@;3YqD6xSC^kTNu_J>91{PH8XfZ(p1pp2-SU@u3#{mEUC}_}tg3+I#{z}{Ok@D_ZUDg- zt0stin4;pC8M{WLSlRH*1pzqEw1}3oOskyNN?j;7HD{BBZ*OEcv4HK!6Bk6beR+04 z&8}k>SkTusVTDmkyOz#5fCA$JTPGJVQvr3uZ?QzzPQFvD0rGf_PdrcF`pMs}p^BcF zKtKTd`0wipR%nKN&Wj+V}pX;WC3SdJV!a_8Qi zE7z`U*|Y^H0^}fB$R?oG%6hQ z+MMLZbQBH@)Vg&1^3?qHb(5!%>3r0+`eq=&V&E}0Dypi0000000000 z00000A^8LW000R9EC2ui03!e$000L5z=Uu}ED8YtqjJd<+B}(9bIOb$3-31_h|V>=0A{ z1Hh0#H30>fNT})^fRU_83uewx9oRr{f{Sx1Ml`t)EQ zGkHZ67&~y{W5Jpq4H_WfuLxp*3<7O}GEl;1ESe36fLNs=B0&LQM1Buf(R)qg(BRd`t1OPjI1m_q4 literal 0 HcmV?d00001 diff --git a/xdevice/figures/reset_device.png b/xdevice/figures/reset_device.png new file mode 100644 index 0000000000000000000000000000000000000000..a7edc02576a80d0cc1b8a0f1009fb359966cee96 GIT binary patch literal 121064 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䷩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 literal 0 HcmV?d00001 diff --git a/xdevice/figures/upgrade_3.png b/xdevice/figures/upgrade_3.png new file mode 100644 index 0000000000000000000000000000000000000000..9003e29f462b1d6dc3bab0960654e9a8418ad9fb GIT binary patch literal 41506 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 a()sNdB`NH~zfAtVr>U+7s(SM3-G2bv?(UWV literal 0 HcmV?d00001 diff --git a/xdevice/figures/upgrade_4.png b/xdevice/figures/upgrade_4.png new file mode 100644 index 0000000000000000000000000000000000000000..aafe95f3c9b0f5ba42537f5104aa51b2d50f28c8 GIT binary patch literal 78725 zcmc$_WmMZu^e$Q#C{Uod6?Z7^q@@8$u;NZy+}*W;1TU@$#T|;fw75%f_cU1W;N0-` zf6rO>)BSYr&8JzDS@WBjy=Tkw>$V1HM!DNZVQP^q@faa$hX1RMDvTV6*a^9*`3 zFnQjn92_pFXsg|7(A-eYV2&uh-`NGxz)dH9t{E)nWhZoO}7l;9qMx=;hwsF7MrI z6AW-5`PW%P8_@W#^$X|!!zl|r5{*$0QQoLIJXfcd30upEqoKAr|IO&9r!x(PS)7mV z*5ncK*QIemZ{f)o;<||gy>!w2B6_2%$0spEcE72;pDZ2RDEl0i{J!4XNK6;^Pp;G} zhevGBU-t}0HJ{_47b{aY!B}}%dJEKdlYNeG8YcKMNGJyIVIKVqyy9x1R$L5w#Ufb=9`tQlku#Z#&9{DQ=q! z#8%UA#4m2XXeV8cnGYN4G?O)I_qHBprU=|{pT5}RF);Uxbv>pMd(x0V!o_K(8D5JO zhVy)Hf*pK2Rkm=FzUYqD>ki_+**>}nvhJk}P5_Wr^ zD_wS4w?ENr45f(_EoP5+iJO~)zziv$1`PM%Eq;k zjA#iktmS-T@owIGWqd-6^Bdt2JKH`{dB(CrrU{y@zU8FWeO*pN64wSalv{P}e}I`L zxgK_IC;dmA@wwh!8L<%Um0XjBz6i2I|4SjCO+J8M`%*!?5X}uzh?<7;W-VRfB#$o~ z;&HpkeDkTz`6f`dmHTGssW=+N>uObh6zlANFV`@y|qJ5>@a` zVq&~d=Vw`btRee3tLh?~F2R;($?z@5w0 z^fUBez~Y7Z$|K{nRDUE|*6+8L!E${p;OtB&G4*t!-ri-{->bv7Zi#~kTg!MRuLXcc z(wL}KBg~=Qd$?y_7N%RB@|}~TT;>wq>-6Z;-3s>*GtGfAGs2lfrZo~0t4pYR-=sIn zdFLd_yTS5Y_a-#PXKzNK;jGXv5xMu5PFJVJ4yS!9tg@0lc#nK`J69f?Q&MQ@Aj6o( zv!kbN27Mk zb07Hg)TGeX%#9wD(o@}cJRdd-mF^NYM?Vq8lLrFbp3-Mtzh3~TB}X&sU4bi$iu7WZ zcNY%w(tmy6D?l+Tx-WkAU(-WfC`_HZ->w;Dyc#_aTk=MOTPkbW%Ooy+L`PYhQG}Q8 zM-wP`efP~#ly3@R0S#s+Gblh6g8TGY(e25qP-6J*uf`?kVZ@@XfN3HVI6Ti|YUI}% zGhmbS;A;PUI(Q)Qv{MG)gidePS=Y65V#{(%_Fs*?%-h_O`ZzSxlI4Ee{`4ZidWwnL zP|*qABBq~y7O=M1g79-Qvb<(?P&@6MKGaIryPBZA)I)7w7wuQ~g*NUvFZP}}BS;8x zPK%rNF=}Q=M@Qf$vWOI_wHf~HzHoHgJjI5b#RKanfsu!JVt$7J;YI#8CmF5sCjAKW z=5D*nlb0L31qm6f2%F&TU6jMDR-*BWP1VPpH_7fhHGng&@@@G_lJT(?QOY6rrls9I zeS6d$l$q(r1 zVoQ#uWXaw7C0~8|R&#OdV{d8|h>wpl0-!hdf`i0Rt@=J*@eqrjt!sldTZ7H^T6twx z{?v%uH}XP!3v-%N)GOV$yUDFEJ;qGj)XTD)%T|xUEQVW{(AOWv?nCD zQ&G2ET_O4A7W=4MckeJ9o>cb3cC>g)T?eVTfh@rB7Cc{jgjnK752|_6W!jz7+rLW8 zTDiSKWK@#V({at-{zdPdx3zmOUE2dLm?Iz%jz+&5+GvBY+ewLw7@sG`1m4AZp!@*i417&W;mOH{M8h|?hR~_qRI<@^HYk9%+XD*^le4=kY*>L0vX|1 zp8YTIg=$Jsfy?i|$`xd9>Ay$qIWLI)Ix9&&!R1G}={&sb+!8=l;fnR&BIFNf7|I08 z)L8xkHDgD-!V-skFUJ;x7x-^?>XEL3|AiNy=IWlWlm?1ZRk^rMKwLvUfb~TZk;hX{ zWPNRZg&8G=$rQ^2Z36wxVF3P)Alu_AQ1}72Z|goI9bLoaM|9bq*^&nc5IuWQ-bhuZ zf897kWQ^)`*-7{3zv`jzx~9%d41rh#n|}BiPXj&=4$Lech#~M>jhAqLAz*Kb46*0n zA|u>AjE66U+;;gcwozlNz#!A0!&<*JIl97EQ@orf*U1xj^W1w2?_5G45OIm`R8?nL zp7b{ine+M{bi5AAKh2oEZJs_3^#NFQcmq3}3bN1xr3chyq zg#l`RAShfXzqnNP$xLXBFm$o!*UZX0(>~{?RJq0qM510y)p`_{S<$6 z%St49a2mvLu=`AtS=uR?(%# zYrgCel|uGcDcl2Bb4$0qt+p$4Q>l>W@M%oeLpUj^)nCF}&H4HpZ zNgSDJdiS4)s3WSeFf;2Sjh6y4+W8JvjUOL`a>1uHkHhkLJeesBL^ua(H`Q zOZ28Bd4>M|)TlvaH+)DoXeN~4(rc5+Yl);?oKY(j_UT>(ct)Sc%ZQu4bO%TrAdL8Dbk11(Jd57^&CdGB) zob7CrK<0r}7p65yngjSc#Y}10I z&b=<#`(2I~ku%Hm{=%)!?)FbNU}t?-N6ALz;={j8N>Rl*@r-nho$8fFd2rQMZrcrV zmGq4u<>Spe(Nf(ImFd2MX z&drezjG2R1n#IKN)KtWTfKyXLeb*?gQj*~G@%~dRTqKYIgVnCc)gVW^h4QRmmS`&h zPQJT+w%P761vHXTbE=|-hE&v8J>JYT$k?9rXP*yXiCl=GcDaHR!`E>HjqS4BBEDxocdg_(VCxW$p_=+x18tu zJl)*4n-A@Mc$(V=+)=1)l>W_PB3Jr`Yf10$dejx-DUFuuiUu6&KK+y-)DJb*Y9eoI z#(@U3IGmTXHlg>=7Qp1pbYon@5|VT1N_YEWiQ$`1Hyc7P#LuEH)9bj;eJU(!^dff* zBxu-13(9V|a?-2iq1G$<{&g3@ z@_FP*Ke~r1G5v+V^g@s4;=Hfv>e7n^M{wJvvn~R}a#MW)GHdZhpa3>}rShK>fxs5} z8^DfGZt7UejdEi87^y+n^`KTWx@GFzyY;rM(GUdI**jcXscV-SWj4P(u=2Ftw!<@y zK2l~!x6T0$_uAENJq)cs2$UIyZ~=sEy!XFvF5vIZVxf4G4J1|Ah;zZECO2A$p-!}? z+Y1R%mSO9Hd8^u-Jbx`A`8=3b0>yA;iZ3JQl0e<|0_txBLS9R2HISXVODJh|vk5)q z4%ph(uJ<{;+?}NIShn?s(v9w4qw)ORpA0YDK@DH#i`Xd?{Z@{+uSWTF%g3nYmt<*b^ft|I*IKuKx9^Q+ z7|PSS@AV>=&Ww0<2nosD<5?8L;Jbvs~z_&L_&CH!#DfG zg{8#HAuSTa)siR+SDtI=NW}u@-ibEeaoA=~CpXOFK+hM3-pRmL4op`dL;Uzd5}P>q zXN0R?5**c>blHahd-d&lUzWxrS|~i3^mLgrI$9`<#oSJ*$#>>zvr_ZMlzmQemafrp z4be12g6`mOqAauRZd@G>CP|TlvEKfZ;hdApo6Dze3)S5X{!y;ldvux9GZmQDE%o-z z0u;Uky;Xtn$Y8_EnPb34Y0v`(*UpTeF^VD zd}$q)@ndXb_K+Mc-r^~)IyaD*TT%%fd}60FZ!;acOfw-v-|jSKk@oo^?&vPTKvl&X zD(@|^kaHjSiN1adIhV^E5K#()iZSNNy9f{ZNJa#&VUVkL!PhYbg7>ADl5emz~h zEq#qk_HfDa~Cwi%C z!~CBOPngS0PPT`ZK9s!xEL#)e1)SU-0Hqd2P6hQH*>H9~sgKiz;FP%c+nkPIZ$ALUPGG|wd}X~hwEUmU&@kw6-~1nI!NzS; z(oHR}(#FBB|In8&)i#$qdnHrVw~IG;JElNjr;#93?6x`i_7aT0-D0=zvon^1lXqZG z))l&ylUUP0^xx;p9`^h%z|Msb*7!e&-mi)O7aY%4sc!DSUn6VkKfv!^o#X$W>i+=t zH;!~`wD)0WiVcBa1^$V!-^yK0+x1wwd1$}A9nJuk-^t~A_}`Z9>HT%<-%II~q+_!P z(fjq{U#Cs-9LKysasYLvbwiB|wO3`rf4X(=ONn!8V54Ac8-)3={(p~#Vn!2*HA0-Q z>*=fk%WnVA;Zm|BC0qtNm5)6CcmExX>aeDV+}&l&%*n~2SbFvE!)+0bgBAaSe>!-- z{XdDBjtJtpU*Ax*UeBSN>x*{{Zzk=Vr%+3un_TLP?>fCFxa|k=|LwcY%jU=d-A?28 z=2gSR4SK1Y^yt!^zg66s>39VX@E48x6yAJ$jSiI30-yAkNNWDuO#T)IEfs1ZitPmg z1Ep5FgsSUH^)c457fCPsBq+#D{CK2=+wTAwl!zDxH#nIdU_Rh5RVrCoe@Sz*(#x); zTDG_0JUnQ7vhq)B-u7bFtZ_n)-+Tyive{WCX+5jT{ zYqSn*9CJ$Q{#yr&@l#yNx`UGA(^G=cv9YlT`#ZE9^IPt+Ju3fSr~Q8fp}uVRUEk?H z&WOR3ab_rmJcL&v=;b0^=u`ctQMz_pmh1Nu{j)o{*0bKx5I8AW)OKBR!GF-stPkpb@-VhY4 zXd|i)?#l8(h0(q#?wj3Fc4h#%R`CWYy6?1ji;mL~nxzNWb9vnL$d#^`X>!(eo9O%b z--7TiQba->P&p^&`c?v9Qcuv@F8&I*U)u zeD2is8b4X$ovKoY#Wb5Bmqo&N&LLPk>J^DO`D|CcGwo=uBU|EQBnEia3xP~Z& z__D(3D1PYPLxS7wbI4!DNs7E4<~6-!ZS>cq*%)`jkEL#rae0t#kgF z5~p)BmBYr}SE+3FM;bpKXo?Vpb%l^thwC!7HZH-Ets3sfKhTWsyFc+uQ7LCQa4Y3^ zy)^EYRngtilG1F|l58>`3IrP`eAjF+B>m(NK6l6*KYK6SITq*@Mnj%Lcb|@qEnCVu z2r3}UK;sJ64~JBJ)U?*;DhkT~ES9FX{#$A*TdAYtOAa}$n5`2iT_|$O@`$p=f{=v$ zDSK3172ibcJd=qnRVq({B3A<*HGNhpL*7r;&jKBg50at`iT;i+W#?~Ne4H9KZR%{? z8mXZ}`2v@|ZRwI)&e8{829+<=n@(o#7F*r>P-(_+MAfU0YrpO_-qzMZ4VZDy+2ts$Dz*F*QBG7r2AfJwwV@*gLYH#NQL(J*O7Id>cs_mQ_@)4rlHcQ zi%X&0jf4IQkV!*Qu1cs&=ilnF&UwK}*YL}oSMucG%v|c|tG%0dDfSl)3#)*PyPH}z zv`e}d({Gj+z-!=Kw8<+pzkcuq{n6o}=aV7D$on8g{zc`o^8G7dm&Mmtzd|d^1rCeE zmrwBi9uc~plR297*blR8L^NTgrBXg*a;=!({G6(Ap|1F$tzHgm%twu&dgT+9Usz!P zMJkUCAwrij&e40foH=fdr`k-m#n{l%&a6~KTTl6L~sEJK8kOXO8 zp?Se+xTQ%+N0H1!Hd^ZwhX;cGYzvfNd=;(?HL#&EMhlGJY7NKvwf@*`5VgH(b?Kde zrH=(_9Vt0$iCz)A^6EDx{H=nxRj!i^08L2N)pzFCxF;!F^k9nYyG90f&%<@pn27{_ zld@Z6NJXalbkEm2<##;Fi{-9K1aS}WXn(OkdAb?>JL3m^ybE;cByP5bXC^cSp zp?UlKE+*H`o8y@m*n8~EAe`2a@+mEqK};nyTcuWAmmys~J3fSX=1-0LqEaf97nI6C ziqFVwJ`Q@f{HqZ_B$oI|-Fa!=Zt&2+c$SCl<2kOjr0BYRyvxGt-!{XA8{U(fZ9)3B zxP_a(L!5`7G$khL!ts9zm`W_i~RHm;%cr&=w>dshJjJS{F<2J&yn-Ozbsh;Z#Gr06S# z4PY^???M{hy3$lecfj9+6dyB|%K@D={i=&V)3)rv=T2|)Z?jJ}1_^Yk&D`gahc8sO zOs<8hC{ZD$#DOifXZ=Ikv?3yeFec0Ce5N=357i|NE5)^cN*C1@C}gR(ix&i}ep_Fi zl?mdr>HQxoxKe}d-Y@cKfoBi>QARggpROeObxdhmh-G-l{j%<4JE5Tx14h_i7x=vH zZ)SP*C-piH-ett>(qeA(uuPub(mH0?Qo7)Sdj0gd?SX)`Z>XeUSg4|awEMPt^MXsm zMqDXwBVHs%0gX3*Kj75eNMU4bjomCX{>aV_@+(T}6Yjdc{@%d*&MEY;eewdDB=LR5 z!Z)b{N(>%JOI4?PE+cMFT|0)fVV$uy`8a1i^xhDy=4kSF`LlsYDWUOd&yQB_TvGao zhl}t13<@G$Ttx_0oU6TQYI-Y?FQ2zKIv=asTzeXH*F`8ri)(&>D!{+QtEzLFxy|;O z7x2XD=N2K1KRaEr5Z7E@DLx-_1F-X_V=2l}T*hA!>ndCV8o+6(Dm{gYY!R)d{Q4g4 zHC-JxM`G2w0VB2)z4g63d$~@<+hT$y%F=OaWTV{qpdd&*K`UXOSI}swuSAuVxs(Vz zGp2sSy{6_K%sJM6pa2>*p7`+Pd5IAledOiC_fm)3-2CdxOBdXY#B6V5Wb6{e24U(p zTQ&*dpnn9KZ2}5gA3NlAlKfmR_|=tU?9&q}+k^=c|J4H6D;M9^R+d~4-UTxI zZ1aXIrQ)k&QYOq2MEi0e0aQ1%!Hfh*(1D-s|293HVMsA1Eaga$Q)BNod?iJ_^l51b zUi0-U5qD9&)Md638xjUHoU5_>bgSZ7afF%}8<}Otik~WKP?)XAw*LHL!0qd%pthmj zaNWZC{5p2ZxSTW&kH9O$ebmF8xz)aRI_u?Qq~k$E5m<;1bkwvlQF~g<+~k%D)ccYz z$McjroZ8JiGr7oDlw!Xh_aKYQ1-dHOa6F-+5twiW)JVRW0-qCPo;*|=QjCMJGRtSF zI-48K=7Hd9B3H!Gg=-IWSf~q@KCuEsYz7s(oEScQC0>)qiCJ0zPZ-5|PI zuism2UHoH>SR^R|)|YP%#%xbav(TYbQ1TOz8X0-%O&jewCwb1smz)NLkxZo;r3ux3 ztQEl(9t6`;9jB1xK$^PZrgMeY6W5bhj=blQ^_HqS-crSVlUDuT%ld^?%Pn(x>aU=- zy6u~!in2q*kPqZ1CwX*;Av5Wr?ZeD>Dd~`ckTX7D7qOuj*p2nmL|oEnVM4FlkRpL8 zojDl~6Ajhj$MyNOnN#DEZJE}k|M7-Ge+9AJJ=?Z3k_h%g3bc(2nPF2sND(HB*bU&) zum0#zX^5kp(+lj~8lBau89C*dlEt5Md<^Spq0x=(-ND4397UAF4v7LU`HKiayFC)b zq2g+KdU|lSU4=R~m!fv=5APpHgTI{GD%~XNM~DrFN)Cg z{Po7aVt3Hx1Omxcd8arYVP=!mTfko&&9mo1CsPA&tJgd)vAfP;*wa!)lsEf-n z2;*Uf+AHjf$_)1Mktt0IuqAH?T?SQ<7ic3X8*EPFV0?7id5Q{1{v9!4rtBf} zLZYFZc78v4NI~_+r11>KtXdY;NmCb1HV8rUt$fDjKMX12l8`-amf@;R$I|`ygSc1n zwMA^s8#!{<{>pNU5ix#$9rv+kOJZv;Z@RwP9Bi#+yaW;vw4>ze$7q~sW{Kb*f&={# z_9KUwV%U8Hk?0kMAM$x1r3#IQb_a>gqHWWt(>d1-pyIl|m`CG8b%*wE7K4N+Tnt|6 z>RdSCG%Fs=8E<_u`Oj_Ey{nb4>#t{g!<4NQV_fX`hkCE-QP+GN8^jG1Pb_$T6(=s; z0V)4BTgawNNhUh2)xym^C=OIlnao-Ir3h1$4KBeQ%fbr5(TFxV3V&V@8{x^>g{6DW zrSZb!+W4EBq}$52;O+FtyHI{DGrJudVS}4?rj!YZ?)lje?Z{%MFpyc;WMR!n4>G!c z3+H3Z{Gp8ur|Gjn*l!z9dEeKDD)XXdJ)d_JtSfn~pTv%*N3I|0n9}9e3_l=?FH*~x zr2QehYNt14<-P=vIU-#7kohC+W7Q+R)gou>?3e84a;0kNitDcCKd;-yeO!!~L~L=w zO|!aPyR@%YOeQ`yUCXbrG^w%_^UB;$O`~(IE9KTD4o*yE$Yv^nzqSlj4gJ(lpEmI_ zg#P`eQ$ty{XJN*+2CtCfvPtFKiTZUo820;HuJ*X;)Nut1%P%Epn$M^R%AlB_K~EiS_HTK2KU_L&3~bf zN3~s;LoGiBI}ui>c|S3b-OFPYdjW~7Iy`$3Z^s`j9)+pFu@cv5eY{jRtnZhS@0eGb zGeE~Z#ToVD{8AUdl+EA%5n>4hJ|=ezF)v(6_8FH)(Dw=!u<D zn49A5@bl+TXJ*Dg5K~eCb$^nE5l2CQ#x3+_^f)j;5f&lgEQ5}~!Z+@r5yG#o+vTtL z2DIaOg`1A=(|z36AtnClD2-c@Sx?TpM%#CslhG&|j!$Rws?|Y!mlXY2auuOJWyZ(K zn67}Ve*B{h^vxjLns_VZzoDq>K$s| zw#j|XrV@$hpPM3htaK;n)MAm-&+h)9ba#Tl57RmG5fi%k+jhufBy0Es8Wbr+YRs3iI_Y{V)iOPdnEIrOw$ym{U`SI~5 z03Gq=Qre?5KI6(EBylhKRApERfSGCVRSM@+gb}Dn*lL$7s3V%TRbq7ijFMNT;mQ2r zm)Eh905Y`@tG%hC(U1U27tzQwN?C|Ah7Jg`W0+x&Xc%-(PBZwJ7jhM-{=xlGL|d2D z9y|9s!TL|FT%c2fcup*Q{n>^1cP1VU*o4`%ZF-mn$b`y|R$xZZ@67{);d?nuaFN1P zdM&y>36jW|LFefid*yYe;|AG`Vv)w-*>qmbo}8Yf=NO82dOv#b8{9j6iZOeZV`eAE z3NhF%#(%DWLHc&|e7ywG1uv`L*buUloqbLfPF0E#c#~h0$16wR^N!ZkCc8XlW>rLC z_I%bbpTVqP6FPam-bZZJ?J7j=*M6uZ|JouFhBbxfPqfKwq`sKNzg4MKSTW@g%btv> ze58v`uyB>AwQnQ#nUN=X`wp83g!9ZSi`j_HIA`makSu;egD~!5HGnR1X{N#_%M@U@ z@-GyoaMTNE4C-tD#0Z1^WkwYXwo=Q-9Wp}<4@h(=FP5yOan-RBlfOeRE{qhvM?thTAp zJPHT7re9E)|FtD$Vc}#NANvWeq^zWtjMFIEgA5p!~r9ZLE-~2of&p@H!rj<@)67o%u*Jc0BV7g7xe!;5^P}WqzY{_*< zhWdo~4xu@m%{ach;X~F$NLb%oZ7&WyMuV`j8kIe#6u6*HI0P+qTX94>sUEK8&%v2T zGE0Mublc3+vB41RuRq_a-<{y=th63|G-z=iWXAIeKabDK_zIH|jb|Yqw=RKloavHS zDI~H8ji*XXTH?W{K}$)PJW^Q8nMa7(5=#6O*@xs{E;9c$vj6?D0k_do%0Hk$Xc-e4 zQLWiIYN9ERMuFO?lg?X2cIM-&RU>U2A~w8ABH4L59q_Qa23ndjoG~q$l0Hm+-scC!#Xp}NqyuE23vKNaTL^Sq@v#~Pz-Wr zIJ5e*76jxc!_Sq->X4&!4S9a}(T43e<-2!kyujis6Lst5U!~M#@)nm?G!bed`vUCA z+scid&e~d$Q-Qg=uooRruG^$QRR$GNryBmWBxmbS3Q@3;kVh4%(#)P+0e?fbpO$YI zn3Q@K7cGG}*^=d>a!#mEJcAzX;Y|&NYdjZlZvW>0Q{AdxThmiqUWU@U8MLW)YFeg$ zuWJqi&7eEkqC)JT*@|)LIt!cL=2t&P_eRz94*wZhnR%`iJLKd(P=T@)4%mc=;%9xX zl^{w5{R(>Z5S1jcb|U`# zl@+B9NQOG5kU6wb|E6U+kZ`ir@qs9I(t$U zHj%P;krgXryPBM&3CBeLIqF?Jd;N+*tbdgiyM$}|+$5AWMLtre>hh)4HC)&4s~db- zNE*?j*N_KxtvOThZP`ANt_13fkip4U&yDw*${&{^dcF6{|6KLDO4zweNEo8Er#>b; zAg*?dsRVtP6lN!IZ+P&#N!7Eb#NcHJx%~E2p-D>)_;fZuNzCBBzpjYJ1)9C_d8GF#M^VryVF3uprwH^1(HrBE}bAND)FIz0FS zD5WD1riE`iNy2lImTAkr)|1#P1#abYgeKLPN%t$wWoPR zMs40KA2PU`96s}MM=f~X$_8I?URqBx4d-ffX=nZj!)|d*^~xka7rP?X=pE-UnA!T-R9SBkZ8WBd+@c& zNz~c~?1VM$LNHmkZnu#Lb&eHZC3wGLfq`cDL+&RI8`_Gfgh`fwn+MUQz5u(4{}HK+ zp2?k=WXnTpvp9X8T_;5+GZQh6YC|#F$@g}Y4+uOQz5Ys@kougRP{yl}(qo)sSPH+h z6|kSbL_Ltr1n)%#d0M!*W|C7tl)}sh6k~Iq?o;l_1DWUIk)>I6*JZ?rtXEt-FvH4e&j@#$_S`3B9jv^J(_~nx6Y*lpCHqcF!6uzbkR-63!uGs~ z4WSz!D|1-sCzGQa@&2=(&2P(t#|v(Kr0uWCOy4t`QLerJDtqnpM$$F&DZf%}_;2to1s+JcyRp7KdH_9S(Jn3z&YF4ys0q&!7IXRe(S8jY+znkRT}22y}R zHhReS^N6wn1c>-x=KTaZ8~lzHURJsoIt8*qA(~G+I>pu5eaEdZF&6VnsOK6mRtm44 zym&?zEl3gu3km$NAJJ&ZOUcvXz9xy+8MlT%oY(ZU`>5JkxZ3kYv5s8>b@tCGdcMU( zdk^(8(*29?aVcpcB!v{fK7xah0LFbxO6D&mLR55>@;YL+<6|*bxUxyy#|61ML)H@v z{F<2O!;bTCGtX#@Cmj$k$NKGWu^oIR&r_NN%4Fk=6D|Ap`!%yJ{9g-rLy7Pzm-U6AT z3Dg7}!@Mp!M@DJ!s_RtXLo%<7OvH^kxYy&gj{`D!x>)>HOeEiI;`nrQ)|U=-W#*;) z$-^NQavEB1yaaY(WfZMwh1z!ismBlrhJG#)y(CpX44!dxWtto11SvklliWH4m`#`& z_hHDUB*!kuqvYfBFPxrinuJjrsAUYd^=;hC$!v|aOc5@NL2q1V*Uj1mMdFjJ_>eAh z8NYxgk0{>Nen|eCO;C`$5M2K4zolN^yen!a3X3Z;w0@7-#jy zbdwph1fT70o3!BJQpahY{`iAKloR$dB5rzott5SYfM8~%wo6$s+ah>F3?}5HS z#zj#hH{|0)KfGhvhQU~Gs>WO{r`=FWVf3Gv`k3d$U)S=-s=;M3W=uyVrMuG6?jRyH zHcwYvGC;n@7cgPZZkXzQ<>XUfdxXhw0D*aZQ7tw;udE6*xSr;0(-drBXh1bkixa>q z+^M+P5up^u{Zn?R2EwzBUl#XFb_>ru!kD#8fPqLat)lrQ(G54YnVnKiPPICAE(zs#QHYljlceyKX;TvyWi6rRaEdrFUwF`P-E zjBDSF$C;`xL>=;^LzgV_G2_5(Y1lavz-{YXO=lqk^R=nVy`?_x%Lt2logvN^;~IT9 zVTNVVZNME}J^#~|dT&v(=(5$n1AkS8XGxt3ujL~f~$;M+u&Op zG<1{yUQfN2a{Ip@LNS{B|ND5#kX=~F6R>q6dafyCqQ?rI_=421e@97n7^Mx2^t)4` zFH^~-C3g30XfV_+CU7S#XQ=l*bzxASp1$Bpg|d({8a(dmcSuwNwN)cJ2%#M$L@MZf)dM2plDrkn!Oc9FV z1iCO$Q<8<$l=k+-DEHkN?x(m?1U5o(!!CaqoMLUllxiAMqBt30VFu63ehH!vg!0j} zx6oiwBe3WB7<+9$w4vP|U(HhBHzd!}=I>7cud8U|%|y%RjnB_*`q= z0uNQns`3e+Z%RTx^spl-Hzq==WLzDoFt+lGbn1&D>n?c=`TDb^ihjQ>tEBR0T3N@< z^@jY))H^MVo2EKS?qA8dqab+%$~F5L^Wury<9}#SI>OH#7=A-~c=m{fhZRar`a2a- ziR=APF-e#1#T#PzLzmU_R&YMFaMr#-D`htxT z6i`F@YB?aVUGQk`O785 zv?F*aUMr?QHGP=~D3ZOH8XbH7D`1nO{#RPt*B*Vet%V- z!vGGIkA#ouRx#Yg&2c?k2g`J#QeE^NMqM$CB*^T$*M;=N+0q@R%D(-#TGj5zOJ4Nq z&#K9a?&?-G2?Qccg7%v?>*bbib!F5uK)a4a%j}kWNpj=rNz*ya?wm@67CU``K+^Ww z@M+O$yc&91`jSVHKh^Zpdh2cc`dA!SO%5MFw&gOIeT5&50W3S~lM5~?)>CJ>3*?~L z)G7>qa?tBjI(tJ?U$9}<;Q`7ZLQ70**tQ$Rbf@&=vo|YTs5j`=4CT56zjGJyA`N?c ztxdvm_Q+=3-wIU<9b(1p>BrM+<=DyxH~5W%6ASfCKD#kq$jibS)mW_Uzg1LDyYuGA za#yzlBR^W_lN~8>O{|Ri3CF4K`R0jBRLgbixYh+^`v|N~=O_bQK<|iLb-~7K!6_}; z9R5~^J}y1h^1+gXijFed*2>*q7kPzbHXP&7ze^}YqQ)g7@z$k6|H++zs zAA1h(#_PddKrwsw9I+rK8jl!rYEwOe>EP;GR@nV8x3!DClQWB`&fw#|cV+nXj&{+z z$qhX1OtJNe7VoA~+?sWKWv3bw45_;7#K1ScUm?J})S=*_@A>a%%!dR^jmCcaRsQ7X>ef2xmjOnc{yo1Leq)a&+3 z7}t~%-Ca;VQ*G2>b6Q7EvC09)f?;bKkFpR+f033B2&5sQl0 zT2q^YL^);m0V2@Hs+#$A)V7K0v=epE@8M2yX_YxeGW$s6s1tti4!V#K)Tli*Ep_aX>vkW*uICzo-aFbTcG_5ea>!TJ**%=D93>Rx zF;gFyq>6n1hEG=s$*yQSUEI=mQ(fh_3enPhl|#8rp1e5TE5KY(IQ|2#abv~>@(5h; zYlfNIY*UV#Q)p*TxpSyQ#y!s^7?hVtUD4OHzI{>b?4x^b zFiUeW(e@O(t6fXIa|lE}Ap_5YQk93q02CB9Zn`xCU-PRhQ%g-wX1#55B)s@fI(9+3 zG<&CKUJY`sUIlMEgtud4-8>(}%v8mAo`O>FO%o%;E^r%X6o783tr&P(2{^DfeT(mo z`dN+ElP9C2i^27FyVO2K)+eSQbRlm7Kos;o^7WZ=$1CQVR(urR{=_ zg0=y2)dz1dj_cQ3lkh{L@cM5#Dz)MiMGFty8?GLD9RqWD9&l?72;#j+c%Uf??xRj* zGqaY;sI?<;_!XowAVp@%Ge3_q*HU5Q`~6 zOmv=H`ZEwzgfDG zueG(qVcWMe%U!`J;M1Z$*+V+DD$4AfSRaK)fS2019;ED~o~^1XcA2kU4=S6i z#9XydnQr3qVI#DgH?nkzZOhjl`wIuOm+vRp~RM2)UrtkvLUwNy%1WA2g-J zAO$sV?|)SV0x^X!k|eOg=c)FltqxmpI|kvdmy%v>TD8!L4rHR4xsGM$J3`L9Eb~&L zdPm7HWRVx5WzRY+f^j6~{cEF9pe7&Q1a{uNInOf*Bfo~Xz%=K|l2m7jOOPVvd;Ku8iTDA5HI1lo zW5&J|C?3j5Q_wzMl#JbEyUrLJxiNYR7Sv19iK8ME`Tf+Ez-bjMC0+VBH(JVzy6m}DrPE!nth1l%hZM>oU? zyWgyCppPOx!6pHE_gf}wsbX*3f+U&+Ga(5&1u&=B@lH>kGC_TLg&jTXzT^4Kq24BA z8+*jSSam=tW0mz5$e^vM!1t~g^q$a!8=iZkf*Cfa>2W zW43$SuMJ1Ww+h2IjF-YY^2JYz+D({@CSg=hy{yn}=O91FYRNC##5>`kfj$iyv6NLEL;Y4djar0UnwfDI>sILX#JMXSOpO6+9b!`YaXTd@a!g^tfcpFv89a;mSe4QFd_Bzf z0lrAr0{@H|zhp(yG;AXwY{7*sll>d7cT4hS>|5 zHk{gcr0nkCeJE`o!|f90F3nA#TS@_Ivj9qWn9x*rCux(pOTE`;#z)VY%uMH$1YAX$ zu!_H;+Qeo1o5Rw{af)wxG7p-&)QH7+7-iq19Ash&0$c*ld;g;tXlY>Wik@S|eqc;i zElR|RF!%;lQJE^N-JG&l4(51vB2bm)9_p(|FE6CAJJLtYM~icbOFbFC7E!6KK^i|W zZ|VAIK$R{%Z3u!PC`xrvThBzRd&36bOY}QGo;xJmXcja0%JU9dM~ZWXC%KuIf`k;9 zcG`IMSqv$*eA+gejj@ZJHrrer+-k(pm#4VHr8Zxg)2n5C+o|no&koY%M($&8nS{&u zuIei| z9^B#8y}9?-?{)V#`WvH1zveHX_C9ByU2E;L_L_6e?dBnT|D(>~CN7bOp-N_LS*a|> zZSugOQr*Y}01=V?eK(=~?oE4{A>}q>ke@zBy=nfS8F4eT2#>3Q`N)%KG~9$AuJ)@+ zB#gMp9@*wm+rybo5vwD#7OlVi++eIOT~kxP#7dt#m9i((&~o0P9&eD16% zFqd+Vq_l4w3BQ5EU8F{5T)VQ_tB6S(HW z@!s?p{4XX9CS}N1*aet%a%jl+@nL?+Md81Rp~8Q4Ibn|FQ}hRO%E)RvO4!Dnu$3&k2o8(mb!DuN$IRn{(yPXger>P$#9lxycQ2*Ze0; zoW_vcWoNr?ASb(`647geBsZGz{2vs0fpO37Vktjkd%^k{z@**>(2e85_V(DT*mJLE zng?$k?w0RN?#{W23{1_9`8eoTb7HH1%x7fhML+(;=!yuu`vN$9+4C&!5&!pR_>koo ziIc}O*hYK3G*By|7^VS>o3DPy06O z5VIWN;!nKRG7a9ph!!S{Y@D@h`^}Z6ZcYn;rIyiYN#UjwlUctgjhKZB-q-oMzbw#m znjjb&7W?sp%=|Th7}s4SWNTuZR`jn03EId?Kgg*a(YNM27e>)!xQ(c*o;k>v zM6NptrTQrBIzKS%j1w+~^3Lar%Ogqx>})>yXrrp{r!RH{Q^_iS3i27kC(jTHsS~Ue zk}6p^Gs@}QVgVcooYqg_dy0G8U0zK0nkN-Vzj%^kBo z$Sn<5vqb^)08v(%Xly_T=O~c3HH~l7J9~-uuOKg2OhX=!CW< z<<;wo_I3)O$LWc@hqs2c2iO6><=w>BLy++fNYhZoY$0|q9!A z>DgOB?VBpQ-&KeE&fKFr@+#5PVIE=766`@kO;J+{9 z0B=V{ge`c7x-erAPTE#*TsXahm~~k(`T`{(nq{HS-n0MKq{vupSv*kd*^f|K)RuCQ>7&=DgP9 ztoY{(#NS0kFP3wG7;%s=#z;TEZyZIX=nF#=nGn!V_mql8($f1kSsi30${fgrEj~5D z@y9hM+OW>CsqSv(_wJ;>^^Dt!T10vDGnUc=%r@-AIEVC#qx&LAa4Cq6PmdzYdL-T) z)<5%a6BLwyKiNAy5^W(ktv9Z^JnQu-RE?2hpM0WkoJUVc)}v4mdAv;X{#fK;885Gy z+O18zK#(7Gvg7MWZ%9NuuUys=f4SC{;Ix-;a{lg%e%lrfRWD(%jgn@ZCwk;=1SbUr zKLAkFC*m{5j$mp&mgDl!ZAGshf&k{Pmz4Om*dC#6&xrXHo;B3{Ib zSOi;rxB1-fq?j(Ist7_zM2x?DxvZVh!lRlOmF}MG>X;FqTA0=(#Mi@(@vTY$I6tCI z{Iw5rgvasbLb;sn`(iJrqt1q|lsXiqC@uZn`I?sR7hiX$5>Maode4c7Sl@?p1dop} zCL^j*cg^(_^81OpVVi_CxMVGg?68%VJXI{IcbD!ORh0fr^#SiWT)sXY*~FJ^ImU(4plw65dugM#CYwqvQT)%%vplw<`U-3xU??88 zrWC0)idlA_b-^)oGlR#Q6O+6yE*jzStd3CiA@MwOAZ8(=v85Ec`&@D*ZlXtXLuzNp zI2jKct=u9Y#ACW%Y}0hr-Ysc@W z$$hH;k(Oi9qAr}wdSK@Ma>RiD#JJ1H!?t43MH4F95$ff(;9w0uFS2>$DJ+VpRc;r= zSdJLqH0Lk!0#{k(4iW|4yS|CGPEGxCrR>uXgO0TEkWnK2I4*VT%23y5QtvydFXvP*@#hlcZ?O2|Vr z86FrZO%$6qK=hVG5<`%&{L}pH&4SrVh1N3wtlWHzp*PmxZykv{cTj}?C+GO5tnMw! zv1;ibjkiXDXaDNLvHqvdGxt9da8T)g72=-Z{zvWYzu%A)x#Fm*des2C{?PFy_1Bi< zRp&JjxZhFvFc3Ar9PoC|6m1pr=OLbvs?+W6&S~ptj-USwETu5Vv=f4e2eevES5#+Y zc)@1^2`fs9H!@D9&zE%n^r2vQNfpI!8UX?@0AhOh54B}=UcbH_&70)VwX(AANlqre z+E8P0I`g^lGsk>TVQd)|R>hZkb_$c68yyvDt3)FN2LEGHUswsjLsp_h0z#*bi_KSR z5D|UDd5=Tgn%Sk%QJBkG+_Z_PRYObB_2i24@Zr**lE?2)boBIu59_{au>-1S?fFgr zRN~(GJ}7%$^B=?g-(-#dD)asS;0XV_Z!OQ5sQA3D;!(xFHqCrL{ZOGOV^8akY1{#k zZb8}-psG{j1EIz3)@gP`5#6BmW1r_K|2WwP8GIMa;G6D#XncdUFy}+He(QPiJGz;HaGj{@&NO}!9grJ)0PNOO8GxdPS!*%W7w9a zV>zhwPv1KLtBkA#_v;0ge|-2^92^G>%)Yevca&qoD;1Lhr+xqY4b6A?v1kMH2Q#c? zEbGD{9^|GG2)n;O{9yP!YNcXUwQlh+j`&aW56~vwh3f(R18fu%(?3@rdQNp+&>V5& z>e;Tr$03YXvoCaIdv4YN6KjgfqPdvfw7WVdBQx4w@~G96z4Zl zGD7iktYvF|9JbA5I^nS+=svzd5YQah;3JG~tq;tmyVxtah6f9gj@t5SAHS)Kh{Hdc zpkjaO1~7}veE*zBU4b|(BPvsAgxr>P+%;nU0vM<&MN(RItufA z@F|F^E(OiYRP%|~^N5O;X*FZd=yji&hFGGcd*{FH z9n!oNQn-HRRu6O$1~_)d_EW^OPq-oX(jd;zCunS%Rj*eDPe&o>6M@?^aP z6^Zl;se^g{j9`#t6nwLh9hgl(dv)|XC-5*m?Ln~jY&OpWN&M?RX_j(#Q_k|lu}~8O z61DZhwL)(thCdyZLGHhP7Imnps6-v6Cj`gvde%lQ8%o*5{ffWK9{0wSU;BY0P2WFd z^oOcJbc^4PJVuU<4C17=kon9PUjv2Z`2LKYQ!FqrmB&aJcAD9M7UiuO*ku7|C_-EL z^&*m^Q{O9yEpjlsj_iNjZ}IF-6{QPs&im+L;&s@ahrjFcN0sl+hgn7u_urby10MT7 z&d*i*w#_27Q-xy+GE3Q}`>#PD_ks}B<{w+>mXVouYY-AO1E5{;iUW!JPGRL-Ttz$y zvdkzIT$^JC@B2H;cIU(I1?j+4L+T9}qq`bbx%@K*^9(mT{!I7aHab`sx9|J5pzMtl z=hUQ#9#PkRj|uM$jYZ|Lf1Y;ydD`CnL0b51F}sMiz(HuNTI7zXLQXr2uEGZK>b3hA zaD`EOH%+|qYAcJ9k?hZWT7h0H zEBpnwW5Sy-h6VhXH?KJ?*Eb4k_*0kuSsAQmxsJ9SLH7r@X9>gJ|0weO)BG)i)ZRZ* ztRMy*0|Sx6I%u~U;J}CX``==d#?KE z4v3{dop)J*qH}2{WsmW%5pfFt`df?zGq(T!Yx{o<1fW(%`2%{F-><<>aey)Fwm>8q zi$oREM%XcxIcQp};OT68w$< zi)3aALHkJ$6?OXFIw*y2YuDFv(eO+1!-}?Qpa$v*lNWF@{_+AMjmAn=xcVGO~~vwbW$QWY#`T{4V_J zwd837A(EqIc+dL=T}g9N^9Y+-{3%LNl9zc|D>oOo@e`R*YVq+U4i00h6y4`Rhe|$W z8Pe&>)VG5e0K^Gajh2U1pPj(`Jq@#L+ch!xzB*o8%V><-8p^sI#=C!zWeo{n8soZw zuHl?Qkg2|A93}y~iQ|WvNwP6++80TS;0lhHT_yR_a-i0WI>_~8QM-EgcVlp|<1^7< z+Y(z^P%Il=uv+Wc$U)X~$qPgW;yZ4va;FiJnMs<$pHN(g4 z%`pMzS78LVwcw}$_ijR#?l~U>Z1P=8&c%$cWU&ed_mfS$;{y5_L@1fBYuJrxd!l-m z-VO^nEMS7#N0IU1yLB}dbwU@#^HnwS*Z~En=~0Hus1`x}G;-o5&r5P*;!v2YBW&TU zXAKb+!;n-|1FdiIhEJE11!zQhQ@7DF;8p5Db7yNI+`O{YX!*Da z2JsqN%XYf%z)KWL#Jtr$q{=knJ*ms@j@fCpPHe2i;1NYE6ppuX&y<$w|tqG6d%Uud-X}yNsGa2SQZ8IKFRye{LuJ$~wWjNctv=0Cv;LH}CgY z-%CIo$Sw_HE#w?@g?7nSfjytXv;{&E&w?yb9AhQd2TU?@JE z8km4LSI5jVI%6Asd(i1WnfK{a@bs6X3A!0W(KDbmc%yOX;!P4IgI=}0HzzQfUh-Ym zW9>2IP87%!u<1_7%&v+e5}H&zM{42MesZLq48m z;#UIPcT8R*U-kS%*LEGTbsDz;OyqX$XfNUJ?|E7=#WpL6=dyM`yRdZ|P~=a`l^~_f zE+3cMFjFl1Xl2)kny&1!CO(8#RL}};mr~kt{-d4nB#RGQZq6ZNAike&rqxllnpdvh zXsV*Q#M}wu)7-~DQNLyc+bWmrPG}F?NRFucWCDN=K0+3YJ>hIJG$rAG_CPKrRDgD)mCg2KhZ9#~fhj z@Xgh^5PJwDrRK6Irot5)`=<*x=lMazX#uT2xYGR^VJ5r00XCiESv;4l=ZbQfqW3yH zy_|b}{O0^RKzm7iKJa{P+p+oE-uHIugV*+~vrPqlmOj!MK}Jc@CHCtXxo}=YJ>bj z3b`|opbvt^9FWvH%+$5GIpemr6~KMO$~eBNglJVbuaLGvd$?WH+O~)bx9Ysf=VDq+ z$$WM*C&MeAr?JCRi_@<42RZoIFATV+2p(S#PCSLn1j^W$oPOVBRF_flXb=GkIrU8yMIF%J z^vnya)EVikx~S{EjtBDG*0)H|Tf5W=p4uMZ?k<}IoJdOU(wT%0OpBd)gQj(%vmb5uMX7$KW_3OVjL_Hp$TG_kA4JLZr)wJ;n=p~lU?i*E?naWmu( zI+-e*JM`3`s!pmn9~|2b%D%cN-)f)zl^GGHP7LJqkGDz_e&?U2VX-^lg)njO`Ip4{q{oZ?JUG2$yOrkgvY&ic)fk^FJAFHOA>sxLYu*q;dN( z)z*8)7~$WMlJwwU;>YIC*KkCARm~c=xOSR#v>TaI$ZEJg?ubRIXhYRmJ%~!R+Li|$kf#MIfnpdQBZ1D#a7H7zy~ z@xXiW-Js-)w@hQK=Fq>gGgA2s3z_tvNL98f+YfDOLZKUePZDXI2>iA9qiB+E0X1sdr7P z8w&Z#5M)ht2gl@y8IpRj%Qjb=wJk}=Kz{Qf-YppH>iTfaxRcN^Y)oJXc7w7q zTfhG00_a3I_6s5WSGOun9)q83tZ(4>!bfV69>S!n2b@d-Nr^Pk60L z&wnLS0RS3~S187nUD6(7eG`*H>u4q4tXt$$4ikK|3JX7|`J{&TOs9p7;bP7GIIpS8$DQJv?(2>B9Xo0nM!vqcX6u z-R2_`v9RYs;(d?AqfbL^xM(L#jS=r49G|Uc-k^u<>1=iW1iX z$TK7Zl~6E1@?a_aTJi?+w8OR6a@G*m$Si9&p`k`kB)h#ptZ|)o=u`E!Qj{(xUH*Bj zTtG4=zJ9WcAe{2q1jUPKMk zJggL{j7#X+3a7$5rvuAP;10Smrr(}A2{Kzh38p~=XQArHi_);iw}oh%wOTl8M&7?5 z4qlpL%>A%v!vcs{lmqE-5Y3iF-@)nD5Zp|adv`gz;()*7`_~<_Fn6quXPSVlIxmhe zRt)NU?^fG%7nV(smXy)AwWXp6OT9LloLXOwN`};+8S+^ouhWD($}UG@vPD23bL{vS z6F?cMn#%%7{9`OvEjnCyGicr6=FH&IzgRzS>SxOl;uJ=8C45FcUAHL*RtS4#{md4J z14y*K*=~h*V#kZxE3Wn$^I*@+c(2?zkdo)9CQGZ5;uPsE*>2&@$tL%HWHE8!#MFH6 zLw*-We&oK?GX3(l@X9+7V)MFstcW-i{T!81>`D(EzYk;6G6VF#tnhpPLZdFjso2IM zGB&<#32?S&bGIO+AR=u23Y*EjIDOB_+E^&DlWm*^M_CHAeoClq0QV~|R5J*Z+YA<4 zVtp2I?lUmrtWWm7lI9!fuc$DM61R|gN-OdC-=-a^+qGRi;I0eDj5c7TKk1|Upu3e$ z?|t*$J|*lGUGO@63N%sn>X=iFaOhND%JPOgkpfe7ucO6#L(eVY z^E{dh?sx?#>tTg2J>`bWXU5(ziv#Lq2O#*IBu!zQ1RgMy$rjxr&G3AaUR#IKZDw0Y zf_5mCy||H+W%i2>G;qyll1&oKhT{{mdY%r+~}`&jhyjg)h2&*P(0TttmIaqKeR^pM{qzKm51F= z?LMyMZqMJ|Y~UBWcFfqLYlQGP2%T~FSow?UR=y?vxSD)cbRJ()n-~mm$O)LdoZ`<5 z*-&lbhF_ubmIX;hL7Li83wCeC?;Z7j_~lx^r-WBOS;YOb<}=yCx!akB~Kd6o|5Xv;)5_X_eJ!$cWB>2 zitxKwr!Hqi*xd5%$pHPLm?!rbf0~?ENlBlOAox-s`6!)k@iY4t>}u0=S!CA61$!+# zV^PxIeX`79Y-gXikF>uyIr2XItV*d(jZ47RVoo$kFtdqIW7WF2{UObd+plOfQtT-O zeIJ@W(K9bM>0xfTy-Hgsm5LE(bXff6>eE@-<_Tn9Iwu0mAt(NFVeuFZJ27y&Ii@Le zpU}Bim=O7s}e zGKYTQleWOo4?YHkI873o-1;Q*Cq4LaeB#Sh=2o`wb@Z7B9j(w@f_z&16 za)^>re>J{MBDbkD@KA$?4CN8(rsCHc_qB>}9FzO{&MAeNBP=$%{xJet`+Z^}&E!Nf zcD6T`RCKX3M$5Y}t`=uE^})#%N%d`K@BEBMgd1EOfJY zYmhzR^BY@ISl~(DZ{{&gZ58&6>o}x8YX7y+R`K=1mgMxx6; z#uf3*2{H(dkH`3b^xnJ+w!3`TB&_7y(u~f`37;B6I=iI?b>9rhgw>nh6URE=Y1oQd zfkEW~cx931Vo5?Lc!7(b0hFf20Kh>DieU=*sZ5kF{J6qy&%>yOK;n`;4zC+@*Xg@t zqi+SGLMkAn@Y&+T5l{)?b0W3m3hcpaob!4!uu=zkYLHsv( zp2UaXdjFK3@NuDjnU+GB7F)L2ey zA-{K@TF>BP!D)``%0HHEt3XbVCp^=$p}i)TH+E0ki16{wC-e4nQIcAX$c;Tq-DyMl zF&fX8>t2*Z_1UtiTW$2W#+2{`w-k>C*2?bt0LI9~U7h@# zHCD4q_jW76!AUXod7)|!*W1mj*zNnn9JcA(SpK%mdk%PSFhs+lq8RszCZ5BOkgLnp zEr)BmDrN!kp~Z!_1y?Rp(;i4^)AOlMp*Jf|NmKI=d%Ef~F2`AD^F(Zlj6$lW4q5D` z7m{ms+sDFnhegr(feoln#qW$2ACB8|$F{jyd<#T)6kIRP*Jktb$(U^3B9xc_y5;90 z-CWd8_>pzBJC~l+i)W7+&A07~erBaeWkAJi7n}$`LoZ?UuX&~6K*>4YKI$;$E{`AC zl_a*%Y~i(YZQZl;wY~R;NyzR5aZV*Wk2!xM##l-XWcY`oW?L`P5ZH>l+ACMTlX^~v zmYU%e^3p_wFf+jsaKV(XxwoKUI(F?qHSu1H;p7^Xn0X4jXvJ|*lU@8I-eujx!-`yF z%k4?(>cy0anpEx!3rnl6hdwbTB{+|nS*6iQZ#H$iwz?C@eqtPca_q9c0M}22g&@?o z3==L&e{=>1yy)2%x*e%D?{Vi)!+HA>-K2t*Dja0-H(^Q@>LE(ZMSik+13<5FNc_}d?Bm%)&%)UC4y!3QrGfho` z@g<6u6>g4WFH8F5`Z!{x>l04}2$%|w#zNAwM7T}9W+LE@RQct7tQSH>95R40%h2GmG2yf4|n$7p6jAk)^)Vo<^0S; z87mFtEUhQoxJh-MMq4=i@8T!$;19XmPQ;cke$FBFo0uLD9{?Rt6-s+_5GP0=%NRQ8 z$v-AC=Mjpx>biH{lG)9hHPx^zIaQP5q>Xjw`;Ts1JUr`mvP4B$kl_4wR0966w9@c< zv6Y)jF*mhAWb)1NsWnQJtE-89uOjq-#n$)K_-egVDY~0R>PLBGX~f>Slc@)?^&bXRZpMYgW$+>fa>YY0 zsR^#mf)OPWx@U*=7}pKDhv9;NfV-QH_D0`!ZoV0pk@$O9*|ENO!G`vw#vtG9o;9L+&?8Gku$^1t0SQR$W|xO?0?{^0YjhzkfDpXLxNLkf8Jm(p2!PMN#7ciVmmIy9L5ZlEB=s_%itzh1WI^}qW z0rD0QQEtzv@i)*D%=OT8>s;K42IxTOFT^eq-*M1IC{B^)lW?*Kct?Kv{iUG|VoUJnMd&#l%UNFA-E!e7c z#%E_TB+F&khnEi+TRJ&mc|sDU|6%{b^uqLB@r#Z+pYfFhGtL;NgiHD zg(aVgT^@3fu*8*4?fCgcbm*Rr@?=Yn5}gCMrX6pkrB}ad&GHD{>^Hit9yu%IR@fUG zh3jx8KEjkV%~vI-N#p!J%9}lKs@@ z!#<~Zh4)R&tzv|8KGvX7i-i0+V?$A8Rl`AGAxBiisUYPK;}3>punjzV$Sc5sizHFH zm!l~BKIr2I9t|5qdoshD7ZK5(pIy|19BMN zUa@=x{k+F8yNjvAWRt3zvw-nVM4MZ-TT;_bhf!0}yWvVT_cLpA5((Wyff|7`{fa@w zWaX+>P|eNmnR0AHY1J~H*IV)HaY_3Z;SJZT=bD)v!<&QEG@Vz>*ktqw%c}GnbTv{A zwYh88{HuV%L-ExZs+D4=l83&+AfJfm?}s2f3-b$*9DQr2@twtf#=A8DP^Bk43X90c z0%Q;KQ*RhO{wx_Ua}zYYJZU&Ca0aYUCaGK5{b&4)R_NZofEymk1zmCWGlPtw7NSo~ z14%t`YiIi;DE(&tH4iO1_s0RMO3K-M3zHnHmh0_PRePWPwS35YC3qfZ&E500!5&CeeZ{##dWzJ@KBBdI^pRE~Ul0N3q~j zBmSs`RishYo91%A&VGwP9R_y;l_3F^C7@q(N8f7)rME_&!@CD@V&!6Q_nJK&_vn3` zY}K8)1rxRxG}`&Z8C0dYm0oKjSHwekJVJaqpl%9RC7J0$35Cq>neJjdO~Y+C2`IKOX zpyyXkNCw^naDStTH;tkSdMoETI8pJm~ zCciZ9v`mZ`2a{TC%4y7iQJcsT-cbMd&i3QQ54{~-jLH;$fg7=@L~J=6Oaj&_xGJtz zl*^3MKBpCEv4hMQPDRA0Jl)10#7~mQ-8><(1}s+RAJNn)D$+%?9ZHuzFM6wxRTIr{IUAJjHJ(bP7Y7hp4egkin@b_xnoJ3-_vQTo@K68<D3p9g#SXB++MvFqm}TjgUYQ2a8`cKvZ)2en~th6H4f z+itc7tdF!48qyH8?>(_i7sNW|$nYUDNMix+Y9>{UY>BF9XR!qddO{s;*lFf77yux2qzP2 zn>r_7pXtm#EU?;m-NW<|y{nn{#muz|dE<+jcpn81bIVXMULNy}sZh;2NWhJ*c63MY z3-7IIi3S0g_f8Ha;!Z3D40ao7!<<8^XML5+&AZeKNvwxAl@g66Y6t|}Zpbqy0Ey1B zx1-eb7AA);-yHMo_L2cb(n=6>^6{UIUFXqBh4Ab7QtWwY7{HZVxp`}rrQT9rtPr*7 zb}zvDg?Q5jPx}>IcKXfBbiY%6wS`n>R}UfW%)0%Qt_({%Ph=5X;x_#r{_}Y=V7II> ztqQ93YJT^^FQa>MZlSb~qY>hJK6`uBDB5Ky-ItxSR=4|cQ6V=ZZPU@i)p04VT#Xtj zc@@)OhsA(?Cx0HVg;8@wd0judnKG|5u_xSOfBM@ytaU*Rw0`Id?G@^?QI_hu_Yr4K z>F#O}TAgWcHu&e0JI#03M$-U@uje4l;ob)N`jF%9|<|l|hUnUx`;) zc7tPW52yR?i+je)<|}AXhVyH$Ueus>gzhs&utJ2bH=j}t;9A~I?_97wJWr9&8$mL5 z!*LO?<()@Bh%mYYnHnt1hdMrL5Ea8xSaq(Ob$JXOZsRFg!Lw79sDIL=hrr~1E?dPO!=nwaU=8>Ww$S($ zqtC6^8;j{A{{9}vx)mb+(oGCJzC#r~#NDv-*_#dV3yEO1xmu!P)0K&c&tqbpYpS4} zI@u!j;g#Ad8P?%=;&3L=dev#nP2RjC10V+^XF+{yjYs`sF<9wyuFdr%Si<21Qxu+z z0UnbN26nlC&O2q9P-svA5|gr8nj?f@bm5pKi4^$gZmu+&*2@1YMDjsts@H5uogAd6 zLnz?fATNT5D4DBiZ_AWJA7*}zJml+w9>olk1;pYCLqi3;;lPGne65 zSM-3>?OAd%HWsLBVvElEB;p*rZ4|I>oov)uTCLN5^Mlg&N0K#6m)pVb8ts&-1rAub z-BndZylssLt$u9IfE@n0uOWEb116~Vg35nsxvr|bRzZcdByX1&KMu+W%^7)`P^QmC zRYcgu(tfkqee!JzdFUJF*OB%4!|-dyM+<%pnQqJpP_qMb(ymL<9sAZAG4nE5XNP7& zv!BPXWy?HCN@B7_xWL8$Qo5mTv#pR@lm)=8v%Zj-m=XWkZ*q*rUR9}&YS19sE7tXx^K+@^KR7uw|oJDnDlJjBEk@#?q(y z5+#nf6|alAT3PfB-;S8po<{EU8ETzB7s&5R88&FE%;^!DfyQtts!W9?D7p%NV#8s8 z+)t}5;6v3+?^W8>@dAi9*CKY`MAodYd$VdWA?30B3pudous>}Oakzmkn2?nC$@a$Y zmZY*Vu552O)>un})5JA>DsUchTn}vm$)G!|GdNOIMCd?G0#m?aBWwdt7VN+wa zdjDgR+LmM)&f}#F6#;uAW?h?Zixd3aQG+)1_3~L$NIp|zICg2@X+{l{P+@a(3?`_%KQI5sq%2R9{G6}L%=@#W1(5q`2Nx2F zZ(?*=Ejf=|g@gYW2$v83e?Yjyji-B7R^fsUjGMY%$AJ8o!@n8{)2iD^MiY?0BSFtc zNT9oN?o&-z4}WWC#Bta?%&V2$PTV86kJc|O*ibF2av>8YqMj|K^%?{UAq@<|N_?$) zdiH6cky@g4teuPD8YNJtL(@)2ga4y(XLBE~esu`G{y5hWVAig*=XYD$fluH#?NQ!5 zYOG;4LJ2j1LRoqHo;`bw%z2w32U69r$SrjZDtjuU3CJ8BQeEqO(kmnetO1V_e-FkRvc}0Fk;(@*nwyeV09`!x3K$P zE&vId1B|RhC@bVCP0Ezw&SZK5s%IQP7Inn2Lz|C2zu}}Uu8imIlo`zaxje6$ZA&L+ z5-^^lRM*0X7xnPDx^=HN1(weKnA@_KO)liIllI{Jm5D4r5k?7aa1ZoO~82wx?En`yOiwj9l@0e+9)Fiz|!2j7421K zWl_U`y%x2Tk@JC=#a+8&LtUGRsnlsl!3S}CjJWu*z(~?jtsZf+`S3*c2kQ?`S=hz>+>v?ez(NrdO_Sy4ObL8sa=?rXsb|k{z5HbY2PxbpA!lad zqnyDnHEV62>%jmiC@9To@uu$xDH0ed=-G7;%HhP}ceNs1QtrPKTv1GJI`U+f+bE!= zTVQkBsYXBS@xcoB{W?X}^>5LS)yxL@-cp`M$9MBJ2hVA0yD$k>4DnrkA+;{aU#-rt z2sB_IKtva-5)tD(=#;T~ic6^ub68KshSLaM&U|d-OAUhZ?KPjq#bUdp=aevL27=dEy&@#q4*#WwV3)G|I#o+#&jw4}qpo*_){1yskWUczOSu>% z-foM$+{&tBwo|^CTjjhOiY36zZtCzEbXpF3TbaZCke4t0PSDe<$h3`V_-n zSEZ-#qtEUUKpd88q8O~DJq+xPGC)**8~Cw|(fF#eG=*$2z83XNPs748Dq=6X^}cTr zKol2&+|)!wa1ZNMJI7u_y!~bpoVH`K0as&qQrS*weFdE^B)g?%af5=NQSVvOlUUbT ztCijy^1gf!z-E1%gdMghqDpOZmGdw@JvR#UuR&Cus0|!*h}-omfh7*@Z;qQQDo!er zN>KFy%PJ%U=l1ro%+GPff~e9@0&`9c9vODUP6t7w1Qg&w2R$3iIo;f!trrqg zE6A&7B^w5j_0qgOme2VLeHSU+hVZ#MpZhJY+*Ktx$>f8JHcNOd!(3(U73&3gV|W1v z-l7$rbmERxkrm{)vQu$Y_;!eEywm$HjfHAsQJ(V*3005kAQXo}JJ)^G(b$4z20D!l zFes)Y!32h)+JMdNF|@QM7|+Ox+0BKkG(?HVU%ks35P-pq_}l1tUOWP$7rK&5e^*!p zz+&V%X@H+H@PdAP6MgW10r!VpfGv=VUnMVeyMQdVklT44i2I>e7_{dLiRIJm4m@(q ziQHFVpP{PSPeVRTZUM%=c99Hav1>!aGPnosj}Hyzc-wsE$-PP?$76(0oTgRQ9_cve zNS8okOG#EWa3}h<#xvULcGe_KM6&J>KEb)4*Ah#K2a8cs|C~`sE}TABk+lOBzV5IO z<1lO%6W!M4`sxsD_c0-=m}_Y(dVY;5j*WTy)M9XI!uRWFSNCd)a51+@tZ}}QaaL>J zs$YZIgjDKoh2(ABDT=L!Y!in|!=b#1RFN{m2xxIvbLcgXE~K|B&CA@(KyhhKfI6wt zd%N#rJz7=u#q9!TTIa=6UN?O>sya&CMg-+-7#rjaMl>1G0qWRwde2Av?9A#6RA2t2 z747-(ra0C`LahE(fd_cDa`?dS4c0`j0WEgdAtuD@j=64u0BIn46Y^KGk9GVJ| zc9%oGI4@sgUGlE0L`G}i7_wHp+jtb*3Kt9dKg@+7ty{z4H`;C>) z#+h=(0D$X#->3qLPeliCAd|`Z&DS3695a}9mz$8QE0-z%C=Yb%ebPI@yOQPGq`_jo zzT3WF2gc7fOpzBG3K?MR5ub4Iz9#_)wrAX4&rn#$0#riIS~pM5p@EF@i3V&|WCeCZGKCgj7<1kkKOs7-)0ZrP*RerA ztd?!|p`bgzVq9|_qpv;1)TkBi=UnG;zT>yzq(w_joF4XD-|7k{WvT{*kZcc=S3Y$e zR9MgQ)yN669|+0tqpcwjPhG$eGFZGTWC7V)Pw-l1v$Vxn^~60#SS zEK-b56s&o1ChY3Y6f$yS=zU7*$T>7{>zXZi@l6esJtMNa`h3>j04)wux=4x6Y01Xj z>)BpXCGQzEJ6h{ByNuN!rF-cCfYs(ki=8)Zk9K{(3!DUF#nwU(LLMX=921*S z%{WUf|AV%-j;pHM`o0$>B`u(&q;yMnN_VFq-JKgy5Rgd%ySl`Mm#7o3+-gHRc@O`5R+`zxReP9dRn(kD)K!s=FjPsEoE(Cpzdg7vQ>!9e<1RI7c z@e<$#rLV&sk?P*{+57jF_t&jL&c6LWoe3E=)hJ{7*cq$B6-M%+oSN^O41M@$VlK90O)YD0(jl&o=N8$Tc1(+L%7e;EVt#9qp`jy% zp`5=*$#<=@#rv*GPDC$aq3%>%2)DP1@?b@0G$7 zo#f0>%Vb;0El)6u6RXqNa%4MpF30MA7-~T%|1utEJA2qcC}kP+-W}(I1vzTw+ac63 zV~_gm92a&R31io6q#W790o#uJKFz`w_TK_dxYmuV`sPWSFO6F>wI=8HaEl(Reu84u z5InsOC)w-#Y&+LdNo#+E^O>Ac z2lw&Y9~txN@0;)EZCwTE zJ@j75w@#=bPzmkqM3y&Xei8FTfVZVINb|O$Ee~UhlRXn_IrS=jeD4%Mfu{BTG%jno zp$zqD;CQX=_bRPftEToG!@<+j5hUwmQpDjr75h;u6djSFS)9T2292M8 zVQhzZm)HJdIMx@Gn7AoV1wV5$;p!OW7#=5iag)%29+Y^W1_i{y@My^WTlf68Z=FwE zmSzx@Y2X3tH`};1M~zF#4#g7}t{-)!Mb*TfBzAZUI;u=!dBbq{n+NR^-@4-MYfBIPsDE zAMTq353x+d~EXO|j zX$!h1aJpFXlxZZ~SomSodO;dp^$fkCWlY|-t^#=N5~g+Ch&8+l9_1?ZSm^k;RfYAt z(NLS1+%}SUT)f6xIH`QsFI#R2)rmK^3{&)Vgk$@W{x@6fFEyJZIha@|bmSD1Vivz) zW>8fMPo*lV+p%(k`ZN8fjr{uJo0**-L;{!#yZArmb1h!&&1i{#*V3Ci)7kxo5D>st znpZg28TMv2*(xV>r$tAF57Gul)cJoSHWOMNPUxt~0Xc_-wLeXS`GPA%#N0b4zq+W9q@m*Xj(G z^uHqkXPK>TzR3#`ch;G1MuzkP(g!PJy;*nT6*j(ewWcwz$Le;$WxlVCFz9k)bmJMl z9u4oT{F!S6Wro-Al1WpPjEK)%rM0g|q_DTOWNU8C{%)p-gB~P6noJY*WZWN;CUJ-` z^X%l4tc|aXd0Rm9Qsr)LvT7e2sipC?@>quA3?n6;*3*K8QO!@cu)|ce8M?>}UPy)2 zZJhT7?1HS?XU~6LC9Fi&l<#>Q_E7On*@PUPJnxuuS)B?vTmY}MYqJHds%-nZ2L zWn$p%p%=!8MsmjBD(f>dJ(G`mg&28Ip~Sp1X3!`3>NS@68CAaz9bXG3RNKT`j>`DN zjwTLQ=)+$u95U^>XiRQ_-MXmVO>{&7uXLf!o4HbqD3+(}Ek0*9azd|FN5a3OWcAtE zg6H4FC_W})-MpUUA#yOZR@BLVJ9($CId*m7?K{82dpr2Lb8S!E3^y_2I#^}a416v& zvTe!g%i`}v4-IhCxley_BN!m0W%W7)fwn_+(l2tPL_u>m8~(@pWp|%a!_Bm7QYpp$ zV~?Xl;p;*Hka-2Wg2Q8^q=5;wab2&fx&`mlod zs7}Z?1Hp66jzQ#axYZ0!)tVb50^WAxe7JqCrMlcb5Yw^lyWP;mSqXB`&$$+N^Yzmj zTwyGLj=A6#_F4hr{Yd0Z3Uvqi%}f@uKG`UOn6}EkZJ+v#Ny^=$n7a#Bkg&z=7uD^@ zhDvBf@;ChaDxVQ@cAJx@Rg1W~M-M!r7+gFn$A|2*QRf##_FYG|Pv}poxqNG>KO8jV zBK~}y`HZrs$KMEpBt|?7fJtYSj4;`vuL)vW3+}TlF%KWp`E3yfG#pN`=K1ZFg6K=P z0(rXyFmG z+n-P-2L_QNnzr00#;Bg>+_rNkv9S-}`hZ2{M&@@*&^BLd9|&A#ptI z;r-6p&X37n{RyqW72ErJd{cvqX&)g#G>`(6(giwUeYn|bbiRMXH+J8S+SDizpvcR3 zi%5qtVTH=Jke2~|GV-|RhuV~Ra8#>Wb*I~oTbpFLp`>dx8zhyprfh?Pg2JU#3zeDq zpk*qI?nzc9Tx7d>p}j5Lwodg{&aVnhA4n9w%A@{XPr9G)DIG-Mv6L(lmb*M{+iK-^ zT#2De&p3*%yusc7qnS3MU0kFN=QvISAiFVdkF=1vy!;F))7_qncu9#CVNCjns_gn! zmOQC}#owuF^n(S$M!`crw$Va*j9&n%I$`tesBc3Yv-F01t)QS6af)b>vH@eLDdAdQF-lzL+_9s8w%Ml8#DSF|C&F1xTc zrR1}cD*7(fE%UogGH|66B~cDj@_QKXuY)Pg^+~>dItFVwbyjKqb8XzGXZwVRhz0%LAiO z1QYPOj|b1_oh+TIlNk!rB6r1%-J-a1ULu%X)!1oQ8fhIbo{yHyg zag+gy{2A*#*jW|Q31lp%yz^8djR!395i|8Hd8<0Ij?JjR@mjSAWiYn*8HUk<6zn;$ zizupr6%Eb5ehwyhuHb1IB%XC_=p_Bu-?#+W{^fsPH%9!r2zI3v+z&UxKeq@Th7aC> z)%y2uSx6{jhQIF&`+|=ByFR}!`E~DK{rc;(0W!I3`C5O=vE2>;uDKZh#GQYkUmoX! zB(Ut#U}5y+1smD#TU0++HJ^nzKD2Kp_{e)Z$3*nI^P(tXiMWix&TK7FG$uo3Hjl~! zC(yla6d>Nz7yNzqZ8938>yD|(PWm9THw%{Gse-PgnBrq%C}VnlHEop9uX+`96Bz9K ziw|2E8)BCoo`nA?AACLOc(u%`Y76IC(^;d;-T)ei5CXz5E2 z=$*8&F7j_Z!!ZhQ7pvjphG*z2-Ur$rSdAZYf_xJr>58^ykNn+=4l`Qs1o}XcXZAik z)FRiL_vYz@{?NGc*-eYvLBD+faQ2)q^f_8cg*f^@tn`-$6z?RJCr#HIlm6AX!(85< zF>-|nmWI^472>VJH>8Sg$xuEJ)X(kt+&(IkZ}d4j%<;W^;yjBNb6`NvrJ1l~<$67v z+x-4n^J8m~+qR*>8-J4Phr`6Xw5eG?5yqUV)oM?XdEK`lyVqy68F%NBy_~*dhf4$j zw_|9%7sf4r50A0++9W@7@+&?LE}LErNhm1k6}YyF+M3)l2q+)8M*X%En1 zGkhvsE{}t?B7XO3o8_%~glFiq3!TEX%PHlbo0^#^Ub-ueTjIMLJeb4(8A@4F;ke!! z!{Ki3R$oLJC?dyfcfQ3N9yKi-k`7|fZ6FtpYCj@wr*WaEw;!A=IwbZ6Sy<>@2Yq}2 z9M`85CXt9Nw-GBEgNqg_%|LEp;k*Jw4x|F!=XzM292SMbX1F<)w_Up2JnF-FUov0iY#$;$eg4RnFgQDo8iv`G>ywS+BF>9@Yrv{_0s7YWQtWa5B5L!|P+n2*&I72N4d9 zRuzWvyrY@;W=c77I(XO-uimnQS)bq4f}Q2Bg{HdwnvBXR{kMWzgg3VfTHW`@*+p)i z0xD~7$pu|o*^cU10}Qy%^_`LZ&#rp!v(oxQZ_f639><-N_m1W63w}%|Bd0dEJILjU z_McXS8VSk0+zU7!?W(v_HfkjCFfq1?R}yD7oM<47CSf=@{bs-#&=oBL>ets(Uz1h* z@N)M{*}~GxH2A+>-w2#sYDw?~oZ^?Mg%K-j4|c&%)YKeown-XJ1$h;rSA!1TZK=qR z9XCHm$B3HAwpU!?1(9JWpxcq01tF#?961{4fnF;LAW)+Vc{vo>(vc+9v_~^#Y+=*b zl*+jIVGrj=<8C;al$D*h0Yts+u7yX0`2w?}A8QFR&r|p8x;F2oD2@B0tywGwxor4E z>kY)xoJ<_(SA0M99v8sgTUB@GP&e~W@UGY|^SCsbzN;u@6>M0bb8&yEG0*0k+v1Wa z*?fK2eLeUIu5W5z2%EN`v}9Z5eRntO0;ELgrg(|G?9GC3_*LmG9y}ZnuSyB{pNBXe z=)Jshznxgy>U$;*>@&Y7bmWEyP{h^LqTc#fPZ#W7QKV%)K%#ZtHrKQW4xurC>~0zz zX`jJAtU=3D^OIgH8B8_Lb{BBDE=@(jD%cCFpuvyW3uSA>O;`oZ7P{})=L6zbI2(h@_R ztDwi-u350xfCYM>92pS!mknMOS5Xjs9%C)2?b;aojJJ~8YAXK~zL@y-NiJH`_5@M- zcSG-{P4OJZXGYU`$p;^1&$ky@i)V~pA;z2_|KZ z@llcmeLJ~CI63ChZ#kJaQ?(`6@u~h6Y=8Un*V^W>aV>rbQy=Cto6PVKJXpT~;w1@| zi^Z@sCf7IQiUJEr)|;EmmEvQ$F-B1Jnjip3pgr!H*4P}KbM<0 zhA0p6+}up3${1gHh)C48X?Fmwh`o^F&m6Qlwsdt3KSs*Fmy zqnAMa8@tFVA3DYiN@Y#{OrX`gIIs?z61b`$p~%Q9t5O8oJxApv+yuAx=xs@wYGT=# zX*OFVqgXT^PhfgXV9OgFke|V}Ryof11jaKeIrnb7Uq*5ng4LFeZkcTwe5%$(e-x=i zk$oWo0=>|LAFi=XI`ptDt)&cMnK8I^(1(9Erv98}6Xum0ym2uEo)3oG8gJ6YX*wW% z4|7Z6GH&L78dG;>;6Fj7D$8Gr8E65K`3^V@OigQ zQIv#+2MwI)7{)FsDvOb%xc~-5F2#D5zAlI{Y|uTO79}r+Tds(?_OIjfEMRe*x_(#G zogF80o;l3MjI~d*1!`ac>WN#J>}Tx5PRbL-6s7pgS;T=rmA!+$x)N(*_Z8_tOqMBI zt=s7*f6Xs?pBrs&ZW57*c{&B3+S?zwK~it#^hhA8SW_p#l)%S)yLGJACC%qJ2q+zQ zGgw}MQG6P9WA1`7g!iu5tum0+tWL4*|0JgJJ+oq8&K zrB4>=T`_69IKsG#wwd}F(MZ+d#0P#L|x%yJMbTSHPu;r(tRsp=#RF|JVHqWLFBId zh5+vSR1qD_T=85rv}L#3gmJ!oC15Aa|5c#|#n!#BBTIL5CMZ%(^Hmthm%fgR>i)ls z>hP+hH+z~HM2H}^w@ZxQt;_dp$hT3&w5-;^uV(KfRe>sjP#t2 zQcM2P*yB&H^?mm1civ9y06>EQ-y$p$h1uvMt)mha(UlQ za6dKR))d|5G-lS}FTP=%d~4(BI2EtT^gif*xNo0;BQf@9^1OZevSg4ea>tDM{-QFv zmsa3oeU0ro!U&ppop}dNh1S%9caJM>>44$mpRqh&>5yxahAU_~zeABsVNKEsQbI+-&A8k3HuIdQAVve>EQhDe5p?P(KjGzNV=#k+#+v|%QlvvKcXSF!Y7s3h ztw;N122ZsPEU8d!5gz#ycRT>Nop80}ep;Kq&wo`l-b+-aofjoL%2+^uIeZb#S3i&# zVt%AU44aA&DTn|RIgp!n*pXs>dR+`jxie<>_QLP#FG>9ZqLm4wx^OZLv zf>9Ky9z2{T z=eAM0E3j8@qPu|7`qBeoNIih2d+FfkqkpX7D5Ae@nnhZe7pW2AW3wQZkJ%n@EcGAJ z*%0~AJkDBJcp>7RU;daoq$etC`CEftI6t~0q*maFm;GYak;IYKwr1ot+=*$%kD2sTr#(5_AN&~An@dGRA}+TZ$|832m1c&1utoN45i5y+bG4n-H)F@Ok(fmf zyV(rV9AzV-5l3rg@Xc0CS|T*l*;r%N8Is-}-aVDyMa@KbhMHB~?XeS-?XcA7tFP30 zm%kVabM#^&jh_wb+%`IFWeCQ)mTnHZXJWK{*hwiyq$<*SR>cts0P&L^nXm|tRRUed zgRB_gb^E184wq{^)J$X`o?sk$VaEtiD0I(g-;Y&Wszbtm2ID`nsdLj@_jg}iO+f1= z-7_CQhKId|M}@UJZ>}yK$PxNFC5)x^{hl+56~oKuYYtY%b>AJD2_?Z?wu7vJG_7ZN z+R}mT^DbwrGs=U6o;Md-)Z&6Qnb6ITx1t`>es5xD7gcqSjgLG_e5_NCC{mwznuXev z=QMq%5ooP@!Tf!~?W!Yp5PW*><>GSMFRx1X1Td-5v;2qmoAo~Qw@*13_`}oxBx%o; z6rC}iVYTj18Rhe`t@{1U7!rg}HbeJI{NW&hNOoRAozR03r*Xcv-udB(ur$4L=JnO4 zVoIf6AsG52t8THlVm{$HjF|kfzeyjjJr9N1b#-U7h(R3&IF~QgVv^xLC{L5jqciT@EsFpK_On_z6c zz<(4GMn?Vv?FjSLz_v4JzRW5u^po4-C*WFA?KS>(A&{o^;t@(k-SZBbzl+?x&W^S3 zXQ!Pl-5=lL=~fmbb_q`zW02nLrns4R7ZCtDQj0P#TNAT-yS(Stn36>K?>vX1+UqAS zS-@!_>8E#-`Z7z`b0@@@A^q->RxMP)*32!lCO9(G=6NM@3EZ2So@?Gr;w0bQE*GT> z9oE{kGv~=CTzB=5b;o7K)?X~a5(McCF!JkL8jc^Z;YDO&Z0rS`w?1qw*-zJYUphi3 z-D7*lCYsmEkA4svYdj2dvXadc&H873@h&qH1LiL(ueAp4QU{WCC#CRh$*vcL5lV&6 zx*mjl!Bqhin~f1GUe0w3Gaax|*$ky-5@_dTrV_Ps`iQkB@4m`@%F#qbx^Zox!u`LTTT$C3g^{miKL$ zS$p{6)rU7}5CJ;HdFa>{=ek7qNHw(8-}7NPv~dWlC20&9824I69-3$Ezdh(9C&Wk~ z@m%JKDc^5}fXUN~sF>EWZbCEo>eUU;4`E(8n5>@zQPyB{vMViA*rxCfum8ZR*KF<>&(@-UT3Nb%esJHO0XAxESoCw)Hl@A@-m;_7D^GSO* z#-}5H8(Rzd8d=(W-Zg>+rgxPwjH{st)u}l%A#U-|1eM^cJ5=LA=H5wtFX@_MT60=x z{)<@nZ~q|{l`H-~^UA*+g{sGxYuzGZ!pMoaj(tR7*G@cp)sb0T_r45Y+#w#XU|z)n zf%-)h{hlc?XWD(gsv&}#{9~8nCkd16Yj40_P06GELwIx>`2m*$3!eyMNbr~HUMO1h z3Y`u*pJY5V2+@C6b=@6nC9!qJ;qI%jBTw9VWjE`TTvwgf6_<)@J1%AjHa-Z&CPE)u zXvpsOTBVOF$S(V;NkrtRFbk=m*mh)X)f9Gm)Q&ZCcUR}xfwo_IvEi+yvST(j1>)?% zz9K&gnCQobH*sKSVu=R=bqF(y;!=ptiu;44JZqHK2nKpqpFKCOx)e?RQu{4i;#mu| z|4yx?`=~MW-ifCD=9M(Ph}|B#vI9@JfguJyPrp$iH!c0yr>``sjxD#&Ew0CO`1jx2 zUermVeJ1_&WFl;$q}XencVf^}a-|6lgg%sP@|(7nTdrlRVn+Lllb4Jw*BY)lo(hKQ zHLzfT^2LV>l-GvtQZbZV+!QRgg(m(>X_<+b8E|+|h$Z{032SN1sk^nZ;KT^G zgP8hx=CS<{$TjVgqis=nqcuW9dAiv}c?(oG#d2`gDFj4DxA9)&YX5Ci7MZ%c;)#7| zDC;j-HsKA+O8!A!-rlkF=!3FO-G%;J&|qGOgQnlA1eV1XbSsIJDe3i?aL}CX zH&<$IdtO`$qpBJBFR{n~%_V{M_dR*5w%a`Akbs+ssGpe5z^6Q!{-Z~Nvt^efr7HpBC zT*qjA=+Ta10hW;*{Z|{xrOH7jJ0rcZA0GJm61i;02y%WJ`JLA+E$p7a0|^}>SIWrR zrL#%iEF$@^HR&5$Zu{^jv*=i@r_xy|a6q=f8H})Rj8Ut@n4`~%<;*H9 zf$Xhrl4;)(GywTWdsJ|(I(HxTk0Q{c+NrO3YjbW!f$^te?ClU;(4-@$+>1@T!p%rYx%`ox`7fOS6_PtLpar#PAUW z(4Z5)LdXZ&%v|G$zu(mHDjfQ(!Mn)2o z1&=6i<8AYL(@`=Ua2UcWuP^Pn$!FDG_$kj;dlz~3qh8q}Qzaa$Mh^L%9&r{voVqhb z-Pqb~%>C?G=bAC_+0GY7xou12p{=jnZ(D#H0dP*#8oxkLCKfy|*d?i6>Aw|>=|(V_ z&u9|~!&@!kgKh>uPl9mA1VN(FPnyq0u`=&>&;vv^mxlbWnkA(CJNq{p&-712x=z1e z5zSzMHk22&krn>0ZsGQ*|Bx~v{Fal+)MAW>x3f%Sh&AH;&zs1`9f|?m*l7X9o$!EG zL^@oHih>SnyZ^p|b96@@29CHVGHk<-5d_-A$>i^g2%mU6018%cux|G$&B^fP+5jEb2|o4zAxn)5pDe>_R-*@S zQI0HI{-P};;`>^2+iCAMzhLBV)?Dz(7+_cEF$1$JUh!!3feRdd=p+=N%@*hK4CTsZ zI@RD@^Un9S2^?8RPDPQ+2*=(MbrXYP#Dh&kJc;<8uwxbW`L4vpb=`e^km);uGtyE*fvss`?b3jD8Zfi#7BG62E0v={+9??6x>EA&4gTON(W7}uJ zUVAYuH&A`%xz)M4|ckK+? zG9z2Ha#Hh1)^X)F)6b)(w0z0a|Jm==(s|VvU3=I?IC;gV{b2n34=VWC=uwej#_0_n zx|?{)DQU)yvj>cpu|&L>DHvsWMelr_7A(D)loy2e6H|y_t-dh5LTzyfLB$K;TxBEx zB}gqL58U_L8$wxl0wrn5AM^!{`bo5J2?%&?$sc;&xB!+93Yl;+C@WRqv$#K$=yN=k zYQc3-=p(uJa=A~w?_|W0S_QPCi{?G8F%nBr7QP=BDT{a}NYZFbI9aSDRKkjk}zE!+@=-Q7&|5^%z{rNxh+Q5eMISh|C^MIka3{ zFL#{xyFV+g>b{d7G9V-sOh4t-^=vWlmJz8I*f&c|sV@K4X2Dc7^TKI@=T6sx!>)^d z%FL3My=vh_#mdlCDw72_C}^xwP%2>4qjh9)5#jmR`@U|)-eWIzzOq5Q`tKXlug5}i zinQJb|AcE);AQ3%=M337Y#RRuO?sOyu<_nP)yAS5aA96xc z30Pmn@#H;v?531$5ci1*Sy|Dn-GuL->}giUYC6{R=>ms;^-5%=gC#8`C!-oyz0qH| zEX0U(MB8BOhiJzn2rJJPANNylGUR9biTFdt0Y&g^2|?hOFdgP+f*82ZYID1zg-ZtO zxZkJ}CYTGk9WULl^bEkgDe%ZLpm^lOcMw#%uAEsnV+U|(ey7GWR^o5bS+*%+aF|Lb z(y}D#I&V)b8!2Z?$^J(xkMro%tUQ;!qf&0Zx`{Eq+hRT$t=#K`&0y77moGL^`(A^U zO>nT%gu57EE#DqfDR{w~o-N|4N0{t5|NN*;0oDd}-a;zPL%Q>ACM5`{G7y$Lx z({(MEFW``rg7A#%nXEHn)jPDOn5rk0R@ZJ-j+-Oj-<#+us@Fx}^i;EtNWUBdRep&* zVP4nHbYyKY?_Er+f5>c2)G#AWp07UXG9IeMROaEBxk}aiTqf|JAzbuqUfiXo)^y}A zuf^f!QL*_WsZs;-vL^_VmL*{#>Z)v~j9P2)IUwHKKFmM=|ed)+AEpqmuJA(e#jDQ0%vqXcrDA>8-Ul-;u1eKu) zBud8gF~gLdQ#*G)as5}4lsG_ree;Jmk6^NfoSy7&hdf36y+a1Jna{VVgJ*4A`vi~} z>?P~jrOQFrlnfsSyo(kHXb zcRjSIEfxt`9R$x8WviTZeeB$lI~I&Q57s1P(4@QCljY7FHFfP07a_YvO*t}H8^`J% ztQeIO#=_c;XUyCe5Yzqy{2EN_3Kkv5$79H-2Q}WA_mvto!p!e5?O6xsoYdyey~S_l z90*eo#Zre0!nW(fDzN#M7Ii%i*Sj;d^g|z{2eXH;YCOCZ^Qn0$d$GYCCTty$vr7#Y>^vUX-q%&iChC|lQ{nK-=b0YwWU%nKHFDS` zVkyn2J8ucT+K`r2*VF_@{8F?L;;n8Az9mepWTdDsJwLy|#11L9sP4%D|NC97ktPP3(Nj*4Bwrj zLI-@esTuquAtDfz!#~l#e1WKRTtok`zXN~k2SumJb@Qyf!g*QRbZ~u{3VxZ3H6e1| z-$7y!26M5mo_%$Srz*$t;=-oahy zQ5g!(g=gZEOaV8_y0@>JIXl)mS0(=gq-5HCBo@Z##mTuE%*5nwC_R03Yd&k*=+^jM z_|Wt0ZD8hC_~T(#;;zNvpAD!>8EkNJ5-;`O73vDNSfsclE>AsV;!f+zSuD={KWFo8 z4|S+H?bjG{313Zzl*Hrjyw7(b?gG&qzM)w@F&$Zv`RW*^?m4mU=e8Tcm`*m2O4We@ zQ_>)#?C?JHj%~d#G@X2Rk|f#}u_311%rgb5t6C^jWibA>vC{E;>BU=(%zA3X5+rQ~ zAy@Lb^uwKl=;7)G{k10p_^S@|H+MXp`EIH{$2?Q!Ym2*~UQH%#`o6mfQ**tFO8n$J z6yldm3Z5gTNq?cx{Ua`L+Se@lJ&wmP^0c;7=f-7pe>YAzm3~Ia9sCh1TD^ndm+WjI z*8uZ&<()Xnn7TIilG|LM@s|qi|AiFme?>oGPx)VKy_iMcHDH>Y zWK%Bka9*8!_wyNxJEHuh$of-cW;!o-&%vMK*ZMV0bfqTxbPh;=YJ$D`6y`o~LL3}F zAgl&1ZkqyKt&Q^ZijnvvY^PMz&o}XaFXMzo=(sITo|v`47j4Ey}=a zs_Pz}OV+)n*; z_K{d9K47+d5>BJvMX!d@27z)ZBs{+1Go&&RkEmB7Z1`fFgz=>5*;I+dbq}{eWCBP4 zrM%(k_R>z`0V?yAruvdWXz1@A9FByE+2-hIZh25HA3e@5+2|}wve~IekGKXz4Hm7Z za&;eqlDI=Q0!~LaDiChfa@=rjc8Ug@oz_*XlRP_2#qDo?sk*bz-%>h3Y7&#yNqXt6 z&J0&`{?;=geg3a{CLoXq>ux#a_Puv2=z1+XD|YSo9xrst|1wA2J=k3E7Y|1EEQ(kJ zK|@~uySynfz*{&s`(uJHEFhO{J4iCJb96$-d*hXoLsU>KPT#BcZx9EADnSFn=d?=l zzV6q#?OQH;hE(@?qW?F`JgV;y_9h;b@B>xH%G}7c9B(uNq6hCWIu$8wDHC3cZ=XdP z=)@lXoNBF=mDf8H_SsNq0I{t8kuv^tOg+X#|je?$R45-2`J;|QiD)MBpw0S zPZayl%EvbJ*O{!H5@e_GYv$QK!-VGO%+dbwFAU0?07h~M2qPW3lDjL~}kKaoH% z)G_e?kp%j5(r<*?gI0PDq(2oGXRU_JFivz)n+wNQQ|}IViVS*UzB#=+M#dCwpix`i zFJK+28Vww-kMHlseY~6W;8c|OOh+0UI*bKb$cK~BH?$6lD%U+3$WJA1Hrq`t!kFnK zNo+Q==L-1j*rb@WIOIqHqhUd;Pv8RkCIwDKZtX$9cS2d9gbF47w#I}<+BL_vdRk5q?+A0xJw{u{0|SzLz5g>t zJ7t9(D*QDit*!%2F-h3xLU{rc9?;MR4h;U*0;D!=HCQG&&VkQ+kK_zQU)kQjI9kV2 z&`c#_-|>i?%ui)c30It3nWNq!C*|s<)jpMi ztmc$M54BY(M-$X;N z((jkm)q(;7@)G+ZsUkxP@Ls7NIn0?GD(NFn)-{SPRNoFJYZR0#-}vA?DE>x*J-+)u^F_ z0v$OCjqx>1-}5Drkw>0PUI(8P2NkKMpM^`I55c#`+qew<4}9_8>)Ff|O6mH`v+igFBt{!ut$^5a8n6DebPtPw>Wc+rSSQ)gC zxi4|#=qa9m3Im?|?3Kx+OGJeI&opP#fa4F%Q9_rZqzLpr=g=xf5~ae$@K5Za&grA* zaHxrDX%R-Y|4HhZ*|%~kUT#ZNmrM1#mAPVICD_L9i{cDufn)PZNXCYL9;26l^U{W0 zPH3!Ahj-1(0xzXNZgr_~$enl)(HURsKDPoF$iMp|W=4OU2aX`ieez|aqqWI5Z!P-h zPBx$`Q-4;wo$E&`K8$2+Qx|ip@^L!0&wMVjITfv^L;Ry@=7YJ!*;{Unz*m=vpE8#D zhS6$So9g^mkEpWL=#kjK>FW2VhQmuo@E_?ls2*79&Uy~|G@`crJu5@tf%<NNh@cx02SxyPutQ%M zl}Tw?nl2`lPPXy$-#&C@*4v_NON8nhsADMu~nErg>J90E;-uaXnEe`2WTG zjilN&T?jdQHS~UVHG#fQi(1Hm?IllxSZ-ukLijV=m90{cnOU?^b7#RP_zp{pt^m!b zqrX)3mycSM^3%NODLXfp`x4jE z_9SX(QP{sY4s*#{z+LdmtNtcdqfzHGaj`R}v|Paorv5oUP<Tigy+)f17HI zPVt1ycDwkxg71oDK+)uUD_6&&(}qEM!qr^J86pLl<-&kbsN`2n_4m^B8n|5s3(nB? z@V%Fd0*CRn4Npc+9#w*Ox^~Q7F7O6%{!upYd|Y2&Pf5!D?c6ksipuPWb=|KJ0idVb};eU>u`1gDKGmzuofL^fPTVrSDJOW5@}6kpzlawIb|pOV5*W;m z&`|+fXzpZ&?v99+5{~!!?tZQ<2hWD_-JWlf4U;P=)~_xgc=E49da6HS6|svmG&1 zC;H1Muk|9zxpg{lWK-yi)pDb}Cl(@1MLxjnW7f=D*#3U9 zG94ZkH8pF}47wtu4Zcu1vxb#l&B4)zXp!r!u51eNyX(?)+FPMGKHDmFEsNr;tiJhx zDYKS2cXqN24sjM2{j><5lCc93t4WpDDzj_OsD)_Sx-l2gBHW66kKh8?Ad6k^?l_X1 zr7T7{K$jQuN<0ppzq;KS0j8!Sp#oX9-kp~mb%XU_Qv1I|5m|NR)jr*Z>(N+VHBK#5 zAfe7sA3u>nXSy&1yY4S_7wh>i)G}t$JRWMV<}$U0KRo<=|7*_ZAXCzOf2iA(OW!wc zG6eR^wR}fIw}y1=1=GPjUV}SUvmii_v|={_0NPZl!5DU{7MIgnQh2o~$rWeRU-KPF zTQN9-6vNAE=rFxE#8aen*dh;>%~P{uVno$*|7O5ei{EREb>gewm70&!{d*J>uj{c zs1cdbqj;p9S$d!qC&c1x{k8b;mA6Eu z<3#W$ELv&g6vqGyHH)1KqDnw;5>wsGNI%J4k}v)Rk4^UKL=lknvAh0;WFf)+LK73~ znQoFwogfo~9tFOtc6rT)R>WGgL690H&u%vEgiVbKpwQYgYcx5jvUGGLRh2R&RrtMv znO%CTNJP)bk*tz{L-D3X#oV`VV$9G%oDN_5;8i5qNPxnWBVl69fx;T=9XUJB2c~lhoxGhl){0ZZ$FK1?VX|WLex{_8puRo;FmEbz4$TrV8~&GA4iJCk^IwB4>g^5- zYzuv4*L?=(ZgAHkjgpO;!f!{N!D#8zyqbkZ$d^d)Q6V5E<*Q!&inKgtx>lHO`);I; z2zfB}U;%@W(H8)&isF+$e*dtiSGC!~<}YI&r~8bAfIJ0a7r3o0bH+eV0c;L-?ZHz3 zVF=>t2%UM+jSRQTM;DMrnFKuS=g1Y8Daoi~PrKG0IjO@OweCH5V2EpL5Eq%YEbbU= zw=e*29wx|3FUL~QkYP*Ozg=Le>*i7vTyNOS>_Mpfq>*LRl&J1`)_u#XhVM2uyMzV! zZ&w<>K3DA-32{g&C&@M-h)bm1;7iY|73MiM=a=%DjD7At5GBaSsY0k)9jQ-P$&Npv6n8e02?_#@|(xqc;9<2>~c zOZs;&FL=rz-pF&I#qQcHJbBdePm7!Ldv;H--^vk?5awx}Ib>{C+-hP*B=hOAax%B~!QvmoA-qnEw`wjr8TJE2(Wnb5!17TdY(6XbaiTmNq zbfqMGV1f^csm4OF`kS=DYEl}Ih`lIAwZveqL^lASm`q%~)B^y~*sC1bQA1oNM-gaT zHW|#nT2JFSxY4uvfuPF6xJQRQss}nq-+gP_S@?FfD;(W%O3@9C<@Xl+*3}X`;ML>+ z#2ne+uAKnK$<~YgB-Got-?CfM-~dVtokmVIJVIPG6Z^9oEM@!@Y6>#_fsG6^c&0K$2^k46`p9A);ithYu6fS!Y{-UzchSlND;}?vik3?; zzp&`&P3DQKMpALxe4RL%3Cc%&-bZmPZwH?v*c{m%UzMTat{%`^pDrY@D1<%ZSwPBS zut|wua-@IAZds6q3q&(_#_9;}-f4Hde5u20=!tDhMblwc$g4HCGe;1guka zXI+qA1#Y&S5C2S>t92ww>?6>3YXF3s?1(Z)=B`%FX-C)njstPfS0+Rd{A5=E8aL&` znvYs&xx)jR77Pu|cF_`xgO{zgxxt}**7n=TZw+3aNL@NRX(lbq&xnbYj$+m%Yv6yS zscxU&(Du(&ExKBE*gf3{21Mr}o2!R|(n&I_%7P`>;s70|+YvWKWJAF{M7uQIUvXxD z)?#onLb0Eubc)SFQe1!_)C~LAykkWK3!a4sObkGWI9p$!O6Y-Ff)FD4OtUvGrp)Dz zDt+Kvlw5xy{GM~HDOiSrr42-E^!H=YAnsxR7|#y=MiL|7lSu)n!w)BLZY8UIZ^N0d zJ009TcJ%b5$Hv<6$*aa@3Hw*5#?J~dA3yof>1V8)EebFCbC^7@1D+zVo$s>&_K*5o znJJppGdR(F-ETfu57#zufbw%V5ho2oij2WWzIH#4q5VI?0s(Pf;(mu#4sr?{fR`i( zm(kMR)|&aT2zT#9V^DK(4}QkekM&e}RTB<-?$eF-n`A>Bo`XZr|5@+yZVX@%d>?NA z%Pdnoz?N=#A9x++e>T7YY}!*cbP`(?kWXC^ZxwYqUKb8jK28wzR$H6gro+hmKg7KS zR9n%uHri96LW{eWP~3_;X(6~34Nh^_;98>)w85R?4#f#tT!IvrAjJ!T;BGH%&pqS* z|GW3yH{QMfczeJYn?bVH+H1|d)|~VE=A7Z2&ROzKOl;(@a-KaMi@Z?v@m$Zf)JQ1K zoNAIR6SyyjP37^^4zTvA!c(Arw;TBx0~kw&oLef(w@Lu?3Kie%J^II;^1)e5I^eto zex~xiBv*_G`;kr}P#6}*8sj{}{dY++!GDZ?3evpBU+ELOuJ`234+;~SkYxRL_Pnwg zM;QEC!(a@bWo^A8FV>OX%^ty13BLC&892g@8=0THZP7|LUT#^TL?7~Ly9*B(rkQNv z#c5Ay@4mqb#+$W4d(6doqFigxpBj8aDI3F>jTz$~$i-cJ4*^-6`NJa7MI@Bqwu|?J zJ{8a;)bWAOeC*woHB+jzu~*lf z-syE)IZ*bD>N!bt8>X(kF01{diRC{ACsdk zhWXx=-_En#gSU_0-$b@Mro@mZ+ku}`8LG7Q+CgvQ?$4u=x2~IF$nVUJV?G{vL66Cm&F-B&@|D`&yKK@QBi}CdB@XNH z`Zc*0*WVM0q_8|w9J}v5{}R3h1hC+KM^PuQL?{m!luwy+UsgRuG>8n58VflAcw3g0 z3t3-)aDG^{+a&#tpYFu}a|TS9b6y8-&wG36Px&)lEy77JaX#Fs-z{c_>9O0om@62W z_r$LXxg?jmXUuxj0F#DtV#If+%)1CTzWLMsA};+t-aMz*3ZO^8WL2NG$8 zxzn`ga;A&u=8fxnIdc8c58ixXe!V!n0(|uICp+o8aIPUZhV*>WZgH{0-zB5YxV|y< zO}5P|H6&aQ<7+q@!u1Y`vv>SfRttM2^c){i1y{zVz26qQ;=vo8)kaMElo9=nSeT^$ zWix-PoU{Vps=lAlaz@J9s`Dhj?o*N1KTvan=Q!NWctd1ivJ8NTnbM`Oefa^GdP6pi zPCxd7FAcp*22COri7_bG5_>y=Uy5JKI$_zm;U-ENe!~q&3^w~#E+0(pz$USM$YRd& zt>KCC2ZD6+^F8*pIInvFN#oLR$CYIUhl$TwpQ&UslR2-BzGPNFYHHSRk~tb(`L^4u zKs74QmtFl_uD=1dqX-ZPmP(G6KzsPPqYSL~YB|q>XC4!AQm3QPTH`xP^ zW5q8HgEd{@Vut=VYzYrAbyMUXknrBahpq0z(H9HN=?h;?Ya8tx^k{zhz`5eP4?vEK zHKny;=cUHgs{}Ck>KM|YPi}==>^w!DC8CeEyFErDTfxX;Y~I*?_#rVM>&JAG)x!^n zNr%dju^yk4Q}E7oD}2XTwcN51EG#ri(>HHj1aB4bU*0!im+%=~X&SR|Vqct<1HBkjiu8WQAie3%^h1=gz-`06 z)Q0ofS%<+7ls()pKricqMUW|^qgll_BG=Kl#+N4=Z(M*Ws0oC&uvmWIR{PDrs??;_ z(NTl~eBZFF&(aiTH0;Kl#*m-nz_xto^~6C%)<*K@*dZ?)mT-2TDcl(Is8UCi? zL9s*Mec}`e{vAvf?rSLre54Fyr-kCwT!=9YD`BQ&V`Ks9a%<|4MBytYM9VqA#xm{N z>h37O2vM^TSgjuy`l|2W=#U z?XnHStbB|!biAo!e<^vsiOEN4VH(soR%1l^klm z(j!Ut65J+;9A{D*^=OABZ8-CW1?PBVGLo=$t{CMI*Bm}+6E<{_lxbccrD9_ zHp`btK2tui06+8YpS_STtxlGlsdKg=S%fzs1>Aba>r|zBP}Ov@q`Fm+(n_Zaj&5dj z(&>e~*@!;mnPpgL`_g9ZNLkfumbB-vnOd?H?hzf$9B|>;6?~)*9vAaoW#dBGBdp`C z$1i0@ScoQL^Gv3o=usXO-LyF67cw)Vl=M@qr0yKv4T?ca*h47|D_N1@&z##SY4D1} zp9Ne1>mz~Y35&{AYrYRuRHrA@Ny)~wy}+eoZ~vT%UnMFZXV~Q+@AqS*{6D(rO=f6b zq{3WsM>mLi;=13DQqD>H5UUOx4-P#53%4O2_#WTfUj3*Q^FGSGy$l>SK;OeyN_W0+ zPX74ed|Z>L#0YqT^$~2WCG!uNh%=fK58v=wfY&bQfu6DVvXhZlIlaAptA)lIzmHVDubj ze@Y&S62r-dxz&k}L@ZP(yXo7aD=|*9HjsCycia{p-yo&Fzsp7GVsD_0a0T@OG546O zl4fZB_il(`cd~z~$NqD7NB5Bn>EF9byyf53sW6v0U8mvSyI#qV#=m!8g77?lw~3hy zY&!Js#lVb-L~`!G(V(W?{6BhGQ>#>K3uvpEfq^V8pDJq5L&VN$d-IK2#0gs)Ak`~m znpm6y``T8ke(O;c{>IgDM}^gf<52~sVk~B$;+=-|6Z>DT&;wlBetH&3}EbC%r%FH=jr60TJ>=E zP2gzO)iy_Nll+{G8P@WO(ONEKbpNHkb~q_XfrJ*zcydyDFA5xair@-oBQWbzplJlp zXEnbu^1FI>cD5W*c5;S`C4}$Ncs7QV9{hIHgIKoE$i84>*lxf3Q}d!jc^TI;YzW#} zCldrLA+KyO17Zu zF4LCC@Vh~#eqIXRWg1)ZIX7Ua2mNaL4IJ%Pd}pj=2aWvOJd{*|hB9+gt65!l3HpDO&nYRKCn;zGYhiFhT=sH^OMAFwZKmc`L&VL6p-pV=zYSJ8r12F96mH+rS%-{!WR_aB zlcHtJN_3L&i0M)Y9~LTZFj$R$F8eT70E&+fuHdRI@SF%4lKiU<&Kf-JDBmn;ZN6=w{XA6VG|UONP%4Z51E#)T*RlqfTi?U(27gm0u0X zc7}xH-~qRDeFWKF54f=5tUZh@o{{&5h`Z&aeE_Z@Q39OA zBf4bx@VqRhV+x;e_0V7uQSpYt1$-rV5FNg==+WnR{}EP8^1J zLX17Q){c`0$O3_cy;OWW{PvsXY9xk!Pw$ z5{gfB`*<0}y;W#J)p!yZJ!4erSi8@YXwE-6$`=!dvX}6Hr}a7KYUJ1UyNsU^j<^f` zZLto|q*U~d;5{e+#5kxm74wxD_FA^6%L^e~ha$t5CIlOv^M4oSs^gZqvW^nNv+8fZ z=`$!d@;@^V?^8=@uxN*74B1TyvSV1s)p&U0zndW{(4o$;=M9RXU5P?&^pC z@+mIra@jn%xsTZ%&;+eEd07&=MU>1Q}}2nITxbT0>)HrB87qbw_~;hZkUK;9aOO zGsi4;#jQLR&Z8(97bAS%b|-fDkkYNf@$?&y0iaC3{01p$@qA!HB=gnDuXN0gen%ek zT60Ln&`70B(apZRR#?X@RM%;j5Tr@}l;PlG7P;|e2yk_PjE8&ZbqinOIO_9vD z7ju@mx~&IWP>^xDn%7F)Xci@~{4TB$1#-)wt+>~XHlT*@#-*nJK) z=I-F0Rd7QNBS$JzvCR%0so&q?}}>B z-#O^Wo|d`)^PF|$Nc=x{4Cm;faK3l_^StD}|IhPsn!@96y~&?>AM1TPpqFbOrNqwb z=IQKTmN|I-k+?gK3L%YxsRdx~+2R+sj<%XkFHMd9j`+DlNTc05h>_0M3(ed&c9uSX za%15IuGW`ja_r6K2y&zj%QhFup}RQ|Gto+iMn5D?UDdQOeH=MDA#oKnjCQGn#^J$Et!Jb!u&gacmj#n;w2~RZ?jNnArV;(0nc7Az^ULv2$&vOjD0c7ZycC&iv%h z{W>g!N2B%C>OMOB8Oh&aMm$Fwc?xJ}W^K|jBmtoz@>e@9Eyd9jI-Aq>6#UTe5QvRS zay<9K0(}i3J_o0!N`9%=V%YH2U|2Ej-?{r9ao^>%K?9Z^LbaS7p?JE<9v%|DRn4ld zqO#Y>)+wt$oyQ_U^hRbqj+2%lJ|i}zqV`YMcN}>_8mFku!h20g^|#s3>!}(dqFUVD zBBD7=1nn)RnQ(*YHce1$2=l{X`Qb=51)d|zg1__dE(K>9?cVdRjg?X(H{mt;hLz7M zZUaN9Hgn~-eghLHxbqZaTn}j=f9le|^~gD$blwodENMP!ULw)0BQ|tt6+0zDpo-&4 zpwqmfSMyiZbFO+d*NpaSQU%Dqua|H3f@>4~grRHKH+TM5_C*!__1uXtj5G!Txp9LckvS z)T9CX=Cu89uGW_P4slM6jqG4m&9OOyLlt9`yAZ+Od7X;<! zQI-M`+}(av2b<5UQgm~bnfdXfMk{E}3ps_qb4p}rLzy=|un!N}Qc1=cGFLs$)!y3D zmLW3>f2PdBdlymnO(FP=jRlE}nMQwWt%B8xJ+VX3a^69*se|d!II8@}mvCxD=Wpi* zyd{~LOIvo_NlGB1rJMd9@$|J-}-Op*T|P|urblo(_B%=J*e%^1uzC;8u0<9o3T z)|ine(1*J=M8d|XmOFEELZrpJ-fqLSBE?TK7%_d+!wV-H?tO8nZ?Qq`;5K+oC3V~i z&{#}mN}F~0<6bSaG#HeQ?hhcQGR5oT>lX~*V-C4Le=I>~PzN_~3CAKAZnM5x!(-9p z(^TQ3-C-&;Zi)LJ6GmI!4Q>E74wK2I%kmNnIM%Ybe^K>$d@W;9!`R{__}5*hrvnO8 z4Z>188#LbAHv|)%54nKHfdLTKtht}B@0Pt{=bC$CHJTj3dOL7re=@+i09r9sO0otD zQ2ih&wKHn1Q3JYG4*M!zA%DyI%tMz`AI$ndl>4IFbPci<-8|C$odjC?_xj+)`|mTh z=e4(-`~%YsYY7}@_xBQLSl9iX#LheFhI~!gsyH_$KQERSmQP(E{qcFG{PSu}vg!Oh zIGc3nh6j;1Z@i)0Vj>MkqtkWec8)F~gz4}-<(Sm8#>vH6giHr-%QXM)VU43A>^=mh z7V{{wW)KT}dDHiNNLtZf?RqGyXwaLO^01#`J{Jy!-MnnQLLNkO3UazTbd^PsCh#}J z+{QV6GrsnXDuyIWfVGg;p`Jrnz@vLVh=HV_&e**xIaJb+*G&AnJLN$WQ=~6}-7N-= z!m#DA!;J=Ie zp1R#LvG+`H$n+a&)<`!=7Lh<-?RpNjA)mQq)Ka6;#&??deO&&56hAR-36C9 z(Jon9xl)e5P;OeB;)QRE$HRXzse1e7k=)ce)tR%RSwWx#WF1GuaM13r-J#u52m3j- z3(3>x0qmHK=3UCOWprtzKCRfd+kYJHaiya?1e_Y^hb>L^H#ghBDyad$UQ*KQgSlhc z+cw6Yq|knQTA>%9XgGC@kVdjr&v9gp@~`tDU1$ zKDq!?g$5=yj`15k3jrGzG_G>M zONz3hVlF6d%Q*kGDvq+@!G6O5)#5qn`T+9qz_o@o`okE6o{7oo17d6GLj`+Xb+=_j z(ZmOWQ`si*7^!A_D2J2c&aBQGJ;zZyFFy&Pn>MdiDfnlIB|9W;a>ibuCNy^1z_|$G z>ct$7zCS-v*99-~axx5JlQ?pn`r~%a#L2b=jtkz-#M);i3;ecZ!qrh00BNU{+QVDat%3KVP^GS zXr6bASM-UkLUN1!#MNnn>WOoAh+jI4kd+_bru00*V`uK@t&`Wj_u(Ow_*kb@ChJ?E z3fw+iE$6=42LuQdO~nMTi4&HxE5A%sNqy|y^V^SGQ8r$HDb*!bpZKfZM#@_%I}!s~ ztt{1l?Yq^He~XSLE{UTZsN<)z>1`_Q3bJ%&OWMJ&iy}2`XeArv= zH8_95c9gfj^q5RDbC-WiO94BD!Fu}+qb&MIp;>_U{+Yq`v@j_(>X4cd08lP_)wzud zEjA0o9UeaRz!EkqY%(egM^VVFL)M}duf{G6UNT<#{R0p-qVU(NBuM#t*-UW|N0fms zlax$=yv2Z?7TK`kQ>|K$BlbnZusD=I(0kL%>I3(Xa&BV_M0O_rcmfTBjDb3JoSUwb zTmXR43I9X-WljiLzR_)1?Tirq<8IN@?MwTB9sk^XUo2s6fsxZ24S#G%7iQJuPJMy2 zO$MM5TYk`z*U@Mc0lvKJ2RVfy1D0se3+7^jfz<4S)*10tt&ot-(qr7%Rc-iuXUK3@1BGZd)`j0C04ISNwTfg5Zc>5rHIbUigUjw_+siG~O*7MzFWC-K4B*XD>EncfpJzjHFR%2&Cryhp`3a0t58R0nGWm~BUf8ro5@1TwAj`vR^4PVAhW5+A^ zB`dfpg#{k&I~!Yl(NHJ{E3EX3>8jxEBZ8)nBuMwY!h5qujKTtbt6c{AzdbRXO}m~0 zozYe=4tguluwf$b8qyv#hER;aadTtbSSG{9arejLL^^oJ8fyOPcNnDEJ>slpc!+zV zdSet&fiR^zuy7RX`>bPnR_D+G>LjDLxQeKC(Oc6^u=j9ulQ9yLXo0G+)T!GwIs38u zd<;D+@MQn?%|r9RE8o&^U2)roCJp_lAG_zxiQD-~E>4V;YV31B-u(t+p$vQH%z2Qt zhYSnz7#TaLZ@_$v;(h5ki8ltOoGOo*1C%-n7y&dS9x;KZh0etZZ8ny zXaOe`ZD|3W0_a5xA14b<^J=$*I(y}|_RE+2S)<--*gao(M+{7>ko6_~YnGh3bVwMElH1cC>efT!(;ASqDLaQ=^$#JOp55G-NBUlR*M(FYDy@>{ zb4oNaJBaNPRBnPo7#86cx`j#Ky|;_SIsW~D|BU!rE*zk*yR;@SeO-_Y{kt2W)g55v^{bnUZKUU~w1UNTLU2(0Mo0rds^x zKlfJ`FN0ceMTSxp{eHm)dJwU*p=Xw24+QGEY?7!#6R91$#U-3plMVDw8zRI%mV@*9 zTt7h6SCztN-o7M|HluZk#AMO9^F6{gB4MuWndA?haYUX|wFs{oGs$I$)!d!DJWJ@) zGRc)9rgQ=WI(3@~`wFa~Bt0hf^o#mWHd@P-+I&7Rxp>a|?_L2Ea%wmENc$g&RPhfg z-Z(qD{UYni8}^Y>vv3)<%2O{dVPcdA*{0df5I(5!ut=(BC$w<`H@^~tU>3^vvs9{^ zFoT{}#USS%Jd+R1L#Xya5oCKEO1tSs{NPz5A~Y>bRzkz9HCofSyyiPuip2EILYuJ1 zgdMF=UH!aFR+6wi;VJ}ml*U7?3@%)m>pcx>+pecxcbU#8OHSNFUG4?O^Hr% zYozTd2Wu{E=VF!;F&cnnRl2M%Q}=aPjfL$ti`?3=f{j5~JN+Td2kZ!0Ysh*dsUpE5 z<}yI3bl6<_UxBwVtG`!e6r&J;Kt$rMXFuc)^P-4#Ifyc-cD>flHdPo(x$5&8ii%2? zEAz?bk;U^MP%7>~-Hj#BpaW!mSlk_S3V+5x+-0Hno_olOK5ZE1J~%c>{HPVj>0&HC zA}U~k>-v(->YjqnVlVkh+sO^Di>FP^pq@3cy!_e674ewsd05y&@q-fX8*_*tJ*ZpGcYvY{#xQ3~O&wVDnT9Bbw6Jf9I+GOwEUzoPXLu zV?Q)neRezbPA&NB;hXP!TI;(?q25E7N{m;4glus=nq2+t3)sU?cM0LAdkjfKyG*8W#K;v&<5FqA4)N zwmSykgYS00o6Ej3ICBs3xBt!276Xkjc)9sFf3Y6SD}0~iaHFu@|!Dea#InZu|$OKx8I$} zXARQh9V7I%ZW@`Oxox|{1aKq`^l2<0kH7KK zZL+u349xvxsBj=HU<#)8^-VagD$B6Df;dz&iucd+<>0uGM(ZFL zwcZ!Fskmlq6?lf-_VvepgvLOF9_gv)-W>YmK@THsW>iZ$>9&yoO%x$;TZ5=Oq`*mX zq$nihNtz_=vriAvuIYr?`=_T?@3pU%WnS|Zr#{}}$#>Q-ZO}gl1Q7Q{0?Qidi$|vYXs5wrYzme%;_WX zvu3Rzs+6llw9CrC@*L1XK{J5sL+FP z%D&tw((bZqpUp@>KQG?-n6;caC{;1cCiRa8o~ziKBl6GczM7Pr3wqGit%FHQ-r1X1!b; zpV_xhhewl2y#JYVpDR6~ak7W}x7@gg`kiMl-d%5_45 z&AU5?X}C{{TVZ{4)p2xFyGsZ-Mj!Zt!=^?O7NoWQ#1|3NC{QTYa#m~tXu$c~;!hGl ziTlH!me18b_2$60d=4SuYo{wPFcvtO<07(C)wQ!_4LGFVd-2UwrfFSvE@&`3uBi2p25-`w~#92g{7drb-su-*=KT0u5@w zo+Dy81Q`%#8>$m0^)?LO&`GOC zh|_Q(<2k@_%5a&kjDW^TEif?br@5l+Ko*7s)I;i&|4*d+=aX-VY;FC-yf;&;451w+GN5oaE_c=x%AjCi zo=vivrF?-aPw=bKv;*&ekDQY`G}@mPJKa$ef%*kcKf(2w#%BrxNLv$n3k&{JY0#DDP7#%S*-`Sm}kM2;>PHq)Pt^v?ejZAh+WlDeMpL)%Y1Pm>@F zB=fg*UEHew5kNL`y9v!X2l(G03A=gfKi$;7f9pZ&Xu6dpiaHFVe&9JpN;%oU_2*yC zW*Ag4BJAHExZfs}3|(9NhiS$1{0cWz($8j5X*b2_m74b7u)r?=TB`UFSD>KRc?DCR zUXN6q$`*P0Q$iDuzY`Y!9Bm1tAtxiFjsq_U14)fj7R-qbTaVWvP7JpOD`kIr{r(Kg z<&*@^UL10?_M>P$__s2RQD@ZaKyz!U|1lLw7Mqnn5FW{pmkabYKH*q^UkeN-9OxR$ zE?+i>R(e^zL1ikZppS|BVYEd9j7( zn`2~P1)ytd?8yz>d6VZ=NqjQhyhXATB+}J`8xqB}@XI#p_U$_>bni<hu zHG~s&DErefoyh}mqk>!d*ka($9GP*whFP)Dw+fG{vH0xu_g@0dvo3Qd7jDi%D~d}k z9tuo4xFC0ILqD%KV2PwV(I#thhR$WM(r(t9j2;^+v2)>blDbE zF|~7c1TH=%-LjuO?&b2}Sg8MYM-)Epoo!aQsaQ6+Zm?T$0n!-kHYnEW$XYsYft8UC zA3c+&-5L(N?m`MUdM3N6d$sfKEkjRE9;xn5{bNYiCe!(=K>-g>_Vu z8r}b{w77q2+ zPRJB^ydv1pZWDnCA;&C>VHRSo;X;p(;u(I|is15v&XBHr+{LX8QRw}x6g4bK^V%o+ zpKIMoh021A_9Jg3+o&n%3dDJSK+{tic&VtTOFuNb)dQHe%+ms z$-SD(I;WaKzw|zwa$u3WJ#*>Yrcx^P-Wkgbrha{Gtqi4_sI8#2pTAMy8@>eifdHwk zuWz?lDmS2J$Ta%O?C_A+V0RJ<8T;sveqM3@=O<~OckDIGj7!`Q^TS{(t6=}=F2~V_ zJ;taEXsO?P*Ib?xy4fLHnL05^2uK)WLwzi5A$m}l9cUX_{wqo#KTm~!N^~X68p3M$ z0QqDhO?<^j!CN8~Vx*gWGxkg}^VPj7zvps3Ro@Ebnv@4|Ih*o2h0BXH0<&daBCf8g zM^W^x-?t7vrKxyW%I)Wm#Q$nJw2KO`E!NnfL~W8C$~=>%+;T$d5&)3`zl_O9$rb7r zwJdQq!#ooU(cwaZyvHh52A1s90=$EWv0gV zYP&`FDZ#O3Jop$c%>?dh1jlkbmJ7;lS%~!Zt6FvcRiOWKRij=y+mk|yya={iKe+&= zftcf~CC=kChGlX8gRo3Zqyj<7xG&#{9W6cJHT_k>e35dlsbbxM<%j=`!dE<3Wc$9* zW(B7w6k2<3)+5>w%^l9I+B358N?6~VY)*iwP!hBr%PwMEVD6Hr>4P-XQdcu3dAhG~bXqRJi~M z!adP5r!!ZxmkO$4a@X@=WZ^?|YJTbN)Lv;@(Q{ zzclM;SN)51(cLo5Vd|R+)f+fhdq@b1631^1L3tXGb@L0Ds(%v}^Tsj#tS@HRvzvTn zpc;+6=v{AvD5FM~3Fd2Xm`zt$sX|5kPP%A;-jn%w_`JJkM^m>2CuR327ksn$lP&c@ zAA1%l*pWL~`!PL@8Z^JH_dDNN=kJmNDl484D@U>QUCD!#mYt#?>J=0ybrup>poShHKax?*~xm#p(VytD-c5uH>(ur1lhGxp4Wv3RYPGjsQ<=maaSi5x&||w zr>7%0aHv(nMXJH*)$Nbc%qJNDG{Upu1b~XUO`VyHnU%DKpqt*dkJm9z9&?5-bFBtqOHWLc!XH!R(v3t0By$}4fmaG@ z4Cjv`DWtI?N`l>P{OsQe(8{4dC^0A(7iYblXXc1ggmVaYcOV!!u3Z48GS3XL=MLKs zTd#j;Yq2?f_PIUR+&4xu2OfE-_b>&xm)QA8|D#4*nJA=8NLYYxZ@ckcHJ?X>iZZCo zx7Nux`B+2u7$g$mA&pR&YxT2mXQz1XY736BX(LnkSxY* zpBbjzTsc7O(#>|Li7I86t=glE{$n#G%>D-^lUtwkI<0j67@pd>_@zX%TPnY|4+B(wa$_8(6-ipr)e>)T1@u4PM9A~u~`zt<$beMRo7aJv<4Fq7ZsI5gipW2ymfuiN$ud^>~v1lMx6bH{lYXC z)0j$ODxtT^Z5?=6Iq+8|Ke=e@0K6`tG4S{Rp>6=FS#)TIV--!ly)$&J{B0?I2dO!!@MoT(Wd)$gGK(rSCUDZvjBh^;wgf)&N?I{c+%R+Zs_A!rN-dDwap?!&Cy-AVv=& zq?=1zONW$*Lc1iE$%~^y6lu4rw5=FJ<)H|1EB=3Hcy6~#4EP-9b}*T-(`;Px_S;cI znI2;HlLeQ)4+;1Y-O!_Mw;>Lr8ZWWe<}Wb^xw2)}^fNfN=`P> z;nekphlf$cCtLB73EZlFC9z%WtG6@dO2-JUq5V{XeOQyl@c`SsC*+5n;-9W`f=z>E(2XVU99=O9 z_EwW!!i?)}LEC3+-9wNR6g4*R@yqLzWK((;!o$OA^}o1I>#UmhWiyXWmFEM8F^Nz|hi@$u~D)Na{T=^M`EC3q;gzI^^ zeS8}e7kK$9-7*~i)}gr4J)z$0He*|V0jtg&9|VPR5i;N+KFtj)^IDi z0Q&m+aK510=;f*=%anDl_4?qdQL6ZIy5VCsO+yW^oUDwja}ZSUXNqdVHxD1t?5Rjlq&YM9KBRrBdL0E5q6dW1 zijGY#&(#N9wzP6){K93!Dz-C>On>N;v3tOEA{?;%v8r)izqW>oIVqI}+A9)MHEE%K z>1#~+sFW<>KMX1K7@6+)4?_kqjDf#6)Lp`V;ZUxlle}IBf-wZyXCu5D9*q=~p6J`+ zQ0V(dGp+}=CCO(4NeNhlkSaAKSn@45bK ztq3Vv+S_@>9oR&*R?7~$(m-5I83Gv;^)DsgVf*twb%7EP?rZActP$%F<^U;9XY=rL z%eArBmV#9ZDQ|26G`VKt2O5U17&jve60Yn2U8Xs06sWwvx-O!iw8^v|DVEs=3hs%; zuqa1T!4i}v0`zPcW~Gb*=)8jS+-hFI+QxX1ty&tCgO6q*Jc1nHHWqRk&%L$6O7eWF z2t>WPOZx<`MOY%6q~E@ze@LN3GBar~65%j14w3n7gcQ#@dd7-q-!Kie%m4{j<_vBs zxCGaqjOE|9nwh;{9Ri8X3U&x;5>IWUh+luqCNK-%-y6SNd0n#*yX&mIo_Or~&2w*i zELcx8VaH}5JAH*dAg)b47FE()k;ptJ_~V32R)4sQGu#ji*4NKY_r8hH&S*5(cwhC3 zURnA0W>+d-&y1GB*DX)9pl{ekvp$tU_ymwLNI~J}4vR=12fNSTq&@{=xt)9Htg zIBVnXG8Fl}L<}&-I}!AAd|+%_6iE@vC3dQ-xPb*tg>eh;20~i}4Q*IAv%hRyAsHLPfPa6B##|)jCfL&bR z^EkxI?t9Hlm=yPljwWil(&=L_hTUp!&>2R4o^MQTYHFSn3C2wk*%4;v8wKVrri}2f z{T7ltwCxlW6d0;)Odkl;_I7WpxZh{)#f@`aq9~pJeyr=-=uFUGGTy&Hs6YG3oys{5 zXEgfR=I4dHbBf0835P$e-QjuoA4RIAHvTJ+?fXsh7{~0*GHl6%uxw# z*alA+Izkfk`IcTW_lJZaceElrinWU4S76^H&t6$bdR$RWB@Y2EhAc!O^p#C*hCP-JXq-=C)^6Kl3{kyb`6I6G}MoyY6_ zX^w0Uf^k#_j1o?VIl>@xRvO-z3I%#^Q`s~KS2Q3XkU0;rCh~r_kmo~@;NkSAy@C#6 zjjzo|TGa-0DjmSrk?Z1CIM;7KiR+_>) z9)O1Rpt;nP{q_ztZZ`z;0f|cWebBWa$}J_mDz&wYr1Ukr`Lq(tK8I{e@sCw?)?VwDT+d>N4-^Ntx^3K8f9~ zTVuss4B_EB2e!*;9+M)Ll}iNY$c*<%^UN7*>$D5n*6$~iZ(ZDl{$fV!)c}Fuj@OwY zF7aWDVEWaP{sxzkP!GIj>C|ouRdeHE{As{FqtId6`cVc(;I(@phqXXWBh#l*k(|wZ zCp)NQeX8Y8R-Arger)b&y+9-<<+EXfU>(;|r230C1M~40AIu!8+9p~8pEGdES|o8! z0w?EbH>#K4@GdL7px~W3giKUGZOU&6xH^e}#x_fHnhhJc631}hbmOgt=e*}vi{1C4 z{dZ+maF_+RI(atA=8Z4==rh>U{CBQ5CvGT@Z|gia+FT4y)4m6m`=Whcet+tSCn%41 z2NwolfQ`$~5Xc)x*SNCoce|D%JW2)Qn_UD@CC$(kW!4G~FqIQKOVM;iRzF&gltDM% z9Y%l)Ba(l2$Ay*`^9!LBpT9nd`p2tx_j}2m<-YtpJtW0CTsIt>pJ?@#T?|j8IL75m zvq_k=pQ&w;2t4TA-(T&DP+)q7nfubmF-cB7>}P0Eu9lRG!(@_9VYa|M-U*q)-Q4^r z9JVk<`04{0Y$rE%p^1Z0&8=bLvi>3_NKgnzGw`|DU04yPurz(jMncOzc1GZtz>b?| zPY=C1Ysv_n`^?Nd*=W7Yd`axmV)NPh=kmaY{T6Zl4Wn%XH-1+U8_bL9$p6Tb&Y`{G z*)dKijj*emE%Zl7@e^1HGln_?wYnt$y5+=R@baJWADmBpR!&rR z7EixToNkAuc_RgO`aP!;`2;3!FQ6K@R^w zUWmQ~KNAaqh{mNQT6;Ic`dmuk{nEv{hol^+xP@qp^J2J)Z1vZk*CHE}o}OtcKK_QC zr`UygjSFW?cUs`l_E0X58#p0RUIIB;&gsrn$l8Zpw-Tz<8BbHFR~a~`f9ex1u~@-r zI3bfTmT98iz9={gVZ_@YRajG6nSG<9ZBg?}{)vo?3a(7?WEorB1+LrKhr&D?MIgLZ zjL8|jW`jDpx-w|~yt+4i^t}%T-D~oze`^$@uf6hZrbh2|+=q(3GJGdbCCfv^XoDb0 ze4Ku`2 z2FK~G5t|Y`MMC2(HMPjeCG>?^svUwxPhVp9EJ?zp48F2l9tLSMNC#`9oQ0|Z350ED zno|XJq77dID<@9a)?$V{lW$HPOAlhTkPaTDpIM@TA|t}1i-a0HEOwEJyM?XvQ|$yB zxThx(?9}fSY@>U>j=db>+Myasl3~MRaWyR*wpzMM&Ysu|A&|G(k4b`#g>jrms^~J{`OFx^|K^%e-MU!Xzxqq+k9{pFGo{fVvTZE2p4Ed2;>XvL3D z<)~l9!I7tfhS?|AYcVNmQrv{_!^6lr4g(>p(uS4Cjd>6^XPu3LKCXo-Hl~fAcCBQIaOH573yJMy0D!=O zl2vRd7cpD05-I6YV)yc`JMm?Hf%3w+EhapYuouFxHJ^~bM*s{kk$Gz zaf)Dy4uD&nNG`g2t0HcvdRAH73Te72>#xfa#_c^$%Hf#;L4+@?p9Rry)XaojKD_En zx+pMNJo-U|LBp$7eci$)dm1vcT(Z?-bHB?yFAHI;tFdnEibGwW>AXqW$*`{x-|^!- zP0Ne<3~_WQnbvV4iiU2Qw082>`q_=(R1MBS^1R6{VA<^!f>2Q2hbNH5-nEl1;*k0x zPW-UKPGNj~AI$^XVXh$#eq6uWNqJ1JfH8dNT4QUyCu$p+-LoJxoypS;*ExbZU5{Mn zCZ8q#U$vcQSX0}#?%jKf1yqW31SHa{5Sj$Y76Q@{PKs|AxWl=>BYbH$ z+ixqk3xyss^9JZm&ETXQ_=^pYfg@`wZiHRjRW8(tS+>m1fu8xh#^eO{_zPWT+mDaV zW4g%iI@uVXgKQkbBVsV;%HS?KWb8Y8Kh~YU2pROD*{YMNZV|cS(rm>D0u`z09b|LF zDB`dt;|t>I1|>W?xXIz2|*Xn3pk~=@sbRjMu97ZO$3(!_@Q&)CE;{% z7FHlSv)y43#q;Lx@LsFh`vR2x|Hdovh3aMbIK^=r-)fVU7#_}0w>x`Y5Wk5^Qg<{t zTobN#gD!9pW0#9z38P(m=+{J?dYvnm8Rz?5-&@l@}snrh{ua4rbx@LIb z;nxpQC)=0gBsJOGpvlP*c-3N7Y}Q5^1D})2=1nQEcVkcqg+k>Nr9#1vZ@a_@Rf$*9 zh?Xp=R%0(7$Z$x@yWKLrk@*51&Vb)y5ge*G@J?@kR6?M1DA>BI7IV}x3Z%D!u&??u z?f?bQ-g#J@*8xPIsJsw%wmv=+d1gD=yWGG|8E3;&K2|b*U%>e;iG?D>*d7sBocpOe zbBdZpU4hEi+BPYMs_pw!z>=^AL@vg!<8qaxz{YXqn3GL<{;2!!hffkThvaEUqIWP( zyWVG%%E|rl{%;m&k*H25y9fpAF#mZD&Tpfx*%_oY^g4xxH+AeiYiejB-!}5A$zbE%?QXI?Xh6jPDOmtNjTrJ`T;!3}40_;#+4+ zc7qmKO|w2>u2@^I^tDDAyPrLQ=Fhv#;hijhTn1g89$uCdQ81zLeg_{u?06RwyzoU! zozr`1IB9n)tFqD?b+!x3mDx@PSh4YH3w`I5f57HVGl7T3=Ri*wQz`C!Gl=ltq0!a`K4bL z9~!!Z0jH<=8L0etwcEQ!rh*Y?(prdM9l8Tm z#yO9ig@+3(ZhoD!4zo!s`(tL}Xdy&o4*X{E>61Eb=d3N(52N{PvXVZsqChpySj`xX zhKk5-5f)Z^GCp8Hag^8f0+Ez1OLi41N*(!3p=?zeNgXE<5wRRv#AuoWq(uK~js#ia zgH*MyY)HPEhMJnsgDjuW`ayAlu13zk`p8c2oKul(4T*C_#}>_RJ0#KEjf$n2mZ_EQ z370q)U~+uo6BSFsnVZIMJ6JOdQTeW!Z*BrG?^{1jRyKiah*N}C&wjsi(17tu*JVtZ z;nl7V0?=%Yg+d2y1`0;w)CK^S4&|qlT_Y-{+ST?c@=DBoZsxfSm1U0_CY0mW_r$g$ z?Y8=z%~HBW<__Bnu~*|y0f189?UEj6RsW;dpfg?YhwQiE#re5^ z=j(5smqh1cU?&3WrY(?q$hLWTj^gc>ln}jW2dL%_|%twCvbv*>Yd8 zt3h&z(p`D2BuGUTr7M6_0(BM)R!)-93H3Iz4l*Avl-6Sz{rfChRo>MyF6QDHdBM+~ zR-t1*aEb3AxOocEShZPLm#D$EJTcp>C%xsR_l}n&;c!Ri{yv3P7U3IiDY$CAS#R-P z<;d%-JA#dSyKa^of{9(J@$bJud@DR>V680Pa=NQES`j=DsUhR5bp>WE5za$0e0;%= zTqa~Me42Ro*Y$t8AD0XsvsvfXOe}hA9{CC#duOv0 z(BT;1!Dch8I|uAtBgJhbjoYLtQJYO8CWJYS{Y~?A(3YVc^7Fc%Il47EtpDB>Uup>w!SaW5>~4MwN= z=dBxSS9ao9*V?W{X;zDic$ziUr9tiKdQ>U*h}~GB2FSijiJer{X`{yYMsQ4cKkM}Q zdg$m&la{#NSIypby<_;sJ(QWay6)MY748XfZMSgaAD5HDHgnb>|8;0?(wt0EnLmkY zw)1Gi#|?i$+D#C3`oJ5qZZY!2l$8iNb-|$p-2l^#MrrHU*tbCk_DCU^l%=w4(5uK9 zEprL+u3YxjxZP+D0MMTOz?%)5=8b{i^@Ru6%7CR| zsyvs;wTGig2(2h#fcp%3$dU})xpHUr-~0{W3Py!z zqt~YT;-N_CE#{P_^L>!&9m1;F)#?iLQCpfxeHn}2=KfrA()>!LR)%|Sj>OeD(%DQ( zQ*w?u(DCf6g~{ni3r0@{OH_x%5+El2O`A@IKGj*)5Q^)n0 zSeSnQ@-)5-!iY*9TK)25oG~Pgt9-EIh|$%QM=DP#zt>&DCC4H|Y3uR&>e#ItWilNL z5$?!=FWkatX$E*1(oYr`?fF{vwgKlDqv)Y5x6LuxxKe~%KG691`u=uOSE|TPq>IRM z;-(nKy`nEgD28DR&Rq5s_~&jCa0CM{>DG>^PQ1k2;f$SM?Sb;Mc)pa+e6T2C!SURL7Ys|K?eOcaT|ps<`Rs9h<{z%WkzFc@@00yG_uO2D4*;@&E7+Y zaDBAnM%`pJ_BapqQD5FpTBNRBfHouR+j76roDGoSdYjFj1D^{#jbt%Om`CvbMmfto zMky+F>7btO9e@DQ=WwjzbN;^3X|92otNB!3;_^`Wowi<#lX#SMWBg4t?o?kPhbO_% zvWCBp?pCn{-{+=C{8G(v;!^ zJPrL!Wb}=1V^$}G3n9Vx2l_NUnzl|VaaFCkg8?&qr5br`@daHhB`OezvN8vis2-{a zk+T}d)pCN|_1_)Yt!x-PZ5B1#eJ|JD*u=DrT>A*?S(O6zq5f(qtC;hT+EyOEXWO=&3gAW ze9cM;h-Wle6@ngnRtM5%8}h#DlTcias&$BtiMm&MniPEk-q;U&3CQg|1&*AA&NPb# z7sbGw=k#izxK(7UER9h&z?4J!E7icfO^Hvjloxw4dvwJY^NHd%AXJBJ&6#(p8jqdr z-d8f7)R(bJ$oiHE%|l*xq>6gGN$;B0A#?ogi7-NGVYk4s7ls)IFu8f192L!Bbh@Ku z&Z|T+#o$LN=RFp@87bVt_^n}g^fY>>;(JtQ{9AO~vpD74((QFV_DPXUiTyp64}*keaVD0Q07aq)rtK_(;g5Det}}o ze5bSfP}W#t(yIN{PkeRSrh-@~ivi9_J-S?L7%h#Jk*3ZV5-@Tc(Usj$Izv?mS_i}M zSi2DyH9)ig&U~Vzt*~m}vck#p#mdSw1fZ&wGy|$Va`!sURbm%l-r-G~;7C!}R|#)`JW>@Ok(Q5;ZFi>Ga8N-H47m zV)Y?LI{|GSL(ftvRKabKM@PTp&x-iC|$&mK`N&k;>2UZq!zu%{V6h^{cYS~1+)bbo-yNYUTr`N?GKSK zUa49?@}Fg@ujiyyeVqg)5dOXqi*#B20f(gb{3#;bEiZs7%G(jE@$_ry7+!%q_Gq!4 zm}AbIlK@3Jj;P4mCezsM$S@x#PEIo`c*ecxW0l4|LF4Y5U`^`@*uGaS1A|l@s7SDv zm!h^7+(SILH?JRH??sYe0zy8%-29=z%8WqqrMw%-*fH(yVbFPpy%-g`%*F=vbdSM| zRCy>_9G?U(PoBtbj%+g~-LPD!q05SyKU^4&xM+lR!N0nkSD6vcUtM|$!9C&O-PnLt znt`PzMqWFKO@(gB?1%?}gze?GM17c|aA6GUd?_dk8#FR3P*7u};w>sV>T_`^0hXR^ z$Y9+SnPQ9^Q~q^Q&^|k#RJE+b>*UQpadf76 zH8sHcS%I0U)}gdn@#gcw%Z`R&3(I^?XbQ8*U;0ngqQAGyPfgM3@UJ$jq;!a!zw6R| zZtG<@@f@c#uuHjRTCwFG-DukRO}(k?L0pUch|I}^wyVRx8?sudgLfF9eL2WV*~x?C zCjM+hU$Eu)mxfdwz4xFd+vskmA=vxn%yjL?aB?SOX+{RgG0@{fJ5RYEU%rH0iJyZX zw9L3XGR~`pZ1`>UfSEQ@V-5>qqdmrm=>{g3Vy9hX|A&4Mcr@a!4q39c^=qfiK914{ z>OVlwe21?Kno!-oR#%wRqGgEDg?DH$2#(eh+we?cyZt z{)i@S-SOtHu58^|6f}00&V7(x|ASaa+yg8~k`#2ZSZnb<*(6wtL+n_cv8N5;@@gau@T z*X#IiYG|z`w8{!KJ2-p|L?#ZYU(+a(#Q+HSd2_7G0VuegZtAv}BCX~g;*Zl)Cs?{Q z_$T#{=JxqOC@&C5=1(lD`khDUY(nWB@?Yv<0EhXTH-mpFP+=rWTc!oQIL7h+8kFjN z*f1tM4kw)IImNZHE=UvoHOf{i>&>?1zGbyrhK2=~J5q%S-gBu`?7Ev&-<>E+qx)L% zS}%zX9&7zc`|$9HgAj9OShgOQHBy$f-z~suy-UcM+CyhEjlwK}3!_^mFv@w={E`Uw zV{jYhv;T}Mu}r`2oc$u0m!>h~oH6t+dfCfp(qh)qb+`;|ccn^`0KM4$#Hf`v)4w^; z=?q%0yw?}ZKOgwhf=6VJP`A(1wD(df5ey<3p}!nFt7Qu9E!v9~h9hB%%t*ejh;e;Uw}63R%t64g)ji{q@7%IOp2UO^nNIiPnr8G!4215 z{c&Y z+sk^SP{S9N2A#T*tj%CD$q|QPQD`|zonWgyZ3J-Oqw{=xmy!dz*`|AXnll4y;F}-$ z84K53@DDL4HrHYJ*Du!=REb!tp2hP)86`rwn9ykBy=lh5c@{zHqyW-Zk1sIz+%j)24qsHJ1p35|9b& zHk^4w5wgGh(mL?2%kThm6eW1J;7D05a|WEoR5L&>%K(k<^y8YSn~ewWuCafDA!bgh zCx16{>1n;6Jn0@wnsMkiPP{{E|1<#v(+tu^_PC5n<}=2@!wrriSMD#)QwgW+-QF4m ze-8T9@SS!w#HG!4Bk*W;IT3UW2?U`fS_!tHaed&3X%5A z>2WXw)$#pL0)sWs_@j~* zZP&{icpP;}fEGgRmVM@ga930gOEMzuJgIG3 zT8$(nhM*Y+kOvU)rdtE4x+jZ+dd>JC&| zsah;{xy|Y>$~BO!?;Qor0nm)YS77Ft;bzdLI^ki7PrizN3fBuN^>-70W%i{!s~lo^ zG4t@|GAnc5vnf&-?V>nHon%r%lilwC_JK}anga}rAbiv<7NCljvs(q$NbsN_QC0Fq z@pjSmMgNFVllkT@q6I%16VO-$w|*UYQ*!?_;}@BL9eH5jeS z`YRN#)enFA?Mu1#Kb_hBd$P>$QxRNrk>oqI82G<_=yBt3rN;mA4Ex{h_sNpM XBUP*6c(tDK^I<3}Xh0BhuipL_C&V8s literal 0 HcmV?d00001 diff --git a/xdevice/figures/upgrade_5.png b/xdevice/figures/upgrade_5.png new file mode 100644 index 0000000000000000000000000000000000000000..64672ef4030b550703a947b79df9bbe8cef3a1a5 GIT binary patch literal 77261 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 0 else 1 + exception = None + for _ in range(retry): + try: + result = func(self, *args, **kwargs) + return result + except ReportException as error: + self.log.exception("Generate report error!", exc_info=False) + exception = error + except (ConnectionResetError, + ConnectionRefusedError, + ConnectionAbortedError) as error: + self.log.error("error type: {}, error: {}".format(error.__class__.__name__, error)) + if self.usb_type == DeviceConnectorType.hdc: + cmd = ["hdc", "reset"] + self.log.info("re-execute hdc reset") + else: + cmd = [UsbConst.connector_type, "start-server"] + self.log.info("re-execute {}".format(cmd)) + exec_cmd(cmd) + callback_to_outer(self, "error:{}, prepare to recover".format(error)) + if not self.recover_device(): + LOG.debug("Set device {} {} false".format(self.device_sn, ConfigConst.recover_state)) + self.set_recover_state(False) + callback_to_outer(self, "recover failed") + raise error + exception = error + callback_to_outer(self, "recover success") + except HdcError as error: + self.log.error("error type: {}, error: {}".format(error.__class__.__name__, error)) + callback_to_outer(self, "error:{}, prepare to recover".format(error)) + if not self.recover_device(): + LOG.debug("Set device {} {} false".format(self.device_sn, ConfigConst.recover_state)) + self.set_recover_state(False) + callback_to_outer(self, "recover failed") + raise error + exception = error + callback_to_outer(self, "recover success") + except Exception as error: + self.log.exception("error type: {}, error: {}".format(error.__class__.__name__, error), exc_info=False) + exception = error + raise exception + + return device_action + + +@Plugin(type=Plugin.DEVICE, id=DeviceOsType.aosp) +class DeviceAosp(IDevice): + """ + Class representing a device. + + Each object of this class represents one device in xDevice, + + Attributes: + device_sn: A string that's the serial number of the device. + """ + + device_sn = None + host = None + port = None + usb_type = None + is_timeout = False + device_log_proc = None + device_hilog_proc = None + device_os_type = DeviceOsType.aosp + test_device_state = None + device_allocation_state = DeviceAllocationState.available + label = None + log = platform_logger("DeviceAosp") + device_state_monitor = None + reboot_timeout = 2 * 60 * 1000 + _device_log_collector = None + _device_report_path = None + test_platform = Platform.aosp + device_id = None + model_dict = { + 'default': ProductForm.phone, + 'car': ProductForm.car, + 'tv': ProductForm.television, + 'watch': ProductForm.watch, + 'tablet': ProductForm.tablet, + 'nosdcard': ProductForm.phone + } + + def __init__(self): + self.extend_value = {} + self.device_lock = threading.RLock() + self.forward_ports = [] + self.proxy_listener = None + + def __eq__(self, other): + return self.device_sn == other.__get_serial__() and \ + self.device_os_type == other.device_os_type + + def __set_serial__(self, device_sn=""): + self.device_sn = device_sn + return self.device_sn + + def set_state(self, state): + self.test_device_state = state + + def __get_serial__(self): + return self.device_sn + + def get(self, key=None, default=None): + if not key: + return default + value = getattr(self, key, None) + if value: + return value + else: + return self.extend_value.get(key, default) + + def recover_device(self): + if not self.get_recover_state(): + LOG.debug("Device {} {} is false, cannot recover device".format( + self.device_sn, ConfigConst.recover_state)) + return False + + LOG.debug("Wait device {} to recover".format(self.device_sn)) + result = self.device_state_monitor.wait_for_device_available() + if result: + self.device_log_collector.restart_catch_device_log() + return result + + def get_device_type(self): + model = self.get_property("ro.build.characteristics", abort_on_exception=True) + self.label = self.model_dict.get(model, None) + + def get_property(self, prop_name, retry=RETRY_ATTEMPTS, abort_on_exception=False): + """ + Hdc command, ddmlib function + """ + command = "getprop {}".format(prop_name) + stdout = self.execute_shell_command(command, timeout=5 * 1000, output_flag=False, retry=retry, + abort_on_exception=abort_on_exception).strip() + if stdout: + LOG.debug(stdout) + return stdout + + @staticmethod + def connector_command(self, command, **kwargs): + timeout = int(kwargs.get("timeout", TIMEOUT)) / 1000 + error_print = bool(kwargs.get("error_print"), True) + join_result = bool(kwargs.get("join_result"), False) + timeout_msg = '' if timeout == 300.0 else " with timeout {}s".format(timeout) + if self.usb_type == DeviceConnectorType.hdc: + LOG.debug("{} execute command hdc {}{}".format(convert_serial(self.device_sn), command, timeout_msg)) + if self.host != "127.0.0.1": + cmd = [AdbHelper.CONNECTOR_NAME, "-s", "{}:{}".format(self.host, self.port), "-t", self.device_sn] + else: + cmd = [AdbHelper.CONNECTOR_NAME, "-t", self.device_sn] + else: + LOG.debug("{} execute command {} {}{}".format(convert_serial(self.device_sn), UsbConst.connector, command, + timeout_msg)) + cmd = [UsbConst.connector, "-s", self.device_sn, "-H", self.host, "-P", str(self.port)] + + if isinstance(command, list): + cmd.extend(command) + else: + command = command.strip() + command.extend(command.split(" ")) + result = exec_cmd(cmd, timeout, error_print, join_result) + if not result: + return result + for line in str(result).split("\n"): + if line.strip(): + LOG.debug(line.strip()) + return result + + @perform_device_action + def execute_shell_command(self, command, timeout=TIMEOUT, receiver=None, **kwargs): + if not receiver: + collect_receiver = CollectingOutputReceiver() + AdbHelper.execute_shell_command(self, command, timeout=timeout, receiver=collect_receiver, **kwargs) + return collect_receiver.output + else: + return AdbHelper.execute_shell_command(self, command, timeout=timeout, receiver=receiver, **kwargs) + + def execute_shell_cmd_background(self, command, timeout=TIMEOUT, receiver=None): + status = AdbHelper.execute_shell_command(self, command, timeout, receiver=receiver) + self.wait_for_device_not_available(DEFAULT_UNAVAILABLE_TIMEOUT) + self.device_state_monitor.wait_for_device_available(BACKGROUND_TIME) + cmd = "target mount" if self.usb_type == DeviceConnectorType.hdc else "remount" + self.connector_command(cmd) + self.device_log_collector.restart_catch_device_log() + return status + + def wait_for_device_not_available(self, wait_time): + return self.device_state_monitor.wait_for_device_not_available(wait_time) + + def _wait_for_device_online(self, wait_time=None): + return self.device_state_monitor.wait_for_device_online(wait_time) + + def _do_reboot(self): + AdbHelper.reboot(self) + if not self.wait_for_device_not_available(DEFAULT_UNAVAILABLE_TIMEOUT): + LOG.error( + "Did not detect device {} becoming unavailable after reboot".format(convert_serial(self.device_sn))) + + def _reboot_until_online(self): + self._do_reboot() + self._wait_for_device_online() + + def reboot(self): + self._reboot_until_online() + self.device_state_monitor.wait_for_device_available(self.reboot_timeout) + self.device_log_collector.restart_catch_device_log() + + @perform_device_action + def install_package(self, package_path, command=""): + if package_path is None: + raise HdcError( + "install package: package path cannot be None!") + # 临时规避push方案在macos上推送失败,导致安装失败的问题 + if platform.system() == "Darwin": + result = self.connector_command("install {} {}".format(command, package_path)) + if "error" in result: + LOG.error("exception {}".format(result)) + raise HdcError(result) + return result + else: + return AdbHelper.install_package(self, package_path, command) + + @perform_device_action + def uninstall_package(self, package_name): + return AdbHelper.uninstall_package(self, package_name) + + @perform_device_action + def push_file(self, local, remote, **kwargs): + """ + Push a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + if local is None: + raise HdcError("XDevice Local path cannot be None!") + remote_is_dir = kwargs.get("remote_is_dir", False) + if remote_is_dir: + ret = self.execute_shell_command("test -d {} && echo 0".format(remote)) + if not (ret != "" and len(str(ret).split()) != 0 and str(ret).split()[0] == "0"): + self.execute_shell_command("mkdir -p {}".format(remote)) + + # 临时规避push方案在macos上推送失败 + if platform.system() == "Darwin": + result = self.connector_command("push {} {}".format(local, remote)) + if "error" in result: + LOG.error("exception {}".format(result)) + raise HdcError(result) + else: + is_create = kwargs.get("is_create", False) + timeout = kwargs.get("timeout", TIMEOUT) + AdbHelper.push_file(self, local, remote, is_create=is_create, timeout=timeout) + if not self.is_file_exist(remote): + LOG.error("Push {} to {} failed".format(local, remote)) + raise HdcError("push {} to {} failed".format(local, remote)) + + @perform_device_action + def pull_file(self, remote, local, **kwargs): + """ + Pull a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + is_create = kwargs.get("is_create", False) + timeout = kwargs.get("timeout", TIMEOUT) + AdbHelper.pull_file(self, remote, local, is_create=is_create, timeout=timeout) + + def is_directory(self, path): + path = check_path_legal(path) + output = self.execute_shell_command("ls -ld {}".format(path)) + if output and output.startswith('d'): + return True + return False + + def get_recover_result(self, retry=RETRY_ATTEMPTS): + command = "getprop dev.bootcomplete" + try: + stdout = self.execute_shell_command(command, timeout=5 * 1000, output_flag=False, retry=retry, + abort_on_exception=True).strip() + if stdout: + LOG.debug(stdout) + except HdcError as error: + self.device.log.error("get_recover_result exception: {}".format(error)) + if self.usb_type == DeviceConnectorType.hdc: + cmd = ["hdc", "list", "targets"] + else: + cmd = [UsbConst.connector, "devices"] + result = exec_cmd(cmd) + LOG.debug("exec_cmd result: {}, current device_sn: {}".format(result, self.device_sn)) + if self.device_sn in result: + stdout = "1" + else: + stdout = "0" + return stdout + + def set_recover_state(self, state): + with self.device_lock: + setattr(self, ConfigConst.recover_state, state) + if not state: + self.test_device_state = TestDeviceState.NOT_AVAILABLE + self.device_allocation_state = DeviceAllocationState.unavailable + + def get_recover_state(self, default_state=True): + with self.device_lock: + state = getattr(self, ConfigConst.recover_state, default_state) + return state + + def set_device_report_path(self, path): + self._device_report_path = path + + def get_device_report_path(self): + return self._device_report_path + + def take_picture(self, name): + pass + + @property + def device_log_collector(self): + if self._device_log_collector is None: + self._device_log_collector = DeviceLogCollector(self) + return self._device_log_collector + + +class DeviceLogCollector: + hilog_file_address = [] + log_file_address = [] + device = None + restart_hilog_proc = [] + restart_log_proc = [] + device_log_level = None + + def __init__(self, device): + self.device = device + + def restart_catch_device_log(self): + if len(self.hilog_file_address) != len(self.log_file_address): + self.device.log.warinng("hilog address not equals to log address.") + return + from xdevice import FilePermission + for index, _ in enumerate(self.log_file_address): + hilog_open = os.open(self.hilog_file_address[index], os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + device_log_open = os.open(self.log_file_address[index], os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + with os.fdopen(hilog_open, "a") as hilog_file_pipe, \ + os.fdopen(device_log_open, "a") as device_log_file_pipe: + log_proc, hilog_proc = self.start_catch_device_log(log_file_pipe=device_log_file_pipe, + hilog_file_pipe=hilog_file_pipe, clear_log=False) + self.restart_hilog_proc.append(hilog_proc) + self.restart_log_proc.append(log_proc) + + def stop_restart_catch_device_log(self): + # when device free stop restart log proc + for _, proc in enumerate(self.restart_hilog_proc): + self.stop_catch_device_log(proc) + for _, proc in enumerate(self.restart_log_proc): + self.stop_catch_device_log(proc) + self.restart_hilog_proc.clear() + self.restart_log_proc.clear() + self.hilog_file_address.clear() + self.log_file_address.clear() + + def start_catch_device_log(self, log_file_pipe=None, hilog_file_pipe=None, clear_log=True, **kwargs): + """ + Starts hdc log for each device in separate subprocesses and save + the logs in files. + """ + # 设置日志级别 + if not self.device_log_level: + log_level = kwargs.get("log_level", "DEBUG") + if log_level not in LOGLEVEL: + self.device_log_level = "DEBUG" + else: + self.device_log_level = log_level + + device_log_proc = None + device_hilog_proc = None + if log_file_pipe: + if clear_log: + self.device.execute_shell_command("logcat -c") + self.device.execute_shell_command("setprop persist.log.tag {}".format(self.device_log_level)) + command = ["logcat", "-v", "threadtime"] + device_log_proc = start_standing_subprocess(self._common_cmd(command), log_file_pipe) + if hilog_file_pipe: + if clear_log: + self.device.execute_shell_command("hilogcat -c") + self.device.execute_shell_command("setprop persist.hilog.tag {}".format(self.device_log_level)) + command = ["hilogcat"] + device_hilog_proc = start_standing_subprocess(self._common_cmd(command), hilog_file_pipe) + return device_log_proc, device_hilog_proc + + def stop_catch_device_log(self, proc): + """ + Stops all hdc log subprocesses. + """ + if proc: + stop_standing_subprocess(proc) + self.device.log.debug("Stop catch device log.") + + def _common_cmd(self, command=None): + if self.device.usb_type == DeviceConnectorType.hdc: + if self.device.host != "127.0.0.1": + cmd = [AdbHelper.CONNECTOR_NAME, "-s", "{}:{}".format(self.device.host, self.device.port), "-t", + self.device.device_sn, "shell"] + command + else: + cmd = [AdbHelper.CONNECTOR_NAME, "-t", self.device.device_sn, "shell"] + command + else: + cmd = [UsbConst.connector, "-s", self.device.device_sn, "-H", self.device.host, "-P", str(self.device.port), + "shell"] + command + return cmd + + def _get_log(self, log_cmd): + data_list = list() + log_name_array = list() + log_result = self.device.execute_shell_command(log_cmd) + if log_result is not None and len(log_result) != 0: + log_name_array = log_result.strip().replace("\r", "").split("\n") + for log_name in log_name_array: + log_name = log_name.strip() + data_list.append(log_name) + return data_list + + def start_get_crash_log(self, task_name, **kwargs): + log_array = self._get_log("ls /data/log/faultlog/faultlogger") + self.device.log.debug("crash log file list is {}".format(log_array)) + if len(log_array) <= 0: + return + module_name = kwargs.get("module_name", None) + if module_name: + log_path = "{}/log/{}/crash_log_{}/".format(self.device.get_device_report_path(), module_name, task_name) + else: + log_path = "{}/log/crash_log_{}/".format(self.device.get_device_report_path(), task_name) + if not os.path.exists(log_path): + os.mkdir(log_path) + self.device.pull_file("/data/log/faultlog/faultlogger", log_path) + + def clear_crash_log(self): + clear_faultlog_crash_cmd = "rm -f /data/log/faultlog/faultlogger/*" + self.device.execute_shell_command(clear_faultlog_crash_cmd) + + def add_log_address(self, log_file_address, hilog_file_address): + # record to restart catch log when reboot device + if log_file_address: + self.log_file_address.append(log_file_address) + if hilog_file_address: + self.hilog_file_address.append(hilog_file_address) + + def remove_log_address(self, log_file_address, hilog_file_address): + if log_file_address and log_file_address in self.log_file_address: + self.log_file_address.remove(log_file_address) + if hilog_file_address and hilog_file_address in self.hilog_file_address: + self.log_file_address.remove(hilog_file_address) + + def pull_extra_log_files(self, task_name, module_name, dirs: str): + if dirs is None: + return + dirs_list = dirs.strip(";") + for dir_path in dirs_list: + extra_log_path = "{}/log/{}/{}_extra_log/".format(self.device.get_device_report_path(), module_name, + task_name) + self.device.pull_file(dir_path, extra_log_path) diff --git a/xdevice/plugins/aosp/environment/dmlib.py b/xdevice/plugins/aosp/environment/dmlib.py new file mode 100644 index 0000000..b7467cf --- /dev/null +++ b/xdevice/plugins/aosp/environment/dmlib.py @@ -0,0 +1,1131 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import platform +import socket +import struct +import threading +import time +import shutil +import stat +from dataclasses import dataclass + +from xdevice import DeviceOsType +from xdevice import ParamError +from xdevice import ReportException +from xdevice import ExecuteTerminate +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import IShellReceiver +from xdevice import exec_cmd +from xdevice import get_file_absolute_path +from xdevice import FilePermission +from xdevice import DeviceError +from xdevice import HdcError +from xdevice import HdcCommandRejectedException +from xdevice import ShellCommandUnresponsiveException +from xdevice import DeviceState +from xdevice import DeviceConnectorType +from xdevice import convert_serial +from xdevice import is_proc_running +from xdevice import convert_ip +from xdevice import create_dir + +from aosp.constants import UsbConst + +ID_OKAY = b'OKAY' +ID_FAIL = b'FAIL' +ID_STAT = b'STAT' +ID_RECV = b'RECV' +ID_DATA = b'DATA' +ID_DONE = b'DONE' +ID_SEND = b'SEND' +ID_LIST = b'LIST' +ID_DENT = b'DENT' + +DEFAULT_ENCODING = "ISO-8859-1" +SYNC_DATA_MAX = 64 * 1024 +REMOTE_PATH_MAX_LENGTH = 1024 +SOCK_DATA_MAX = 256 + +INSTALL_TIMEOUT = 2 * 60 * 1000 +DEFAULT_TIMEOUT = 40 * 1000 + +MAX_CONNECT_ATTEMPT_COUNT = 10 +DATA_UNIT_LENGTH = 4 +HEXADECIMAL_NUMBER = 16 +SPECIAL_FILE_MODE = 41471 +FORMAT_BYTES_LENGTH = 4 +DEFAULT_OFFSET_OF_INT = 4 + +INVALID_MODE_CODE = -1 +DEFAULT_PORT = 5037 +HDC_NAME = "hdc" +LOG = platform_logger("Adb") + + +class AdbMonitor: + """ + A Device monitor. + This monitor connects to the Device Connector, gets device and + debuggable process information from it. + """ + MONITOR_MAP = {} + + def __init__(self, host="127.0.0.1", port=None, device_connector=None): + self.channel = dict() + self.channel.setdefault("host", host) + self.channel.setdefault("port", port) + self.main_adb_connection = None + self.connection_attempt = 0 + self.is_stop = False + self.monitoring = False + self.server = device_connector + self.devices = [] + self.hdc_version = None + + @staticmethod + def get_instance(host, port=None, device_connector=None): + if host not in AdbMonitor.MONITOR_MAP: + monitor = AdbMonitor(host, port, device_connector) + AdbMonitor.MONITOR_MAP[host] = monitor + LOG.debug("AdbMonitor map add host {}, map is {}".format + (host, AdbMonitor.MONITOR_MAP)) + + return AdbMonitor.MONITOR_MAP[host] + + def start(self): + """ + Starts the monitoring. + """ + try: + LOG.debug("AdbMonitor usb type is {}".format(self.server.usb_type)) + if self.server.usb_type == DeviceConnectorType.hdc and shutil.which(HDC_NAME): + # 先停止ADB,因为ADB与HDC有冲突 + self.stop_adb(UsbConst.connector) + AdbHelper.CONNECTOR_NAME = HDC_NAME + self.init_hdc(AdbHelper.CONNECTOR_NAME) + else: + self.stop_adb(HDC_NAME) + AdbHelper.CONNECTOR_NAME = UsbConst.connector + if not is_proc_running(UsbConst.connector): + self.start_adb(connector=UsbConst.connector, + local_port=self.channel.setdefault("port", DEFAULT_PORT)) + time.sleep(1) + server_thread = threading.Thread(target=self.loop_monitor, name="AdbMonitor", args=()) + server_thread.daemon = True + server_thread.start() + except FileNotFoundError as _: + LOG.error("AdbMonitor can't find connector, init device environment failed!") + + def init_hdc(self, connector_name=HDC_NAME): + env_hdc = shutil.which(connector_name) + # if not, add xdevice's own hdc path to environ path. + # tell if hdc has already been in the environ path. + if env_hdc is None: + LOG.debug("AdbMonitor can't find HDC, try to find ADB.") + # use adb + if shutil.which(UsbConst.connector) is not None: + connector_name = UsbConst.connector + else: + LOG.error("AdbMonitor can't find HDC or ADB, init device environment failed!") + if not is_proc_running(connector_name, HDC_NAME): + port = DEFAULT_PORT + self.start_adb(connector=connector_name, local_port=self.channel.setdefault("port", port)) + time.sleep(1) + + def stop_adb(self, connector="adb"): + """ + Starts the hdc host side server. + """ + if shutil.which(HDC_NAME) is not None: + self.hdc_version = exec_cmd([HDC_NAME, "-v"], error_print=False) + LOG.debug("AdbMonitor {} version: {}".format(HDC_NAME, self.hdc_version)) + if self.hdc_version and "Ver:" in self.hdc_version: + LOG.debug("The connector is normalized version, so does not need to be killed.") + return + + if connector.startswith(HDC_NAME): + if is_proc_running(connector): + try: + LOG.debug("AdbMonitor {} kill".format(connector)) + exec_cmd([connector, "kill"]) + except ParamError as error: + LOG.debug("AdbMonitor {} kill error: {}".format(connector, error)) + except FileNotFoundError as _: + LOG.warning("Cannot kill {} process, please close it manually!".format(connector)) + else: + if is_proc_running(UsbConst.connector): + LOG.debug("AdbMonitor {}".format(UsbConst.kill_server)) + exec_cmd([UsbConst.connector, "kill-server"]) + + def stop(self): + """ + Stops the monitoring. + """ + for host in AdbMonitor.MONITOR_MAP: + LOG.debug("AdbMonitor stop host {}".format(host)) + monitor = AdbMonitor.MONITOR_MAP[host] + try: + monitor.is_stop = True + if monitor.main_adb_connection is not None: + monitor.main_adb_connection.shutdowm(2) + monitor.main_adb_connection.close() + monitor.main_adb_connection = None + except (socket.error, socket.gaierror, socket.timeout) as _: + LOG.error("AdbMonitor close socket exception") + AdbMonitor.MONITOR_MAP.clear() + LOG.debug("AdbMonitor {} monitor stop!".format(AdbMonitor.CONNECTOR_NAME)) + LOG.debug("AdbMonitor map is {}".format(AdbMonitor.MONITOR_MAP)) + + def loop_monitor(self): + """ + Monitors the devices. This connects to the Debug Bridge + """ + LOG.debug("current connector name is {}".format(AdbMonitor.CONNECTOR_NAME)) + while not self.is_stop: + try: + if self.main_adb_connection is None: + self.main_adb_connection = self.open_adb_connection() + if self.main_adb_connection is None: + self.connection_attempt += 1 + + if self.connection_attempt > MAX_CONNECT_ATTEMPT_COUNT: + self.is_stop = True + LOG.error("AdbMonitor attempt {}, can't connect to hdc for Device List Monitoring".format( + str(self.connection_attempt))) + raise HdcError("AdbMonitor cannot connect {} server({} {}) please check!".format( + AdbHelper.CONNECTOR_NAME, self.channel.get("host"), str(self.channel.get("port")))) + LOG.debug("AdbMonitor Connection attempts: {}".format(str(self.connection_attempt))) + time.sleep(2) + else: + LOG.debug("AdbMonitor Connection to {} for device monitoring, main_adb_connection is {}".format( + AdbHelper.CONNECTOR_NAME, self.main_adb_connection)) + self.track_devices() + except (HdcError, Exception) as _: + self.handle_exception_monitor_loop() + break + + def handle_exception_monitor_loop(self): + LOG.debug("Handle exception monitor loop: {}".format(self.main_adb_connection)) + if self.main_adb_connection is None: + return + self.main_adb_connection.close() + LOG.debug("Handle exception monitor loop, main {} connection closed, main {} connection: {}".format( + AdbHelper.CONNECTOR_NAME, AdbHelper.CONNECTOR_NAME, self.main_adb_connection)) + + def device_list_monitoring(self): + request = AdbHelper.form_adb_requset("host:track-devices") + AdbHelper.write(self.main_adb_connection, request) + resp = AdbHelper.read_adb_response(self.main_adb_connection) + if not resp.okay: + LOG.error("AdbMonitor execute command success:send device_list monitoring request") + return True + + def process_incoming_target_data(self, length): + local_array_list = [] + if length > 0: + data_buf = AdbHelper.read(self.main_adb_connection, length) + data_str = AdbHelper.reply_to_string(data_buf) + lines = data_str.split("\n") + for line in lines: + items = line.strip().split("\n") + if len(items) != 2: + continue + device_instance = self._get_device_instance(items, DeviceOsType.aosp) + local_array_list.append(device_instance) + self.update_devices(local_array_list) + + def _get_device_instance(self, items, os_type): + device = get_plugin(plugin_type=Plugin.DEVICE, plugin_id=os_type)[0] + device_instance = device.__class__() + device_instance.__set_serial__(items[0]) + device_instance.host = self.channel.get("host") + device_instance.port = self.channel.get("port") + if self.changed: + LOG.debug("Dmlib get device instance {} {} {}".format + (device_instance.device_sn, + device_instance.host, device_instance.port)) + device_instance.device_state = DeviceState.get_state(items[3]) + return device_instance + + def update_devices(self, param_array_list): + devices = [item for item in self.devices] + devices.reverse() + for local_device1 in devices: + k = 0 + for local_device2 in param_array_list: + if local_device1.device_sn == local_device2.device_sn and \ + local_device1.device_os_type == \ + local_device2.device_os_type: + k = 1 + if local_device1.device_state != \ + local_device2.device_state: + local_device1.device_state = local_device2.device_state + self.server.device_changed(local_device1) + param_array_list.remove(local_device2) + break + + if k == 0: + self.devices.remove(local_device1) + self.server.device_disconnected(local_device1) + for local_device in param_array_list: + self.devices.append(local_device) + self.server.device_connected(local_device) + + def open_adb_connection(self): + sock = None + try: + LOG.debug("AdbMonitor connecting to hdc for Device List Monitoring") + LOG.debug( + "AdbMonitor socket connection host: {}, port: {}".format(str(convert_ip(self.channel.get("host"))), + str(int(self.channel.get("port"))))) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((self.channel.get("host"), int(self.channel.get("port")))) + return sock + except (socket.error, socket.gaierror, socket.timeout) as exception: + LOG.error("AdbMonitor hdc socket connection Error: {}, host is {}, port is {}" + "".format(exception, self.channel.get("host"), self.channel.get("port"))) + return sock + + def start_adb(self, connector=HDC_NAME, kill=False, local_port=None): + if connector.startswith(HDC_NAME): + if kill: + LOG.debug("AdbMonitor {} kill".format(connector)) + exec_cmd([connector, "kill"]) + if self.hdc_version and "Ver:" not in self.hdc_version: + LOG.debug("AdbMonitor {} reset".format(connector)) + exec_cmd([connector, "reset"], error_print=False, redirect=True) + else: + LOG.debug("AdbMonitor {} start".format(connector)) + exec_cmd([connector, "-l5", "start"], error_print=False, redirect=True) + else: + if kill: + LOG.debug("AdbMonitor {}".format(UsbConst.kill_server)) + exec_cmd([UsbConst.connector, "kill-server"]) + LOG.debug("AdbMonitor {}".format(UsbConst.start_server)) + exec_cmd([UsbConst.connector, "start-server"], error_print=False) + + def track_device(self): + if self.main_adb_connection and not self.monitoring: + self.monitoring = self.device_list_monitoring() + if self.monitoring is True: + self.connection_attempt = 0 + len_buf = AdbHelper.read(self.main_adb_connection, DATA_UNIT_LENGTH) + self.server.monitor_lock.acquire() + len_str = AdbHelper.reply_to_string(len_buf) + length = int(len_str, HEXADECIMAL_NUMBER) + if length >= 0: + self.process_incoming_target_data(length) + self.server.monitor_lock.release() + + +@dataclass +class AdbResponse: + okay = ID_OKAY + message = "" + + +class SyncService: + + def __init__(self, device, host=None, port=None): + self.device = device + self.host = host + self.port = port + self.sock = None + + def open_sync(self, timeout=DEFAULT_TIMEOUT): + LOG.debug("Open sync, timeout={}".format(int(timeout / 1000))) + self.sock = AdbHelper.socket(host=self.host, port=self.port, timeout=timeout) + AdbHelper.set_device(self.device, self.sock) + + request = AdbHelper.form_adb_request("sync:") + AdbHelper.write(self.sock, request) + + resp = AdbHelper.read_adb_response(self.sock) + if not resp.okay: + self.device.log.error("Got unhappy response form HDC sync req: {}".format(resp.message)) + raise HdcError("Got unhappy response form HDC sync req: {}".format(resp.message)) + + def close(self): + if self.sock is not None: + try: + self.sock.close() + except socket.error as error: + LOG.error("Socket close error: {}".format(error), error_no="00420") + finally: + self.sock = None + + def pull_file(self, remote, local, is_create=False): + """ + Pulls a file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + mode = self.read_mode(remote) + self.device.log.debug("Remote file %s mode is %d" % (remote, mode)) + if mode == 0: + raise HdcError("Remote object doesn't exist!") + + if str(mode).startswith("168"): + if is_create: + remote_file_split = os.path.split(remote)[-1] \ + if os.path.split(remote)[-1] else os.path.split(remote)[-2] + remote_file_basename = os.path.basename(remote_file_split) + new_local = os.path.join(local, remote_file_basename) + create_dir(new_local) + else: + new_local = local + + collect_receiver = CollectingOutputReceiver() + AdbHelper.execute_shell_command(self.device, "ls %s" % remote, + receiver=collect_receiver) + files = collect_receiver.output.split() + for file_name in files: + self.pull_file("%s/%s" % (remote, file_name), + new_local, is_create=True) + elif mode == SPECIAL_FILE_MODE: + self.device.log.info("skipping special file '%s'" % remote) + else: + if os.path.isdir(local): + local = os.path.join(local, os.path.basename(remote)) + + self.do_pull_file(remote, local) + + def do_pull_file(self, remote, local): + """ + Pulls a remote file + """ + self.device.log.info( + "%s pull %s to %s" % (convert_serial(self.device.device_sn), + remote, local)) + remote_path_content = remote.encode(DEFAULT_ENCODING) + if len(remote_path_content) > REMOTE_PATH_MAX_LENGTH: + raise HdcError("Remote path is too long.") + + msg = self.create_file_req(ID_RECV, remote_path_content) + AdbHelper.write(self.sock, msg) + pull_result = AdbHelper.read(self.sock, DATA_UNIT_LENGTH * 2) + if not self.check_result(pull_result, ID_DATA) and \ + not self.check_result(pull_result, ID_DONE): + raise HdcError(self.read_error_message(pull_result)) + if platform.system() == "Windows": + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_BINARY + else: + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + pulled_file_open = os.open(local, flags, FilePermission.mode_755) + with os.fdopen(pulled_file_open, "wb") as pulled_file: + while True: + if self.check_result(pull_result, ID_DONE): + break + + if not self.check_result(pull_result, ID_DATA): + raise HdcError(self.read_error_message(pull_result)) + + try: + length = self.swap32bit_from_array( + pull_result, DEFAULT_OFFSET_OF_INT) + except IndexError as index_error: + self.device.log.debug("do_pull_file: %s" % + str(pull_result)) + if pull_result == ID_DATA: + pull_result = self.sock.recv(DATA_UNIT_LENGTH) + self.device.log.debug( + "do_pull_file: %s" % str(pull_result)) + length = self.swap32bit_from_array(pull_result, 0) + self.device.log.debug("do_pull_file: %s" % str(length)) + else: + raise IndexError(str(index_error)) from index_error + + if length > SYNC_DATA_MAX: + raise HdcError("Receiving too much data.") + + pulled_file.write(AdbHelper.read(self.sock, length)) + pulled_file.flush() + pull_result = self.sock.recv(DATA_UNIT_LENGTH * 2) + + def push_file(self, local, remote, is_create=False): + """ + Push a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + if not os.path.exists(local): + raise HdcError("Local path doesn't exist.") + + if os.path.isdir(local): + if is_create: + local_file_split = os.path.split(local)[-1] \ + if os.path.split(local)[-1] else os.path.split(local)[-2] + local_file_basename = os.path.basename(local_file_split) + remote = "{}/{}".format( + remote, local_file_basename) + AdbHelper.execute_shell_command( + self.device, "mkdir -p %s" % remote) + + for child in os.listdir(local): + file_path = os.path.join(local, child) + if os.path.isdir(file_path): + self.push_file( + file_path, "%s/%s" % (remote, child), + is_create=False) + else: + self.do_push_file(file_path, "%s/%s" % (remote, child)) + else: + self.do_push_file(local, remote) + + def do_push_file(self, local, remote): + """ + Push a single file + + Args: + ------------ + local : string + the local file to push + remote : string + the remote file (length max is 1024) + """ + mode = self.read_mode(remote) + self.device.log.debug("Remote file %s mode is %d" % (remote, mode)) + self.device.log.debug("%s execute command: hdc push %s %s" % ( + convert_serial(self.device.device_sn), local, remote)) + if str(mode).startswith("168"): + remote = "%s/%s" % (remote, os.path.basename(local)) + + try: + try: + remote_path_content = remote.encode(DEFAULT_ENCODING) + except UnicodeEncodeError as _: + remote_path_content = remote.encode("UTF-8") + if len(remote_path_content) > REMOTE_PATH_MAX_LENGTH: + raise HdcError("Remote path is too long.") + + # create the header for the action + # and send it. We use a custom try/catch block to make the + # difference between file and network IO exceptions. + msg = self.create_send_file_req(ID_SEND, remote_path_content, + FilePermission.mode_644) + + AdbHelper.write(self.sock, msg) + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(local, flags, modes), "rb") as test_file: + while True: + if platform.system() == "Linux": + data = test_file.read(1024 * 4) + else: + data = test_file.read(SYNC_DATA_MAX) + + if not data: + break + + buf = struct.pack( + "%ds%ds%ds" % (len(ID_DATA), FORMAT_BYTES_LENGTH, + len(data)), ID_DATA, + self.swap32bits_to_bytes(len(data)), data) + self.sock.send(buf) + except Exception as exception: + self.device.log.error("exception %s" % exception) + raise exception + + msg = self.create_req(ID_DONE, int(time.time())) + AdbHelper.write(self.sock, msg) + result = AdbHelper.read(self.sock, DATA_UNIT_LENGTH * 2) + if not self.check_result(result, ID_OKAY): + self.device.log.error("exception %s" % result) + raise HdcError(self.read_error_message(result)) + + def read_mode(self, path): + """ + Returns the mode of the remote file. + Return an Integer containing the mode if all went well or null + """ + msg = self.create_file_req(ID_STAT, path) + AdbHelper.write(self.sock, msg) + + # read the result, in a byte array containing 4 ints + stat_result = AdbHelper.read(self.sock, DATA_UNIT_LENGTH * 4) + if not self.check_result(stat_result, ID_STAT): + return INVALID_MODE_CODE + + return self.swap32bit_from_array(stat_result, DEFAULT_OFFSET_OF_INT) + + def create_file_req(self, command, path): + """ + Creates the data array for a file request. This creates an array with a + 4 byte command + the remote file name. + + Args: + ------------ + command : + the 4 byte command (ID_STAT, ID_RECV, ...) + path : string + The path, as a byte array, of the remote file on which to execute + the command. + + return: + ------------ + return the byte[] to send to the device through hdc + """ + if isinstance(path, str): + try: + path = path.encode(DEFAULT_ENCODING) + except UnicodeEncodeError as _: + path = path.encode("UTF-8") + + return struct.pack( + "%ds%ds%ds" % (len(command), FORMAT_BYTES_LENGTH, len(path)), + command, self.swap32bits_to_bytes(len(path)), path) + + def create_send_file_req(self, command, path, mode=0o644): + # make the mode into a string + mode_str = ",%s" % str(mode & FilePermission.mode_777) + mode_content = mode_str.encode(DEFAULT_ENCODING) + return struct.pack( + "%ds%ds%ds%ds" % (len(command), FORMAT_BYTES_LENGTH, len(path), + len(mode_content)), + command, self.swap32bits_to_bytes(len(path) + len(mode_content)), + path, mode_content) + + def create_req(self, command, value): + """ + Create a command with a code and an int values + """ + return struct.pack("%ds%ds" % (len(command), FORMAT_BYTES_LENGTH), + command, self.swap32bits_to_bytes(value)) + + @staticmethod + def check_result(result, code): + """ + Checks the result array starts with the provided code + + Args: + ------------ + result : + the result array to check + path : string + the 4 byte code + + return: + ------------ + bool + return true if the code matches + """ + return result[0:4] == code[0:4] + + def read_error_message(self, result): + """ + Reads an error message from the opened Socket. + + Args: + ------------ + result : + the current hdc result. Must contain both FAIL and the length of + the message. + """ + if self.check_result(result, ID_FAIL): + length = self.swap32bit_from_array(result, 4) + if length > 0: + return str(AdbHelper.read(self.sock, length)) + + return None + + @staticmethod + def swap32bits_to_bytes(value): + """ + Swaps an unsigned value around, and puts the result in an bytes that + can be sent to a device. + + Args: + ------------ + value : + the value to swap. + """ + return bytes([value & 0x000000FF, + (value & 0x0000FF00) >> 8, + (value & 0x00FF0000) >> 16, + (value & 0xFF000000) >> 24]) + + @staticmethod + def swap32bit_from_array(value, offset): + """ + Reads a signed 32 bit integer from an array coming from a device. + + Args: + ------------ + value : + the array containing the int + offset: + the offset in the array at which the int starts + + Return: + ------------ + int + the integer read from the array + """ + result = 0 + result |= (int(value[offset])) & 0x000000FF + result |= (int(value[offset + 1]) & 0x000000FF) << 8 + result |= (int(value[offset + 2]) & 0x000000FF) << 16 + result |= (int(value[offset + 3]) & 0x000000FF) << 24 + + return result + + +class AdbHelper: + CONNECTOR_NAME = "" + + @staticmethod + def push_file(device, local, remote, is_create=False, timeout=DEFAULT_TIMEOUT): + if device.usb_type == DeviceConnectorType.hdc: + device.log.info("{} execute command: {} file send {} {}".format( + convert_serial(device.device_sn), AdbHelper.CONNECTOR_NAME, local, remote)) + else: + device.log.info("{} execute command: {} {} {} {}".format( + convert_serial(device.device_sn), AdbHelper.CONNECTOR_NAME, UsbConst.push, local, remote)) + service = None + try: + service = SyncService(device, host=device.host, port=device.port) + service.open_sync(timeout) + service.push_file(local, remote, is_create=is_create) + finally: + if service is not None: + service.close() + + @staticmethod + def pull_file(device, remote, local, is_create=False, timeout=DEFAULT_TIMEOUT): + if device.usb_type == DeviceConnectorType.hdc: + device.log.info("{} execute command: {} file recv {} {}".format( + convert_serial(device.device_sn), AdbHelper.CONNECTOR_NAME, remote, local)) + else: + device.log.info("{} execute command: {} {} {} {}".format( + convert_serial(device.device_sn), AdbHelper.CONNECTOR_NAME, UsbConst.pull, remote, local)) + service = None + try: + service = SyncService(device, host=device.host, port=device.port) + service.open_sync(timeout) + service.pull(remote, local, is_create=is_create) + finally: + if service is not None: + service.close() + + @staticmethod + def _install_remote_package(device, remote_file_path, command): + receiver = CollectingOutputReceiver() + cmd = "bm install %s %s" % (command.strip(), remote_file_path) + AdbHelper.execute_shell_command(device, cmd, INSTALL_TIMEOUT, receiver) + return receiver.output + + @staticmethod + def install_package(device, package_file_path, command): + device.log.info("%s install %s" % (convert_serial(device.device_sn), + package_file_path)) + remote_file_path = "/data/local/tmp/%s" % os.path.basename( + package_file_path) + + service = None + try: + service = SyncService(device, host=device.host, port=device.port) + service.open_sync() + service.push_file(package_file_path, remote_file_path) + finally: + if service is not None: + service.close() + + result = AdbHelper._install_remote_package(device, remote_file_path, command) + + AdbHelper.execute_shell_command(device, "rm %s " % remote_file_path) + return result + + @staticmethod + def uninstall_package(device, package_name): + receiver = CollectingOutputReceiver() + command = "bm uninstall -n %s " % package_name + device.log.info("%s %s" % (convert_serial(device.device_sn), command)) + AdbHelper.execute_shell_command(device, command, INSTALL_TIMEOUT, + receiver) + return receiver.output + + @staticmethod + def reboot(device, into=None): + if device.usb_type == DeviceConnectorType.hdc: + device.log.info("{} execute command: {} target boot".format( + convert_serial(device.device_sn), AdbHelper.CONNECTOR_NAME)) + else: + device.log.info("{} execute command: {}".format( + convert_serial(device.device_sn), UsbConst.reboot)) + with AdbHelper.socket(host=device.host, port=device.port) as sock: + AdbHelper.set_device(device, sock) + if into is None: + request = AdbHelper.form_adb_request("reboot:") + else: + request = AdbHelper.form_adb_request("reboot:{}".format(into)) + AdbHelper.write(sock, request) + + @staticmethod + def execute_shell_command(device, command, timeout=DEFAULT_TIMEOUT, + receiver=None, **kwargs): + """ + Executes a shell command on the device and retrieve the output. + + Args: + ------------ + device : IDevice + on which to execute the command. + command : string + the shell command to execute + timeout : int + max time between command output. If more time passes between + command output, the method will throw + ShellCommandUnresponsiveException (ms). + """ + try: + if not timeout: + timeout = DEFAULT_TIMEOUT + + with AdbHelper.socket(host=device.host, port=device.port, + timeout=timeout) as sock: + output_flag = kwargs.get("output_flag", True) + timeout_msg = " with timeout %ss" % str(timeout / 1000) + if device.usb_type == DeviceConnectorType.hdc: + message = "{} execute command: hdc shell {}{}".format( + convert_serial(device.device_sn), command, timeout_msg) + else: + message = "{} execute command: {} {}{}".format( + convert_serial(device.device_sn), UsbConst.shell, command, timeout_msg) + if output_flag: + LOG.info(message) + else: + LOG.debug(message) + from xdevice import Scheduler + AdbHelper.set_device(device, sock) + request = AdbHelper.form_adb_request("shell:{}".format(command)) + AdbHelper.write(sock, request) + resp = AdbHelper.read_adb_response() + if not resp.okay: + device.log.error( + "[AdbHelper] {} rejected shell command ({}): {}".format(AdbHelper.CONNECTOR_NAME, command, + resp.message)) + raise HdcCommandRejectedException(resp.message) + + data = sock.recv(SYNC_DATA_MAX) + while data != b'': + ret = AdbHelper.reply_to_string(data) + if ret: + if receiver: + receiver.__read__(ret) + else: + LOG.debug(ret) + if not Scheduler.is_execute: + raise ExecuteTerminate() + data = AdbHelper.read(sock, SYNC_DATA_MAX) + return resp + except socket.timeout as error: + device.log.error("ShellCommandUnresponsiveException: {} shell {} timeout[{}S]".format( + convert_serial(device.device_sn), command, str(timeout / 1000))) + raise error + finally: + if receiver: + receiver.__done__() + + @staticmethod + def set_device(device, sock): + """ + Tells hdc to talk to a specific device + if the device is not -1, then we first tell hdc we're looking to talk + to a specific device + """ + msg = "host:transport:%s" % device.device_sn + device_query = AdbHelper.form_adb_request(msg) + AdbHelper.write(sock, device_query) + resp = AdbHelper.read_adb_response(sock) + if not resp.okay: + raise HdcCommandRejectedException(resp.message) + + @staticmethod + def form_adb_request(req): + """ + Create an ASCII string preceded by four hex digits. + """ + try: + req = req.encode("utf-8").decode("latin1") + result_str = "%04X%s" % (len(req), req) + result = result_str.encode(DEFAULT_ENCODING) + except UnicodeEncodeError as ex: + LOG.error(ex) + raise ex + return result + + @staticmethod + def read_adb_response(sock, read_diag_string=False): + """ + Reads the response from HDC after a command. + + Args: + ------------ + read_diag_string : + If true, we're expecting an OKAY response to be followed by a + diagnostic string. Otherwise, we only expect the diagnostic string + to follow a FAIL. + """ + resp = AdbResponse() + reply = AdbHelper.read(sock, DATA_UNIT_LENGTH) + if AdbHelper.is_okay(reply): + resp.okay = True + else: + read_diag_string = True + resp.okay = False + + while read_diag_string: + len_buf = AdbHelper.read(sock, DATA_UNIT_LENGTH) + len_str = AdbHelper.reply_to_string(len_buf) + msg = AdbHelper.read(sock, int(len_str, HEXADECIMAL_NUMBER)) + resp.message = AdbHelper.reply_to_string(msg) + break + + return resp + + @staticmethod + def write(sock, req, timeout=10): + if isinstance(req, str): + req = req.encode(DEFAULT_ENCODING) + elif isinstance(req, list): + req = bytes(req) + + start_time = time.time() + while req: + if time.time() - start_time > timeout: + LOG.debug("Socket write timeout, timeout:%ss" % timeout) + break + + size = sock.send(req) + if size < 0: + raise DeviceError("channel EOF") + + req = req[size:] + time.sleep(5 / 1000) + + @staticmethod + def read(sock, length, timeout=10): + data = b'' + recv_len = 0 + start_time = time.time() + exc_num = 3 + while length - recv_len > 0: + if time.time() - start_time > timeout: + LOG.debug("Socket read timeout, timout:%ss" % timeout) + break + try: + recv = sock.recv(length - recv_len) + if len(recv) > 0: + time.sleep(5 / 1000) + else: + break + except ConnectionResetError as error: + if exc_num <= 0: + raise error + exc_num = exc_num - 1 + recv = b'' + time.sleep(1) + LOG.debug("ConnectionResetError occurs") + + data += recv + recv_len += len(recv) + + return data + + @staticmethod + def is_okay(reply): + """ + Checks to see if the first four bytes in "reply" are OKAY. + """ + return reply[0:4] == ID_OKAY + + @staticmethod + def reply_to_string(reply): + """ + Converts an HDC reply to a string. + """ + try: + return str(reply, encoding=DEFAULT_ENCODING) + except (ValueError, TypeError) as _: + return "" + + @staticmethod + def socket(host=None, port=None, timeout=None): + end = time.time() + 10 * 60 + sock = None + adb_connection = AdbMonitor.MONITOR_MAP.get(host, "127.0.0.1") + while host not in AdbMonitor.MONITOR_MAP or \ + adb_connection.main_hdc_connection is None: + LOG.debug("Host: %s, port: %s, HdcMonitor map is %s" % ( + host, port, AdbMonitor.MONITOR_MAP)) + if host in AdbMonitor.MONITOR_MAP: + LOG.debug("Monitor main hdc connection is %s" % + adb_connection.main_hdc_connection) + if time.time() > end: + raise HdcError("Cannot detect HDC monitor!") + time.sleep(2) + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, int(port))) + except socket.error as exception: + LOG.exception("Connect hdc server error: %s" % str(exception), + exc_info=False) + raise exception + + if sock is None: + raise HdcError("Cannot connect hdc server!") + + if timeout is not None: + sock.setblocking(False) + sock.settimeout(timeout / 1000) + + return sock + + +class DeviceConnector(object): + __instance = None + __init_flag = False + + def __init__(self, host=None, port=None, usb_type=None): + if DeviceConnector.__init_flag: + return + self.device_listeners = [] + self.device_monitor = None + self.monitor_lock = threading.Condition() + self.host = host if host else "127.0.0.1" + self.usb_type = usb_type + connector_name = UsbConst.connector + AdbHelper.CONNECTOR_NAME = connector_name + if port: + self.port = int(port) + elif usb_type == DeviceConnectorType.hdc: + self.port = int(os.getenv("HDC_SERVER_PORT", DEFAULT_PORT)) + else: + self.port = int(os.getenv(UsbConst.server_port, DEFAULT_PORT)) + + def start(self): + self.device_monitor = AdbMonitor.get_instance( + self.host, self.port, device_connector=self) + self.device_monitor.start() + + def terminate(self): + if self.device_monitor: + self.device_monitor.stop() + self.device_monitor = None + + def add_device_change_listener(self, device_change_listener): + self.device_listeners.append(device_change_listener) + + def remove_device_change_listener(self, device_change_listener): + if device_change_listener in self.device_listeners: + self.device_listeners.remove(device_change_listener) + + def device_connected(self, device): + LOG.debug("DeviceConnector device connected:host %s, port %s, " + "device sn %s " % (self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_connected(device) + + def device_disconnected(self, device): + LOG.debug("DeviceConnector device disconnected:host %s, port %s, " + "device sn %s" % (self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_disconnected(device) + + def device_changed(self, device): + LOG.debug("DeviceConnector device changed:host %s, port %s, " + "device sn %s" % (self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_changed(device) + + +class CollectingOutputReceiver(IShellReceiver): + def __init__(self): + self.output = "" + + def __read__(self, output): + self.output = "%s%s" % (self.output, output) + + def __error__(self, message): + pass + + def __done__(self, result_code="", message=""): + pass + + +class DisplayOutputReceiver(IShellReceiver): + def __init__(self): + self.output = "" + self.unfinished_line = "" + + def _process_output(self, output, end_mark="\n"): + content = output + if self.unfinished_line: + content = "".join((self.unfinished_line, content)) + self.unfinished_line = "" + lines = content.split(end_mark) + if content.endswith(end_mark): + # get rid of the tail element of this list contains empty str + return lines[:-1] + else: + self.unfinished_line = lines[-1] + # not return the tail element of this list contains unfinished str, + # so we set position -1 + return lines[:-1] + + def __read__(self, output): + self.output = "%s%s" % (self.output, output) + lines = self._process_output(output) + for line in lines: + line = line.strip() + if line: + LOG.info(line) + + def __error__(self, message): + pass + + def __done__(self, result_code="", message=""): + pass + + +def process_command_ret(ret, receiver): + try: + if ret != "" and receiver: + receiver.__read__(ret) + receiver.__done__() + except Exception as error: + LOG.exception("Error generating log report.", exc_info=False) + raise ReportException() from error + + if ret != "" and not receiver: + lines = ret.split("\n") + for line in lines: + line = line.strip() + if line: + LOG.debug(line) diff --git a/xdevice/plugins/aosp/managers/__init__.py b/xdevice/plugins/aosp/managers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevice/plugins/aosp/managers/manager_device.py b/xdevice/plugins/aosp/managers/manager_device.py new file mode 100644 index 0000000..e4165d6 --- /dev/null +++ b/xdevice/plugins/aosp/managers/manager_device.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +# coding=utf-8 +import shutil +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import threading + +from xdevice import UserConfigManager +from xdevice import ManagerType +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import IDeviceManager +from xdevice import IFilter +from xdevice import platform_logger +from xdevice import ParamError +from xdevice import ConfigConst +from xdevice import HdcCommandRejectedException +from xdevice import DeviceEvent +from xdevice import TestDeviceState +from xdevice import DeviceState +from xdevice import handle_allocation_event +from xdevice import DeviceAllocationState +from xdevice import DeviceStateMonitor +from xdevice import convert_serial +from xdevice import DeviceNode +from xdevice import DeviceSelector +from xdevice import DeviceConnectorType + +from aosp.environment.dmlib import DeviceConnector +from aosp.constants import UsbConst + +__all__ = ["ManagerAospDevice"] + +LOG = platform_logger("ManagerAospDevice") + + +@Plugin(type=Plugin.MANAGER, id=ManagerType.aosp_device) +class ManagerAospDevice(IDeviceManager, IFilter): + """ + Class representing device manager + managing the set of available devices for testing + """ + + def __init__(self): + self.devices_list = [] + self.global_device_filter = None + self.lock_con = threading.Condition() + self.list_con = threading.Condition() + self.device_connector = None + self.managed_device_listener = None + self.support_labels = ["phone", "watch", "car", "tv", "tablet", "ivi"] + self.support_types = ["device_aosp", "device"] + self.wait_times = 0 + + def init_environment(self, environment="", user_config_file=""): + self._start_device_monitor(environment, user_config_file) + + def env_stop(self): + self._stop_device_monitor() + + def _start_device_monitor(self, environment="", user_config_file=""): + self.managed_device_listener = ManagedDeviceListener(self) + device = UserConfigManager( + config_file=user_config_file, env=environment).get_aosp_device( + "environment/device") + if device: + try: + self.device_connector = DeviceConnector(device.get("ip"), + device.get("port"), + device.get("usb_type")) + self.global_device_filter = UserConfigManager( + config_file=user_config_file, env=environment).get_sn_list( + device.get("sn")) + self.device_connector.add_device_change_listener( + self.managed_device_listener) + self.device_connector.start() + except (ParamError, FileNotFoundError) as error: + self.env_stop() + LOG.debug("Start {} error: {}".format( + device.get("usb_type"), error)) + self.device_connector = DeviceConnector( + device.get("ip"), device.get("port"), + UsbConst.connector_type) + self.device_connector.add_device_change_listener( + self.managed_device_listener) + self.device_connector.start() + else: + raise ParamError("Manager device is not supported, please " + "check config user_config.xml", error_no="00108") + + def _stop_device_monitor(self): + self.device_connector.remove_device_change_listener( + self.managed_device_listener) + self.device_connector.terminate() + + def find(self, idevice): + LOG.debug("Find: apply list con lock") + self.list_con.acquire() + try: + for device in self.devices_list: + if device.device_sn == idevice.device_sn and \ + device.device_os_type == idevice.device_os_type: + return device + finally: + LOG.debug("Find: release list con lock") + self.list_con.release() + + def apply_device(self, device_option, timeout=3): + + LOG.debug("Apply device: apply lock con lock") + self.lock_con.acquire() + try: + device = self.allocate_device_option(device_option) + if device: + return device + LOG.debug("Wait for available device founded") + self.wait_times += 3 + if self.wait_times > timeout: + self.lock_con.wait(timeout) + else: + self.lock_con.wait(self.wait_times) + LOG.debug("Wait for available device founded") + return self.allocate_device_option(device_option) + finally: + LOG.debug("Apply device: release lock con lock") + self.lock_con.release() + + def allocate_device_option(self, device_option): + """ + Request a device for testing that meets certain criteria. + """ + + LOG.debug("Allocate device option: apply list con lock") + if not self.list_con.acquire(timeout=5): + LOG.debug("Allocate device option: list con wait timeout") + return None + try: + allocated_device = None + LOG.debug("Require device label is: %s" % device_option.label) + for device in self.devices_list: + if device_option.matches(device): + self.handle_device_event(device, + DeviceEvent.ALLOCATE_REQUEST) + LOG.debug("Allocate device sn: {}, type: {}".format( + device.__get_serial__(), device.__class__)) + return device + return allocated_device + + finally: + LOG.debug("Allocate device option: release list con lock") + self.list_con.release() + + def release_device(self, device): + LOG.debug("Release device: apply list con lock") + self.list_con.acquire() + try: + if device.test_device_state == TestDeviceState.ONLINE: + self.handle_device_event(device, DeviceEvent.FREE_AVAILABLE) + else: + self.handle_device_event(device, DeviceEvent.FREE_UNAVAILABLE) + + device.device_id = None + + LOG.debug("Free device sn: {}, type: {}".format( + device.__get_serial__(), device.__class__.__name__)) + + finally: + LOG.debug("Release_device: release list con lock") + self.list_con.release() + + def lock_device(self, device): + LOG.debug("Apply device: apply list con lock") + self.list_con.acquire() + try: + if device.test_device_state == TestDeviceState.ONLINE: + self.handle_device_event(device, DeviceEvent.ALLOCATE_REQUEST) + LOG.debug("Lock device sn: {}, type: {}".format(device.__get_serial__(), device.__class__.__name__)) + finally: + LOG.debug("Lock_device: release list con lock") + self.list_con.release() + + def reset_device(self, device): + if device and hasattr(device, "reset"): + device.reset() + + def find_device(self, device_sn, device_os_type): + for device in self.devices_list: + if device.device_sn == device_sn and \ + device.device_os_type == device_os_type: + return device + + def append_device_by_sort(self, device_instance): + if (not self.global_device_filter or + not self.devices_list or + device_instance.device_sn not in self.global_device_filter): + self.devices_list.append(device_instance) + else: + device_dict = dict(zip( + self.global_device_filter, + list(range(1, len(self.global_device_filter) + 1)))) + for index in range(len(self.devices_list)): + if self.devices_list[index].device_sn not in \ + self.global_device_filter: + self.devices_list.insert(index, device_instance) + break + if device_dict[device_instance.device_sn] < \ + device_dict[self.devices_list[index].device_sn]: + self.devices_list.insert(index, device_instance) + break + else: + self.devices_list.append(device_instance) + + def find_or_create(self, idevice): + LOG.debug("Find or create: apply list con lock") + self.list_con.acquire() + try: + device = self.find_device(idevice.device_sn, + idevice.device_os_type) + if device is None: + device = get_plugin( + plugin_type=Plugin.DEVICE, + plugin_id=idevice.device_os_type)[0] + device_instance = device.__class__() + device_instance.__set_serial__(idevice.device_sn) + device_instance.host = idevice.host + device_instance.port = idevice.port + if self.device_connector.usb_type == DeviceConnectorType.hdc and shutil.which("hdc"): + device_instance.usb_type = DeviceConnectorType.hdc + else: + device_instance.usb_type = "usb-adb" + LOG.debug("Create device({}) host is {}, " + "port is {}, device sn is {}, usb type is {}".format + (device_instance, device_instance.host, + device_instance.port, device_instance.device_sn, + device_instance.usb_type)) + device_instance.device_state = idevice.device_state + device_instance.test_device_state = \ + TestDeviceState.get_test_device_state( + device_instance.device_state) + device_instance.device_state_monitor = \ + DeviceStateMonitor(device_instance) + if idevice.device_state == DeviceState.ONLINE or \ + idevice.device_state == DeviceState.CONNECTED: + device_instance.get_device_type() + self.append_device_by_sort(device_instance) + device = device_instance + else: + LOG.debug("Find device({}), host is {}, " + "port is {}, device sn is {}, usb type is {}".format + (device, device.host, device.port, device.device_sn, + device.usb_type)) + return device + except HdcCommandRejectedException as hcr_error: + LOG.debug("{} occurs error. Reason:{}".format + (idevice.device_sn, hcr_error)) + finally: + LOG.debug("Find or create: release list con lock") + self.list_con.release() + + def remove(self, idevice): + LOG.debug("Remove: apply list con lock") + self.list_con.acquire() + try: + self.devices_list.remove(idevice) + finally: + LOG.debug("Remove: release list con lock") + self.list_con.release() + + def handle_device_event(self, device, event): + state_changed = None + old_state = device.device_allocation_state + new_state = handle_allocation_event(old_state, event) + + if new_state == DeviceAllocationState.checking_availability: + if self.global_device_filter and \ + device.device_sn not in self.global_device_filter: + event = DeviceEvent.AVAILABLE_CHECK_IGNORED + else: + event = DeviceEvent.AVAILABLE_CHECK_PASSED + new_state = handle_allocation_event(new_state, event) + + if old_state != new_state: + state_changed = True + device.device_allocation_state = new_state + + if state_changed is True and \ + new_state == DeviceAllocationState.available: + # notify_device_state_change + LOG.debug("Handle device event apply lock con") + self.lock_con.acquire() + LOG.debug("Find available device") + self.lock_con.notify_all() + LOG.debug("Handle device event release lock con") + self.lock_con.release() + + if device.device_allocation_state == \ + DeviceAllocationState.unknown: + self.remove(device) + return + + def launch_emulator(self): + pass + + def kill_emulator(self): + pass + + def list_devices(self): + self.device_connector.monitor_lock.acquire(1) + print("devices:") + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}".format( + "Serial", "OsType", "State", "Allocation", "Product", "host", + "port")) + for device in self.devices_list: + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}".format( + convert_serial(device.device_sn), device.device_os_type, + device.test_device_state.value, + device.device_allocation_state, + device.label if device.label else 'None', + device.host, device.port)) + self.device_connector.monitor_lock.release() + + def __filter_selector__(self, selector): + if isinstance(selector, DeviceSelector): + return True + return False + + def __filter_xml_node__(self, node): + if isinstance(node, DeviceNode): + if UsbConst.connector_type in node.get_connectors(): + return True + return False + + +class ManagedDeviceListener(object): + """ + A class to listen for and act on device presence updates from ddmlib + """ + + def __init__(self, manager): + self.manager = manager + + def device_changed(self, idevice): + test_device = self.manager.find_or_create(idevice) + if test_device is None: + return + new_state = TestDeviceState.get_test_device_state(idevice.device_state) + test_device.test_device_state = new_state + if new_state == TestDeviceState.ONLINE: + self.manager.handle_device_event(test_device, + DeviceEvent.STATE_CHANGE_ONLINE) + elif new_state == TestDeviceState.NOT_AVAILABLE: + self.manager.handle_device_event(test_device, + DeviceEvent.STATE_CHANGE_OFFLINE) + test_device.device_state_monitor.set_state( + test_device.test_device_state) + LOG.debug("Device changed to {}: {} {} {} {}".format( + new_state, convert_serial(idevice.device_sn), + idevice.device_os_type, idevice.host, idevice.port)) + + def device_connected(self, idevice): + test_device = self.manager.find_or_create(idevice) + if test_device is None: + return + + new_state = TestDeviceState.get_test_device_state(idevice.device_state) + test_device.test_device_state = new_state + if test_device.test_device_state == TestDeviceState.ONLINE: + self.manager.handle_device_event(test_device, + DeviceEvent.CONNECTED_ONLINE) + elif new_state == TestDeviceState.NOT_AVAILABLE: + self.manager.handle_device_event(test_device, + DeviceEvent.CONNECTED_OFFLINE) + test_device.device_state_monitor.set_state( + test_device.test_device_state) + LOG.debug("Device connected: {} {} {} {}, state: {}".format( + convert_serial(idevice.device_sn), idevice.device_os_type, + idevice.host, idevice.port, test_device.test_device_state)) + LOG.debug("Set device {} {} to true".format( + convert_serial(idevice.device_sn), ConfigConst.recover_state)) + test_device.set_recover_state(True) + + def device_disconnected(self, disconnected_device): + test_device = self.manager.find(disconnected_device) + if test_device is not None: + test_device.test_device_state = TestDeviceState.NOT_AVAILABLE + self.manager.handle_device_event(test_device, + DeviceEvent.DISCONNECTED) + test_device.device_state_monitor.set_state( + TestDeviceState.NOT_AVAILABLE) + LOG.debug("Device disconnected: {} {} {} {}".format( + convert_serial(disconnected_device.device_sn), + disconnected_device.device_os_type, + disconnected_device.host, disconnected_device.port)) diff --git a/xdevice/plugins/aosp/setup.py b/xdevice/plugins/aosp/setup.py new file mode 100644 index 0000000..4ce1b66 --- /dev/null +++ b/xdevice/plugins/aosp/setup.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from setuptools import setup + +INSTALL_REQUIRES = ["xdevice"] + + +def main(): + setup(name='xdevice-aosp', + description='plugin for aosp', + url='', + package_dir={'aosp': ''}, + packages=['aosp', + 'aosp.environment', + 'aosp.managers', + 'aosp.testkit' + ], + entry_points={ + 'device': [ + 'device=aosp.environment.device' + ], + 'manager': [ + 'manager=aosp.managers.manager_device' + ], + 'testkit': [ + 'kit=aosp.testkit.kit' + ] + }, + zip_safe=False, + install_requires=INSTALL_REQUIRES, + ) + + +if __name__ == "__main__": + main() diff --git a/xdevice/plugins/aosp/testkit/__init__.py b/xdevice/plugins/aosp/testkit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevice/plugins/aosp/testkit/kit.py b/xdevice/plugins/aosp/testkit/kit.py new file mode 100644 index 0000000..25d36d7 --- /dev/null +++ b/xdevice/plugins/aosp/testkit/kit.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +from dataclasses import dataclass + +from xdevice import AppInstallError +from xdevice import ITestKit +from xdevice import Plugin +from xdevice import get_config_value +from xdevice import get_file_absolute_path +from xdevice import platform_logger +from xdevice import DeviceTestType +from xdevice import get_install_args +from xdevice import get_app_name_by_tool + +from aosp.constants import CKit +from aosp import RES_PATH + +LOG = platform_logger("Kit") + +__all__ = ["ApkInstallKit"] + + +@dataclass +class Props: + trying_remove_maximum_times = 3 + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.install) +class ApkInstallKit(ITestKit): + def __init__(self): + self.app_list = "" + self.app_list_name = "" + self.is_clean = "" + self.alt_dir = "" + self.ex_args = "" + self.installed_app = set() + self.paths = "" + self.is_pri_app = "" + self.env_index_list = None + + def __check_config__(self, options): + self.app_list = get_config_value('test-file-name', options) + self.app_list_name = get_config_value('test-file-packName', options) + self.is_clean = get_config_value('cleanup-apks', options, False) + self.alt_dir = get_config_value('alt-dir', options, False) + if self.alt_dir and self.alt_dir.startswith("resource/"): + self.alt_dir = self.alt_dir[len("resource/"):] + self.ex_args = get_config_value('install-arg', options, False) + self.installed_app = set() + self.paths = get_config_value('paths', options) + self.is_pri_app = get_config_value('install-as-privapp', options, False, default=False) + self.env_index_list = get_config_value('env-index', options) + + def __setup__(self, device, **kwargs): + request = kwargs.get("request", None) + del kwargs + LOG.debug("ApkInstallKit setup, device:{}".format(device.device_sn)) + if len(self.app_list) == 0: + LOG.info("No app to install, skipping!") + return + # to disable app install alert + for app in self.app_list: + if self.alt_dir: + app_file = get_file_absolute_path(app, self.paths, + self.alt_dir) + else: + app_file = get_file_absolute_path(app, self.paths) + if app_file is None: + LOG.error("The app file {} does not exist".format(app)) + continue + result = device.install_package(app_file, get_install_args(device, app_file, self.ex_args)) + if "Success" not in result and "successfully" not in result: + raise AppInstallError( + "Failed to install {} on {}. Reason:{}".format + (app_file, device.__get_serial__(), result)) + self.installed_app.add(app_file) + # arkuix跨平台测试需要一个入口文件来启动,通过查找app包名获取文件路径 + if request.root.source.test_type == DeviceTestType.arkuix_jsunit_test: + for app in self.installed_app: + app_name = get_app_name_by_tool(app, [RES_PATH]) + if app_name == request.config.bundle_name: + request.config.testargs.update({"app_file": app}) + break + + def __teardown__(self, device): + LOG.debug("ApkInstallKit teardown: device:{}".format(device.device_sn)) + if self.is_clean and str(self.is_clean).lower() == "true": + if self.app_list_name and len(self.app_list_name) > 0: + for app_name in self.app_list_name: + result = device.uninstall_package(app_name) + if result and (result.startwith("Success") or "successfully" in result): + LOG.debug("uninstalling package Success. result is {}".format(result)) + else: + LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) + else: + for app in self.installed_app: + app_name = get_app_name_by_tool(app, [RES_PATH]) + if app_name: + result = device.uninstall_package(app_name) + if result and (result.startwith("Success") or "successfully" in result): + LOG.debug("uninstalling package Success. result is {}".format(result)) + else: + LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) + else: + LOG.warning("Can't find app name for {}".format(app)) diff --git a/xdevice/plugins/ios/__init__.py b/xdevice/plugins/ios/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevice/plugins/ios/constants.py b/xdevice/plugins/ios/constants.py new file mode 100644 index 0000000..19f80f9 --- /dev/null +++ b/xdevice/plugins/ios/constants.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from dataclasses import dataclass + +__all__ = ["UsbConst", "CKit"] + + +@dataclass +class UsbConst: + connector = "ios" + connector_type = "usb-ios" + connector_ace = "ace" + connector_ios_deploy = "ios-deploy" + connector_idevice_id = "idevice_id" + connector_libimobiledevice = "libimobiledevice" + connector_idevicesyslog = "idevicesyslog" + connector_idevicecrashreport = "idevicecrashreport" + connector_ideviceinfo = "ideviceinfo" + connector_idevicediagnostics = "idevicediagnostics" + + +@dataclass +class CKit: + ios_app_install = "IosAppInstallKit" + ios_push = "IosPushKit" + ios_shell = "IosShellKit" diff --git a/xdevice/plugins/ios/environment/__init__.py b/xdevice/plugins/ios/environment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevice/plugins/ios/environment/device.py b/xdevice/plugins/ios/environment/device.py new file mode 100644 index 0000000..e326a32 --- /dev/null +++ b/xdevice/plugins/ios/environment/device.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import pathlib +import os +import threading + +from xdevice import DeviceOsType +from xdevice import AppInstallError +from xdevice import TestDeviceState +from xdevice import ProductForm +from xdevice import ReportException +from xdevice import IDevice +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import exec_cmd +from xdevice import ConfigConst +from xdevice import HdcError +from xdevice import DeviceAllocationState +from xdevice import convert_serial +from xdevice import start_standing_subprocess +from xdevice import stop_standing_subprocess +from xdevice import Platform + +from ios.environment.dmlib import IosHelper +from ios.constants.dmlib import UsbConst + +__all__ = ["DeviceIos"] +TIMEOUT = 300 * 1000 +RETRY_ATTEMPTS = 2 +DEFAULT_UNAVAILABLE_TIMEOUT = 20 * 1000 + +LOG = platform_logger("DeviceIos") + + +def perform_device_action(func): + def callback_to_outer(device, msg): + # callback to decc ui + if getattr(device, "callback_method", None): + device.callback_method(msg) + + def device_action(self, *args, **kwargs): + if not self.get_recover_state(): + LOG.debug("Device {} {} is false".format(self.device_sn, + ConfigConst.recover_state)) + return None + # avoid infinite recursion, such as device reboot + abort_on_exception = bool(kwargs.get("abort_on_exception", False)) + if abort_on_exception: + result = func(self, *args, **kwargs) + return result + + tmp = int(kwargs.get("retry", RETRY_ATTEMPTS)) + retry = tmp + 1 if tmp > 0 else 1 + exception = None + for _ in range(retry): + try: + result = func(self, *args, **kwargs) + return result + except ReportException as error: + self.log.exception("Generate report error!", exc_info=False) + exception = error + except Exception as error: + self.log.error("error type: {}, error: {}".format + (error.__class__.__name__, error)) + callback_to_outer(self, "error:{}, prepare to recover".format(error)) + if not self.recover_device(): + LOG.debug("Set device {} {} false".format( + self.device_sn, ConfigConst.recover_state)) + self.set_recover_state(False) + callback_to_outer(self, "recover failed") + raise error + exception = error + callback_to_outer(self, "recover success") + raise exception + + return device_action + + +@Plugin(type=Plugin.DEVICE, id=DeviceOsType.ios) +class DeviceIos(IDevice): + """ + Class representing a device. + + Each object of this class represents one device in xDevice, + + Attributes: + device_sn: A string that's the serial number of the device. + """ + + device_sn = None + usb_type = UsbConst.connector_type + test_device_state = None + device_state_monitor = None + device_log_proc = None + label = None + host = None + port = None + device_id = None + _device_report_path = None + log = platform_logger("DeviceIos") + reboot_timeout = 2 * 60 * 1000 + device_os_type = DeviceOsType.ios + device_allocation_state = DeviceAllocationState.available + test_platform = Platform.ios + _device_log_collector = None + model_dict = { + 'default': ProductForm.phone, + 'tablet': ProductForm.tablet + } + + def __init__(self): + self.extend_value = {} + self.device_lock = threading.RLock() + self.forward_ports = [] + self.proxy_listener = None + + def __eq__(self, other): + return self.device_sn == other.__get_serial__() and \ + self.device_os_type == other.device_os_type + + def __set_serial__(self, device_sn=""): + self.device_sn = device_sn + return self.device_sn + + def set_state(self, state): + self.test_device_state = state + + def __get_serial__(self): + return self.device_sn + + def get_device_type(self): + model = "default" + product_type = exec_cmd([UsbConst.connector_ideviceinfo, "-u", self.device_sn, "-k", "ProductType"]) + if "iPhone" in product_type: + model = "default" + elif "iPad" in product_type: + model = "tablet" + self.label = self.model_dict.get(model, None) + + def get(self, key=None, default=None): + if not key: + return default + value = getattr(self, key, None) + if value: + return value + else: + return self.extend_value.get(key, default) + + def set_device_report_path(self, path): + self._device_report_path = path + + def get_device_report_path(self): + return self._device_report_path + + def set_recover_state(self, state): + with self.device_lock: + setattr(self, ConfigConst.recover_state, state) + if not state: + self.test_device_state = TestDeviceState.NOT_AVAILABLE + self.device_allocation_state = DeviceAllocationState.unavailable + + def get_recover_state(self, default_state=True): + with self.device_lock: + state = getattr(self, ConfigConst.recover_state, default_state) + return state + + def recover_device(self): + if not self.get_recover_state(): + LOG.debug("Device {} {} is false, cannot recover device".format( + self.device_sn, ConfigConst.recover_state)) + return False + + LOG.debug("Wait device {} to recover".format(self.device_sn)) + result = self.device_state_monitor.wait_for_device_available() + if result: + self.device_log_collector.restart_catch_device_log() + return result + + @perform_device_action + def execute_shell_command(self, command, timeout=TIMEOUT, **kwargs): + return IosHelper.execute_shell_command(self, command, timeout=timeout, **kwargs) + + @perform_device_action + def install_package(self, package_path, ex_args): + if package_path is None: + raise AppInstallError( + "install package: package path cannot be None!") + return IosHelper.install_package(self, package_path, ex_args) + + @perform_device_action + def uninstall_package(self, package_name): + return IosHelper.uninstall_package(self, package_name) + + @perform_device_action + def push_file(self, local, remote, package_name=None, **kwargs): + """ + Push a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + if local is None: + raise HdcError("XDevice Local path cannot be None!") + command = [] + if package_name: + command = ["--bundle_id", package_name, "--upload", local, "--to", remote] + else: + command = ["-f", "--upload", local, "--to", remote] + is_create = kwargs.get("is_create", False) + timeout = kwargs.get("timeout", TIMEOUT) + return IosHelper.push_file(self, command, is_create=is_create, timeout=timeout) + + @perform_device_action + def pull_file(self, remote, local, package_name=None, **kwargs): + """ + Pull a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + command = [] + + if package_name: + command.extend(["--bundle", package_name]) + else: + command.append("-f") + if pathlib.Path(remote).is_dir(): + command.extend(["--download={}".format(remote), "--to", local]) + else: + command.extend(["-w{}".format(remote), "--to", local]) + + is_create = kwargs.get("is_create", False) + timeout = kwargs.get("timeout", TIMEOUT) + return IosHelper.push_file(self, command, is_create=is_create, timeout=timeout) + + def take_picture(self, name): + pass + + def wait_for_device_not_available(self, wait_time): + return self.device_state_monitor.wait_for_device_not_available( + wait_time) + + def _wait_for_device_online(self, wait_time=None): + return self.device_state_monitor.wait_for_device_online(wait_time) + + def _do_reboot(self): + IosHelper.reboot(self) + if not self.wait_for_device_not_available(DEFAULT_UNAVAILABLE_TIMEOUT): + LOG.error( + "Did not detect device {} becoming unavailable after reboot".format(convert_serial(self.device_sn))) + + def _reboot_until_online(self): + self._do_reboot() + self._wait_for_device_online() + + def reboot(self): + self._reboot_until_online() + self.device_state_monitor.wait_for_device_available(self.reboot_timeout) + self.device_log_collector.restart_catch_device_log() + + @property + def device_log_collector(self): + if self._device_log_collector is None: + self._device_log_collector = DeviceLogCollector(self) + return self._device_log_collector + + +class DeviceLogCollector: + log_file_address = [] + device = None + restart_log_proc = [] + + def __init__(self, device): + self.device = device + + def restart_catch_device_log(self): + from xdevice import FilePermission + for index, _ in enumerate(self.log_file_address): + device_log_open = os.open(self.log_file_address[index], os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + with os.fdopen(device_log_open, "a") as device_log_file_pipe: + log_proc = self.start_catch_device_log(log_file_pipe=device_log_file_pipe) + self.restart_log_proc.append(log_proc) + + def stop_restart_catch_device_log(self): + # when device free stop restart log proc + for _, proc in enumerate(self.restart_proc): + self.stop_catch_device_log(proc) + self.restart_log_proc.clear() + self.log_file_address.clear() + + def start_catch_device_log(self, log_file_pipe=None): + """ + Starts ios log for each device in separate subprocesses and save + the logs in files. + """ + device_log_proc = None + if log_file_pipe: + device_log_proc = start_standing_subprocess(self._syslog_cmd(), log_file_pipe) + return device_log_proc + + def stop_catch_device_log(self, proc): + """ + Stops all ios log subprocesses. + """ + if proc: + stop_standing_subprocess(proc) + self.device.log.debug("Stop catch device log.") + + def _syslog_cmd(self): + cmd = [UsbConst.connector_idevicesyslog, "-d", self.device.device_sn] + return cmd + + def _get_log(self, log_cmd): + pass + + def start_get_crash_log(self, file_name, **kwargs): + IosHelper.start_get_crash_log(self.device, file_name) + + def clear_crash_log(self): + IosHelper.clear_crash_log(self.device) + + def add_log_address(self, log_file_address): + # record to restart catch log when reboot device + if log_file_address: + self.log_file_address.append(log_file_address) + + def remove_log_address(self, log_file_address): + if log_file_address and log_file_address in self.log_file_address: + self.log_file_address.remove(log_file_address) diff --git a/xdevice/plugins/ios/environment/dmlib.py b/xdevice/plugins/ios/environment/dmlib.py new file mode 100644 index 0000000..4545ab3 --- /dev/null +++ b/xdevice/plugins/ios/environment/dmlib.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import threading +import time +import shutil + +from xdevice import DeviceOsType +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import IShellReceiver +from xdevice import exec_cmd +from xdevice import DeviceState + +from ios.constants import UsbConst + +DEFAULT_ENCODING = "ISO-8859-1" + +INSTALL_TIMEOUT = 2 * 60 * 1000 +DEFAULT_TIMEOUT = 40 * 1000 + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 27015 +LOG = platform_logger("Ios") + + +class IosMonitor: + """ + A Device monitor. + This monitor connects to the Device Connector, gets device and + debuggable process information from it. + """ + MONITOR_MAP = {} + + def __init__(self, host="127.0.0.1", port=None, device_connector=None): + self.channel = dict() + self.channel.setdefault("host", host) + self.channel.setdefault("port", port) + self.is_stop = False + self.monitoring = False + self.server = device_connector + self.devices = [] + self.changed = True + self.last_msg_len = 0 + + @staticmethod + def get_instance(host, port=None, device_connector=None): + if host not in IosMonitor.MONITOR_MAP: + monitor = IosMonitor(host, port, device_connector) + IosMonitor.MONITOR_MAP[host] = monitor + LOG.debug("IosMonitor map add host {}, map is {}".format + (host, IosMonitor.MONITOR_MAP)) + + return IosMonitor.MONITOR_MAP[host] + + def start(self): + """ + Starts the monitoring. + """ + if not shutil.which(UsbConst.connector_ace) or not shutil.which( + UsbConst.connector_ios_deploy) or not shutil.which(UsbConst.connector_idevice_id): + return + try: + LOG.debug("IosMonitor usb type is {}".format(self.server.usb_type)) + server_thread = threading.Thread(target=self.loop_monitor, + name="IosMonitor", args=()) + server_thread.daemon = True + server_thread.start() + except FileNotFoundError as _: + LOG.error("IosMonitor can't find connector, init device " + "environment failed!") + + def stop(self): + """ + Stops the monitoring. + """ + for host in IosMonitor.MONITOR_MAP: + LOG.debug("IosMonitor stop host {}".format(host)) + monitor = IosMonitor.MONITOR_MAP[host] + try: + monitor.is_stop = True + except Exception as _: + LOG.error("IosMonitor close socket exception") + IosMonitor.MONITOR_MAP.clear() + LOG.debug("IosMonitor {} monitor stop!".format(IosMonitor.CONNECTOR_NAME)) + LOG.debug("IosMonitor map is {}".format(IosMonitor.MONITOR_MAP)) + + def loop_monitor(self): + """ + Monitors the devices. This connects to the Debug Bridge + """ + LOG.debug("current connector name is {}".format(IosMonitor.CONNECTOR_NAME)) + while not self.is_stop: + self.list_targets() + time.sleep(1) + + def list_targets(self): + data = exec_cmd([UsbConst.connector_idevice_id, "-l"]) + if self.last_msg_len != len(data): + self.last_msg_len = len(data) + self.changed = True + else: + self.changed = False + self.process_incoming_target_data(data) + + def process_incoming_target_data(self, data): + local_array_list = [] + if data: + lines = data.split('\n') + for line in lines: + items = line.strip().split('\t') + # Example: udid + device_instance = self._get_device_instance(items, DeviceOsType.ios) + local_array_list.append(device_instance) + self.update_devices(local_array_list) + + def update_devices(self, param_array_list): + devices = [item for item in self.devices] + devices.reverse() + for local_device1 in devices: + k = 0 + for local_device2 in param_array_list: + if local_device1.device_sn == local_device2.device_sn and \ + local_device1.device_os_type == \ + local_device2.device_os_type: + k = 1 + if local_device1.device_state != \ + local_device2.device_state: + local_device1.device_state = local_device2.device_state + self.server.device_changed(local_device1) + param_array_list.remove(local_device2) + break + + if k == 0: + self.devices.remove(local_device1) + self.server.device_disconnected(local_device1) + for local_device in param_array_list: + self.devices.append(local_device) + self.server.device_connected(local_device) + + def _get_device_instance(self, items, os_type): + device = get_plugin(plugin_type=Plugin.DEVICE, plugin_id=os_type)[0] + device_instance = device.__class__() + device_instance.__set_serial__(items[0]) + device_instance.host = self.channel.get("host") + device_instance.port = self.channel.get("port") + if self.changed: + LOG.debug("Dmlib get device instance {} {} {}".format + (device_instance.device_sn, + device_instance.host, device_instance.port)) + device_instance.device_state = DeviceState.get_state(items[3]) + return device_instance + + +class IosHelper: + CONNECTOR_NAME = "" + + @staticmethod + def push_file(device, command, is_create=False, timeout=DEFAULT_TIMEOUT): + return exec_cmd([UsbConst.connector_ios_deploy, "-i", device.device_sn] + command) + + @staticmethod + def pull_file(device, command, is_create=False, timeout=DEFAULT_TIMEOUT): + return exec_cmd([UsbConst.connector_ios_deploy, "-i", device.device_sn] + command) + + @staticmethod + def install_package(device, package_file_path, ex_args): + command = [UsbConst.connector_ios_deploy, "--id", device.device_sn, "--bundle", package_file_path] + if ex_args: + command.append(["--args", "\"{}\"".format(ex_args)]) + return exec_cmd(command) + + @staticmethod + def uninstall_package(device, package_name): + return exec_cmd([UsbConst.connector_ios_deploy, "--id", device.device_sn, "--uninstall_only", "--bundle_id", package_name]) + + @staticmethod + def reboot(device): + return exec_cmd([UsbConst.connector_idevicediagnostics, "-u", device.device_sn, "restart"]) + + @staticmethod + def clear_crash_log(device): + tmp_path = os.path.join(device.get_device_report_path(), "crash_tmp") + if not os.path.exists(tmp_path): + os.mkdir(tmp_path) + exec_cmd([UsbConst.connector_idevicecrashreport, "-u", device.device_sn, tmp_path]) + shutil.rmtree(tmp_path) + + @staticmethod + def start_get_crash_log(device, file_name): + crash_path = os.path.join(os.path.join(device.get_device_report_path(), "log"), file_name) + if not os.path.exists(crash_path): + os.mkdir(crash_path) + exec_cmd([UsbConst.connector_idevicecrashreport, "-u", device.device_sn, crash_path]) + + @staticmethod + def execute_shell_command(device, command, timeout=DEFAULT_TIMEOUT, **kwargs): + if isinstance(command, list): + return exec_cmd([UsbConst.connector_ios_deploy, "--id", device.device_sn] + command) + elif isinstance(command, str): + return exec_cmd([UsbConst.connector_ios_deploy, "--id", device.device_sn] + command.split(" ")) + else: + return False + + +class DeviceConnector(object): + __instance = None + __init_flag = False + + def __init__(self, host=None, port=None, usb_type=None): + if DeviceConnector.__init_flag: + return + self.device_listeners = [] + self.device_monitor = None + self.monitor_lock = threading.Condition() + self.host = host if host else "127.0.0.1" + self.usb_type = usb_type + connector_name = "ios" + IosHelper.CONNECTOR_NAME = connector_name + if port: + self.port = int(port) + else: + self.port = DEFAULT_PORT + + def start(self): + self.device_monitor = IosMonitor.get_instance( + self.host, self.port, device_connector=self) + self.device_monitor.start() + + def terminate(self): + if self.device_monitor: + self.device_monitor.stop() + self.device_monitor = None + + def add_device_change_listener(self, device_change_listener): + self.device_listeners.append(device_change_listener) + + def remove_device_change_listener(self, device_change_listener): + if device_change_listener in self.device_listeners: + self.device_listeners.remove(device_change_listener) + + def device_connected(self, device): + LOG.debug("DeviceConnector device connected:host {}, port {}, " + "device sn {} ".format(self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_connected(device) + + def device_disconnected(self, device): + LOG.debug("DeviceConnector device disconnected:host {}, port {}, " + "device sn {}".format(self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_disconnected(device) + + def device_changed(self, device): + LOG.debug("DeviceConnector device changed:host {}, port {}, " + "device sn {}".format(self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_changed(device) + + +class CollectingOutputReceiver(IShellReceiver): + def __init__(self): + self.output = "" + + def __read__(self, output): + self.output = "%s%s" % (self.output, output) + + def __error__(self, message): + pass + + def __done__(self, result_code="", message=""): + pass + + +class DisplayOutputReceiver(IShellReceiver): + def __init__(self): + self.output = "" + self.unfinished_line = "" + + def _process_output(self, output, end_mark="\n"): + content = output + if self.unfinished_line: + content = "".join((self.unfinished_line, content)) + self.unfinished_line = "" + lines = content.split(end_mark) + if content.endswith(end_mark): + # get rid of the tail element of this list contains empty str + return lines[:-1] + else: + self.unfinished_line = lines[-1] + # not return the tail element of this list contains unfinished str, + # so we set position -1 + return lines[:-1] + + def __read__(self, output): + self.output = "%s%s" % (self.output, output) + lines = self._process_output(output) + for line in lines: + line = line.strip() + if line: + LOG.info(line) + + def __error__(self, message): + pass + + def __done__(self, result_code="", message=""): + pass + + +def process_command_ret(ret, receiver): + try: + if ret != "" and receiver: + receiver.__read__(ret) + receiver.__done__() + except Exception as error: + LOG.exception("Error generating log report.", exc_info=False) + raise error + + if ret != "" and not receiver: + lines = ret.split("\n") + for line in lines: + line = line.strip() + if line: + LOG.debug(line) diff --git a/xdevice/plugins/ios/managers/__init__.py b/xdevice/plugins/ios/managers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevice/plugins/ios/managers/manager_device.py b/xdevice/plugins/ios/managers/manager_device.py new file mode 100644 index 0000000..04f6a62 --- /dev/null +++ b/xdevice/plugins/ios/managers/manager_device.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import threading + +from xdevice import UserConfigManager +from xdevice import ManagerType +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import IDeviceManager +from xdevice import IFilter +from xdevice import platform_logger +from xdevice import ParamError +from xdevice import ConfigConst +from xdevice import HdcCommandRejectedException +from xdevice import DeviceEvent +from xdevice import TestDeviceState +from xdevice import DeviceState +from xdevice import handle_allocation_event +from xdevice import DeviceAllocationState +from xdevice import DeviceStateMonitor +from xdevice import convert_serial +from xdevice import DeviceNode +from xdevice import DeviceSelector + +from ios.environment.dmlib import DeviceConnector +from ios.constants import UsbConst + +__all__ = ["ManagerIosDevice"] + +LOG = platform_logger("ManagerIosDevice") + + +@Plugin(type=Plugin.MANAGER, id=ManagerType.ios_device) +class ManagerIosDevice(IDeviceManager, IFilter): + """ + Class representing device manager + managing the set of available devices for testing + """ + + def __init__(self): + self.devices_list = [] + self.global_device_filter = None + self.lock_con = threading.Condition() + self.list_con = threading.Condition() + self.device_connector = None + self.managed_device_listener = None + self.support_labels = ["phone", "tablet"] + self.support_types = ["device_ios", "device"] + self.wait_times = 0 + + def init_environment(self, environment="", user_config_file=""): + self._start_device_monitor(environment, user_config_file) + + def env_stop(self): + self._stop_device_monitor() + + def _start_device_monitor(self, environment="", user_config_file=""): + self.managed_device_listener = ManagedDeviceListener(self) + device = UserConfigManager( + config_file=user_config_file, env=environment).get_device( + "environment/device") + if device: + try: + self.device_connector = DeviceConnector(device.get("ip"), + device.get("port"), + device.get("usb_type")) + self.global_device_filter = UserConfigManager( + config_file=user_config_file, env=environment).get_sn_list( + device.get("sn")) + self.device_connector.add_device_change_listener( + self.managed_device_listener) + self.device_connector.start() + except (ParamError, FileNotFoundError) as error: + self.env_stop() + LOG.debug("Start {} error: {}".format( + device.get("usb_type"), error)) + self.device_connector = DeviceConnector( + device.get("ip"), device.get("port"), + UsbConst.connector_type) + self.device_connector.add_device_change_listener( + self.managed_device_listener) + self.device_connector.start() + else: + raise ParamError("Manager device is not supported, please " + "check config user_config.xml", error_no="00108") + + def _stop_device_monitor(self): + self.device_connector.remove_device_change_listener( + self.managed_device_listener) + self.device_connector.terminate() + + def find(self, idevice): + LOG.debug("Find: apply list con lock") + self.list_con.acquire() + try: + for device in self.devices_list: + if device.device_sn == idevice.device_sn and \ + device.device_os_type == idevice.device_os_type: + return device + finally: + LOG.debug("Find: release list con lock") + self.list_con.release() + + def apply_device(self, device_option, timeout=3): + + LOG.debug("Apply device: apply lock con lock") + self.lock_con.acquire() + try: + device = self.allocate_device_option(device_option) + if device: + return device + LOG.debug("Wait for available device founded") + self.wait_times += 3 + if self.wait_times > timeout: + self.lock_con.wait(timeout) + else: + self.lock_con.wait(self.wait_times) + LOG.debug("Wait for available device founded") + return self.allocate_device_option(device_option) + finally: + LOG.debug("Apply device: release lock con lock") + self.lock_con.release() + + def allocate_device_option(self, device_option): + """ + Request a device for testing that meets certain criteria. + """ + + LOG.debug("Allocate device option: apply list con lock") + if not self.list_con.acquire(timeout=5): + LOG.debug("Allocate device option: list con wait timeout") + return None + try: + allocated_device = None + LOG.debug("Require device label is: %s" % device_option.label) + for device in self.devices_list: + if device_option.matches(device): + self.handle_device_event(device, + DeviceEvent.ALLOCATE_REQUEST) + LOG.debug("Allocate device sn: {}, type: {}".format( + device.__get_serial__(), device.__class__)) + return device + return allocated_device + + finally: + LOG.debug("Allocate device option: release list con lock") + self.list_con.release() + + def release_device(self, device): + LOG.debug("Release device: apply list con lock") + self.list_con.acquire() + try: + if device.test_device_state == TestDeviceState.ONLINE: + self.handle_device_event(device, DeviceEvent.FREE_AVAILABLE) + else: + self.handle_device_event(device, DeviceEvent.FREE_UNAVAILABLE) + + device.device_id = None + + LOG.debug("Free device sn: {}, type: {}".format( + device.__get_serial__(), device.__class__.__name__)) + + finally: + LOG.debug("Release_device: release list con lock") + self.list_con.release() + + def lock_device(self, device): + LOG.debug("Apply device: apply list con lock") + self.list_con.acquire() + try: + if device.test_device_state == TestDeviceState.ONLINE: + self.handle_device_event(device, DeviceEvent.ALLOCATE_REQUEST) + LOG.debug("Lock device sn: {}, type: {}".format(device.__get_serial__(), device.__class__.__name__)) + finally: + LOG.debug("Lock_device: release list con lock") + self.list_con.release() + + def reset_device(self, device): + if device and hasattr(device, "reset"): + device.reset() + + def find_device(self, device_sn, device_os_type): + for device in self.devices_list: + if device.device_sn == device_sn and \ + device.device_os_type == device_os_type: + return device + + def append_device_by_sort(self, device_instance): + if (not self.global_device_filter or + not self.devices_list or + device_instance.device_sn not in self.global_device_filter): + self.devices_list.append(device_instance) + else: + device_dict = dict(zip( + self.global_device_filter, + list(range(1, len(self.global_device_filter) + 1)))) + for index in range(len(self.devices_list)): + if self.devices_list[index].device_sn not in \ + self.global_device_filter: + self.devices_list.insert(index, device_instance) + break + if device_dict[device_instance.device_sn] < \ + device_dict[self.devices_list[index].device_sn]: + self.devices_list.insert(index, device_instance) + break + else: + self.devices_list.append(device_instance) + + def find_or_create(self, idevice): + LOG.debug("Find or create: apply list con lock") + self.list_con.acquire() + try: + device = self.find_device(idevice.device_sn, + idevice.device_os_type) + if device is None: + device = get_plugin( + plugin_type=Plugin.DEVICE, + plugin_id=idevice.device_os_type)[0] + device_instance = device.__class__() + device_instance.__set_serial__(idevice.device_sn) + device_instance.host = idevice.host + device_instance.port = idevice.port + device_instance.usb_type = UsbConst.connector_type + LOG.debug("Create device({}) host is {}, " + "port is {}, device sn is {}, usb type is {}".format + (device_instance, device_instance.host, + device_instance.port, device_instance.device_sn, + device_instance.usb_type)) + device_instance.device_state = idevice.device_state + device_instance.test_device_state = \ + TestDeviceState.get_test_device_state( + device_instance.device_state) + device_instance.device_state_monitor = \ + DeviceStateMonitor(device_instance) + if idevice.device_state == DeviceState.ONLINE or \ + idevice.device_state == DeviceState.CONNECTED: + device_instance.get_device_type() + self.append_device_by_sort(device_instance) + device = device_instance + else: + LOG.debug("Find device({}), host is {}, " + "port is {}, device sn is {}, usb type is {}".format + (device, device.host, device.port, device.device_sn, + device.usb_type)) + return device + except HdcCommandRejectedException as hcr_error: + LOG.debug("{} occurs error. Reason:{}".format + (idevice.device_sn, hcr_error)) + finally: + LOG.debug("Find or create: release list con lock") + self.list_con.release() + + def remove(self, idevice): + LOG.debug("Remove: apply list con lock") + self.list_con.acquire() + try: + self.devices_list.remove(idevice) + finally: + LOG.debug("Remove: release list con lock") + self.list_con.release() + + def handle_device_event(self, device, event): + state_changed = None + old_state = device.device_allocation_state + new_state = handle_allocation_event(old_state, event) + + if new_state == DeviceAllocationState.checking_availability: + if self.global_device_filter and \ + device.device_sn not in self.global_device_filter: + event = DeviceEvent.AVAILABLE_CHECK_IGNORED + else: + event = DeviceEvent.AVAILABLE_CHECK_PASSED + new_state = handle_allocation_event(new_state, event) + + if old_state != new_state: + state_changed = True + device.device_allocation_state = new_state + + if state_changed is True and \ + new_state == DeviceAllocationState.available: + # notify_device_state_change + LOG.debug("Handle device event apply lock con") + self.lock_con.acquire() + LOG.debug("Find available device") + self.lock_con.notify_all() + LOG.debug("Handle device event release lock con") + self.lock_con.release() + + if device.device_allocation_state == \ + DeviceAllocationState.unknown: + self.remove(device) + return + + def launch_emulator(self): + pass + + def kill_emulator(self): + pass + + def list_devices(self): + self.device_connector.monitor_lock.acquire(1) + print("devices:") + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}".format( + "Serial", "OsType", "State", "Allocation", "Product", "host", + "port")) + for device in self.devices_list: + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}".format( + self.convert_sn(convert_serial(device.device_sn)), device.device_os_type, + device.test_device_state.value, + device.device_allocation_state, + device.label if device.label else 'None', + device.host, device.port)) + self.device_connector.monitor_lock.release() + + @staticmethod + def convert_sn(device_sn): + sn = device_sn.split("*") + return sn[0] + "**" + sn[-1] + + def __filter_selector__(self, selector): + if isinstance(selector, DeviceSelector): + return True + return False + + def __filter_xml_node__(self, node): + if isinstance(node, DeviceNode): + if UsbConst.connector_type in node.get_connectors(): + return True + return False + + +class ManagedDeviceListener(object): + """ + A class to listen for and act on device presence updates from ddmlib + """ + + def __init__(self, manager): + self.manager = manager + + def device_changed(self, idevice): + test_device = self.manager.find_or_create(idevice) + if test_device is None: + return + new_state = TestDeviceState.get_test_device_state(idevice.device_state) + test_device.test_device_state = new_state + if new_state == TestDeviceState.ONLINE: + self.manager.handle_device_event(test_device, + DeviceEvent.STATE_CHANGE_ONLINE) + elif new_state == TestDeviceState.NOT_AVAILABLE: + self.manager.handle_device_event(test_device, + DeviceEvent.STATE_CHANGE_OFFLINE) + test_device.device_state_monitor.set_state( + test_device.test_device_state) + LOG.debug("Device changed to {}: {} {} {} {}".format( + new_state, convert_serial(idevice.device_sn), + idevice.device_os_type, idevice.host, idevice.port)) + + def device_connected(self, idevice): + test_device = self.manager.find_or_create(idevice) + if test_device is None: + return + + new_state = TestDeviceState.get_test_device_state(idevice.device_state) + test_device.test_device_state = new_state + if test_device.test_device_state == TestDeviceState.ONLINE: + self.manager.handle_device_event(test_device, + DeviceEvent.CONNECTED_ONLINE) + elif new_state == TestDeviceState.NOT_AVAILABLE: + self.manager.handle_device_event(test_device, + DeviceEvent.CONNECTED_OFFLINE) + test_device.device_state_monitor.set_state( + test_device.test_device_state) + LOG.debug("Device connected: {} {} {} {}, state: {}".format( + convert_serial(idevice.device_sn), idevice.device_os_type, + idevice.host, idevice.port, test_device.test_device_state)) + LOG.debug("Set device {} {} to true".format( + convert_serial(idevice.device_sn), ConfigConst.recover_state)) + test_device.set_recover_state(True) + + def device_disconnected(self, disconnected_device): + test_device = self.manager.find(disconnected_device) + if test_device is not None: + test_device.test_device_state = TestDeviceState.NOT_AVAILABLE + self.manager.handle_device_event(test_device, + DeviceEvent.DISCONNECTED) + test_device.device_state_monitor.set_state( + TestDeviceState.NOT_AVAILABLE) + LOG.debug("Device disconnected: {} {} {} {}".format( + convert_serial(disconnected_device.device_sn), + disconnected_device.device_os_type, + disconnected_device.host, disconnected_device.port)) diff --git a/xdevice/plugins/ios/setup.py b/xdevice/plugins/ios/setup.py new file mode 100644 index 0000000..15cf942 --- /dev/null +++ b/xdevice/plugins/ios/setup.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from setuptools import setup + +INSTALL_REQUIRES = ["xdevice"] + + +def main(): + setup(name='xdevice-ios', + description='plugin for ios', + url='', + package_dir={'ios': ''}, + packages=['ios', + 'ios.environment', + 'ios.managers', + 'ios.testkit' + ], + entry_points={ + 'device': [ + 'device=ios.environment.device' + ], + 'manager': [ + 'manager=ios.managers.manager_device' + ], + 'testkit': [ + 'kit=ios.testkit.kit' + ] + }, + zip_safe=False, + install_requires=INSTALL_REQUIRES, + ) + + +if __name__ == "__main__": + main() diff --git a/xdevice/plugins/ios/testkit/__init__.py b/xdevice/plugins/ios/testkit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xdevice/plugins/ios/testkit/kit.py b/xdevice/plugins/ios/testkit/kit.py new file mode 100644 index 0000000..77cd7cb --- /dev/null +++ b/xdevice/plugins/ios/testkit/kit.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import plistlib +import re +from dataclasses import dataclass + +from xdevice import AppInstallError +from xdevice import FilePermission +from xdevice import ITestKit +from xdevice import ParamError +from xdevice import Plugin +from xdevice import get_config_value +from xdevice import get_file_absolute_path +from xdevice import platform_logger +from xdevice import DeviceTestType + +from ios.constants import CKit + +LOG = platform_logger("Kit") + +__all__ = ["IosAppInstallKit", "IosPushKit", "IosShellKit"] + + +@dataclass +class Props: + trying_remove_maximum_times = 3 + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.ios_app_install) +class IosAppInstallKit(ITestKit): + def __init__(self): + self.app_list = "" + self.app_list_name = "" + self.is_clean = "" + self.alt_dir = "" + self.ex_args = "" + self.installed_app = set() + self.paths = "" + self.env_index_list = None + + def __check_config__(self, options): + self.app_list = get_config_value('test-file-name', options) + self.app_list_name = get_config_value('test-file-packName', options) + self.is_clean = get_config_value('cleanup-apps', options, False) + self.alt_dir = get_config_value('alt-dir', options, False) + if self.alt_dir and self.alt_dir.startswith("resource/"): + self.alt_dir = self.alt_dir[len("resource/"):] + self.ex_args = get_config_value('install-arg', options, False) + self.installed_app = set() + self.paths = get_config_value('paths', options) + self.env_index_list = get_config_value('env-index', options) + + def __setup__(self, device, **kwargs): + request = kwargs.get("request", None) + del kwargs + LOG.debug("IosAppInstallKit setup, device:{}".format(device.device_sn)) + if len(self.app_list) == 0: + LOG.info("No app to install, skipping!") + return + # to disable app install alert + for app in self.app_list: + if self.alt_dir: + app_file = get_file_absolute_path(app, self.paths, + self.alt_dir) + else: + app_file = get_file_absolute_path(app, self.paths) + if app_file is None: + LOG.error("The app file {} does not exist".format(app)) + continue + result = self.install_app(device, app_file, self.ex_args) + if result is not True: + raise AppInstallError( + "Failed to install {} on {}. Reason:{}".format + (app_file, device.__get_serial__(), result)) + self.installed_app.add(app_file) + # arkuix跨平台测试需要一个入口文件来启动,通过查找app包名获取文件路径 + if request.root.source.test_type == DeviceTestType.arkuix_jsunit_test: + for app in self.installed_app: + app_name = get_app_name(app) + if app_name == request.config.bundle_name: + request.config.testargs.update({"app_file": app}) + break + + def __teardown__(self, device): + LOG.debug("IosAppInstallKit teardown: device:{}".format(device.device_sn)) + if self.is_clean and str(self.is_clean).lower() == "true": + if self.app_list_name and len(self.app_list_name) > 0: + for app_name in self.app_list_name: + result = device.uninstall_package(app_name) + if result and "OK" in result: + LOG.debug("uninstalling package Success. result is {}".format(result)) + else: + LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) + else: + for app in self.installed_app: + app_name = get_app_name(app) + if app_name: + result = device.uninstall_package(app_name) + if result and "OK" in result: + LOG.debug("uninstalling package Success. result is {}".format(result)) + else: + LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) + else: + LOG.warning("Can't find app name for {}".format(app)) + + @staticmethod + def install_app(device, app_file, ex_args): + result = device.install_package(app_file, ex_args) + if "Installed package" not in result: + LOG.debug("Install {} failed, try again.".format(app_file)) + return IosAppInstallKit.retry_install_app(device, app_file, ex_args) + else: + LOG.debug("Install {} success".format(app_file)) + return True + + @staticmethod + def retry_install_app(device, app_file, ex_args): + result = device.install_package(app_file, ex_args) + if "Installed package" not in result: + LOG.debug("Install {} failed.".format(app_file)) + return result + else: + LOG.debug("Install {} success".format(app_file)) + return True + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.ios_push) +class IosPushKit(ITestKit): + def __init__(self): + self.pre_push = "" + self.push_list = "" + self.post_push = "" + self.is_uninstall = "" + self.paths = "" + self.pushed_file = [] + self.abort_on_push_failure = True + self.teardown_push = "" + + def __check_config__(self, config): + self.pre_push = get_config_value('pre-push', config) + self.push_list = get_config_value('push', config) + self.post_push = get_config_value('post-push', config) + self.teardown_push = get_config_value('teardown-push', config) + self.is_uninstall = get_config_value('uninstall', config, + is_list=False, default=True) + self.abort_on_push_failure = get_config_value( + 'abort-on-push-failure', config, is_list=False, default=True) + if isinstance(self.abort_on_push_failure, str): + self.abort_on_push_failure = False if \ + self.abort_on_push_failure.lower() == "false" else True + + self.paths = get_config_value('paths', config) + self.pushed_file = [] + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("PushKit setup, device:{}".format(device.device_sn)) + for command in self.pre_push: + run_command(device, command) + dst = None + for push_info in self.push_list: + files = re.split('->|=>', push_info) + if len(files) != 2: + LOG.error("The push spec is invalid: {}".format(push_info)) + continue + src = files[0].strip() + bundle_id = files[1].strip().split("/")[1] + if "." in bundle_id: + dst = files[1].strip().replace("/" + bundle_id + "/", "") + else: + bundle_id = None + dst = files[1].strip() + LOG.debug( + "Trying to push the file local {} to remote {}".format(src, dst)) + try: + real_src_path = get_file_absolute_path(src, self.paths) + except ParamError as error: + if self.abort_on_push_failure: + raise error + else: + LOG.warning(error, error_no=error.error_no) + continue + ret = device.push_file(real_src_path, dst, bundle_id) + if "Error" in ret: + LOG.error("push file fail.") + else: + LOG.debug("push file successfully.") + self.pushed_file.append(files[1].strip()) + for command in self.post_push: + run_command(device, command) + return self.pushed_file, dst + + def __teardown__(self, device): + LOG.debug("PushKit teardown: device:{}".format(device.device_sn)) + for command in self.teardown_push: + run_command(device, command) + if self.is_uninstall: + for file_name in self.pushed_file: + LOG.debug("Trying to remove file {}".format(file_name)) + file_name = file_name.replace("\\", "/") + bundle_id = file_name[1].strip().split("/")[1] + if "." in bundle_id: + dst = file_name[1].strip().replace("/" + bundle_id + "/", "") + else: + bundle_id = None + dst = file_name[1].strip() + + if bundle_id: + command = ["--bundle_id", bundle_id, "--rm", dst] + else: + command = ["-f", "-R", dst] + + for _ in range(Props.trying_remove_maximum_times): + ret = device.execute_shell_command(command) + if "Error" not in ret: + LOG.debug( + "Removed file {} successfully".format(file_name)) + break + else: + LOG.error("Failed to remove file {}".format(file_name)) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.ios_shell) +class IosShellKit(ITestKit): + def __init__(self): + self.command_list = [] + self.tear_down_command = [] + self.paths = None + + def __check_config__(self, config): + self.command_list = get_config_value('run-command', config) + self.tear_down_command = get_config_value('teardown-command', config) + self.paths = get_config_value('paths', config) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("ShellKit setup, device:{}".format(device.device_sn)) + if len(self.command_list) == 0: + LOG.info("No setup_command to run, skipping!") + return + for command in self.command_list: + run_command(device, command) + + def __teardown__(self, device): + LOG.debug("ShellKit teardown: device:{}".format(device.device_sn)) + if len(self.tear_down_command) == 0: + LOG.info("No teardown_command to run, skipping!") + else: + for command in self.tear_down_command: + run_command(device, command) + + +def get_app_name(app): + path = os.path.join(app, "Info.plist") + app_name = "" + try: + app_open = os.open(path, os.O_RDONLY, FilePermission.mode_755) + with os.fdopen(app_open, mode="rb") as f: + app_file_info = plistlib.load(f, fmt=plistlib.FMT_BINARY, dict_type=dict) + app_name = app_file_info.get("CFBundleIdentifier") + except Exception as e: + LOG.error("get app name from app error: {}".format(e)) + return app_name + + +def run_command(device, command): + LOG.debug("The command:{} is running".format(command)) + stdout = None + if command.strip() == "reboot": + device.reboor() + else: + stdout = device.execute_shell_command(command) + LOG.error("Run command result: {}".format(stdout if stdout else "")) + return stdout diff --git a/xdevice/plugins/ohos/setup.py b/xdevice/plugins/ohos/setup.py new file mode 100644 index 0000000..192ca7f --- /dev/null +++ b/xdevice/plugins/ohos/setup.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from setuptools import setup + +INSTALL_REQUIRES = [] + + +def main(): + setup(name='xdevice-ohos', + description='plugin for ohos', + url='', + package_dir={'': 'src'}, + packages=['ohos', + 'ohos.drivers', + 'ohos.environment', + 'ohos.executor', + 'ohos.managers', + 'ohos.parser', + 'ohos.testkit' + ], + entry_points={ + 'device': [ + 'device=ohos.environment.device', + 'device_lite=ohos.environment.device_lite' + ], + 'manager': [ + 'manager=ohos.managers.manager_device', + 'manager_lite=ohos.managers.manager_lite' + ], + 'driver': [ + 'drivers=ohos.drivers.drivers', + 'drivers_lite=ohos.drivers.drivers_lite', + 'openharmony=ohos.drivers.openharmony', + 'arkuix=ohos.drivers.arkuix' + ], + 'listener': [ + 'listener=ohos.executor.listener', + ], + 'testkit': [ + 'kit=ohos.testkit.kit', + 'kit_lite=ohos.testkit.kit_lite' + ], + 'parser': [ + 'parser_lite=ohos.parser.parser_lite', + 'parser=ohos.parser.parser' + + ] + }, + zip_safe=False, + install_requires=INSTALL_REQUIRES, + ) + + +if __name__ == "__main__": + main() diff --git a/xdevice/plugins/ohos/src/ohos/__init__.py b/xdevice/plugins/ohos/src/ohos/__init__.py new file mode 100644 index 0000000..160d3a7 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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/xdevice/plugins/ohos/src/ohos/constants.py b/xdevice/plugins/ohos/src/ohos/constants.py new file mode 100644 index 0000000..dc7dc34 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/constants.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from dataclasses import dataclass + + +__all__ = ["ComType", "HostDrivenTestType", + "ParserType", "DeviceLiteKernel", "CKit"] + + +@dataclass +class ComType(object): + """ + ComType enumeration + """ + cmd_com = "cmd" + deploy_com = "deploy" + + +@dataclass +class HostDrivenTestType(object): + """ + HostDrivenType enumeration + """ + device_test = "DeviceTest" + windows_test = "WindowsTest" + + +@dataclass +class ParserType: + ctest_lite = "CTestLite" + cpp_test_lite = "CppTestLite" + cpp_test_list_lite = "CppTestListLite" + open_source_test = "OpenSourceTest" + build_only_test = "BuildOnlyTestLite" + jsuit_test_lite = "JSUnitTestLite" + + +@dataclass +class DeviceLiteKernel(object): + """ + Lite device os enumeration + """ + linux_kernel = "linux" + lite_kernel = "lite" + + +@dataclass +class CKit: + push = "PushKit" + liteinstall = "LiteAppInstallKit" + command = "CommandKit" + config = "ConfigKit" + wifi = "WIFIKit" + propertycheck = 'PropertyCheckKit' + sts = 'STSKit' + shell = "ShellKit" + deploy = 'DeployKit' + mount = 'MountKit' + liteuikit = 'LiteUiKit' + rootfs = "RootFsKit" + liteshell = "LiteShellKit" + app_install = "AppInstallKit" + deploytool = "DeployToolKit" + query = "QueryKit" + component = "ComponentKit" + permission = "PermissionKit" + smartperf = "SmartPerfKit" diff --git a/xdevice/plugins/ohos/src/ohos/drivers/__init__.py b/xdevice/plugins/ohos/src/ohos/drivers/__init__.py new file mode 100644 index 0000000..160d3a7 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/drivers/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py new file mode 100644 index 0000000..0ed6348 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# +import os +import platform +import shutil +import subprocess +import threading +import time + +from xdevice import IDriver +from xdevice import Plugin +from xdevice import ConfigConst +from xdevice import check_result_report +from xdevice import get_device_log_file +from xdevice import ParamError +from xdevice import JsonParser +from xdevice import platform_logger +from xdevice import DeviceTestType +from xdevice import get_config_value +from xdevice import get_plugin +from xdevice import TestDescription +from xdevice import CommonParserType +from xdevice import ShellHandler +from xdevice import convert_serial +from xdevice import HdcError +from xdevice import ShellCommandUnresponsiveException +from xdevice import DeviceOsType +from xdevice import FilePermission +from xdevice import get_kit_instances +from xdevice import do_module_kit_setup +from xdevice import do_module_kit_teardown +from xdevice import disable_keyguard +from xdevice import ExecuteTerminate + +__all__ = ["ARKUIXJSUnitTestDriver"] + +LOG = platform_logger("ARKUIX") + +TIME_OUT = 300 * 10000 + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.arkuix_jsunit_test) +class ARKUIXJSUnitTestDriver(IDriver): + """ + ARKUIXJSUnitTestDriver is a Test that runs a native test package on + given device. + """ + + def __init__(self): + self.timeout = 80 * 1000 + self.start_time = None + self.result = "" + self.error_message = "" + self.kits = [] + self.config = None + self.runner = None + self.rerun = True + self.rerun_all = True + # log + self.device_log = None + self.hi_log = None + self.log_proc = None + self.hilog_proc = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + LOG.debug("Start execute ARKUIX JSUnitTest") + self.result = os.path.join( + request.config.report_path, "result", + '.'.join((request.get_module_name(), "xml"))) + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + suite_file = request.root.source.source_file + + if not suite_file: + raise ParamError( + "test source '{}' not exists".format(request.root.source.source_string), error_no="00110") + LOG.debug("Test case file path: {}".format(suite_file)) + self.config.device.set_device_report_path(request.config.report_path) + self.config.device.device_log_collector.clear_crash_log() + log_level = self.config.device_log.get(ConfigConst.tag_enable, "DEBUG") + if self.config.device.device_os_type == DeviceOsType.ios: + self.device_log = get_device_log_file(request.config.report_path, + "{}_{}".format(request.config.device.__get_serial__(), + request.get_module_name()), "device_log") + + device_log_open = os.open(self.device_log, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + self.config.device.device_log_collector.add_log_address(self.device_log) + with os.fdopen(device_log_open, "a") as device_log_file_pipe: + self.log_proc = self.config.device.device_log_collector.start_catch_device_log( + log_file_pipe=device_log_file_pipe) + self._run_arkuix_jsunit(config_file, request) + else: + self.hi_log = get_device_log_file(request.config.report_path, + request.config.device.__get_serial__(), + "device_hilog_{}".format(request.get_module_name())) + hi_log_open = os.open(self.hi_log, os.O_WRONLY | os.O_CREAT | os.O_APPEND, FilePermission.mode_755) + self.device_log = get_device_log_file(request.config.report_path, self.config.device.__get_serial__(), + "device_log_{}".format(request.get_module_name())) + device_log_open = os.open(self.device_log, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + with os.fdopen(hi_log_open, "a") as hilog_file_pipe, \ + os.fdopen(device_log_open, "a") as device_log_file_pipe: + self.log_proc, self.hilog_proc = self.config.device.device_log_collector. \ + start_catch_device_log(device_log_file_pipe, hilog_file_pipe, log_level=log_level) + self.config.device.device_log_collector.add_log_address(self.device_log, self.hi_log) + self._run_arkuix_jsunit(config_file, request) + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=True, error_no="03409") + raise exception + finally: + try: + self._handle_logs(request) + finally: + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def _run_arkuix_jsunit(self, config_file, request): + try: + if not os.path.exists(config_file): + LOG.error("Error: Test cases don't exist {}.".format(config_file)) + raise ParamError( + "Error: Test cases don't exist {}.".format(config_file), + error_no="00102") + json_config = JsonParser(config_file) + self.kits = get_kit_instances(json_config, + self.config.resource_path, + self.config.testcases_path) + self._get_driver_config(json_config) + self.runner = ARKUIXJSUnitTestRunner(self.config) + do_module_kit_setup(request, self.kits) + self.runner.suites_name = request.get_module_name() + if hasattr(self.config, "history_report_path") and self.config.testargs.get("test"): + self._do_test_retry(request.listeners, self.config.testargs) + else: + if self.rerun: + self.runner.retry_times = self.runner.MAX_RETRY_TIMES + # execute test case + app_file = self.config.testargs.get("app_file") + if app_file: + self._do_test_run(listener=request.listeners, path=app_file) + else: + LOG.error("Not find ace test app file!") + raise ExecuteTerminate(error_msg="Not find ace test app file!") + finally: + do_module_kit_teardown(request) + if self.runner.coverage_data: + LOG.debug("Coverage data: {}".format(self.runner.coverage_data)) + + def _get_driver_config(self, json_config): + package = get_config_value('package-name', + json_config.get_driver(), False) + module = get_config_value('module-name', + json_config.get_driver(), False) + bundle = get_config_value('bundle-name', + json_config.get_driver(), False) + is_rerun = get_config_value('rerun', json_config.get_driver(), False) + + self.config.package_name = package + self.config.module_name = module + self.config.bundle_name = bundle + self.rerun = True if is_rerun == 'true' else False + + if not package and not module: + raise ParamError("Neither package nor module is found" + " in config file.", error_no="03201") + timeout_config = get_config_value("test-timeout", + json_config.get_driver(), False) + if timeout_config: + self.config.timeout = int(timeout_config) + else: + self.config.timeout = TIME_OUT + + def _do_test_retry(self, listener, testargs): + tests_dict = dict() + case_list = list() + for test in testargs.get("test"): + test_item = test.split("#") + if len(test_item) != 2: + continue + case_list.append(test) + if test_item[0] not in tests_dict: + tests_dict.update({test_item[0]: []}) + tests_dict.get(test_item[0]).append( + TestDescription(test_item[0], test_item[1])) + self.runner.add_arg("class", ",".join(case_list)) + self.runner.expect_tests_dict = tests_dict + self.config.testargs.pop("test") + self.runner.run(listener, self.config.testargs.get("app_file")) + self.runner.notify_finished() + + def _do_test_run(self, listener, path): + self.runner.run(listener, path) + self.runner.notify_finished() + + def _handle_logs(self, request): + serial = "crash_log_{}_{}".format(str(self.config.device.__get_serial__()), time.time_ns()) + log_tar_file_name = "{}".format(str(serial).replace(":", "_")) + if self.config.device.device_os_type == DeviceOsType.ios: + self.config.device.device_log_collector.remove_log_address(self.device_log) + else: + self.config.device.device_log_collector.remove_log_address(self.device_log, self.hi_log) + self.config.device.device_log_collector.stop_catch_device_log(self.hilog_proc) + self.config.device.device_log_collector.stop_catch_device_log(self.log_proc) + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +class ARKUIXJSUnitTestRunner: + MAX_RETRY_TIMES = 3 + + def __init__(self, config): + self.arg_list = {} + self.suites_name = None + self.config = config + self.rerun_attemp = 3 + self.suite_recorder = {} + self.finished = False + self.expect_tests_dict = dict() + self.finished_observer = None + self.retry_times = 1 + self.compile_mode = "" + self.coverage_data = "" + + def run(self, listener, path): + handler = self._get_shell_handler(listener) + command = self._get_run_command(self.config.device) + self.execute_arkuix_command(self.config.device, command, timeout=self.config.timeout, receiver=handler, retry=0, + path=path) + + @staticmethod + def execute_arkuix_command(device, command, timeout=TIME_OUT, receiver=None, **kwargs): + if not shutil.which("ace"): + raise HdcError(error_msg="Can not find acetools, please check.") + stop_event = threading.Event() + path = kwargs.get("path", None) + output_flag = kwargs.get("output_flag", True) + run_command = command + ["--timeout", str(timeout)] + ["-d", device.device_sn] + ["--path", path] + try: + if device.device_os_type == DeviceOsType.aosp: + disable_keyguard(device) + if output_flag: + LOG.info(" ".join(run_command)) + else: + LOG.debug(" ".join(run_command)) + if platform.system() == "Windows": + proc = subprocess.Popen(["C:\\Windows\\System32\\cmd.exe", "/c", " ".join(run_command)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE, + shell=False) + proc.stdin.write(b"\r\n") + else: + proc = subprocess.Popen(run_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=False) + timeout_thread = threading.Thread(target=ARKUIXJSUnitTestRunner.kill_proc, args=(proc, timeout, stop_event)) + timeout_thread.daemon = True + timeout_thread.start() + while True: + output = proc.stdout.readline() + if b'Test failed' in output: + raise ExecuteTerminate(error_msg="Test failed!") + if output == b'' and proc.poll() is not None: + break + if output and b'OHOS_REPORT' in output: + if receiver: + receiver.__read__(output.strip().decode('utf-8') + "\n") + else: + LOG.debug(output.strip().decode('utf-8')) + if stop_event.is_set(): + device.log.error( + "ShellCommandUnresponsiveException: {} command {}".format(convert_serial(device.device_sn), + run_command)) + LOG.exception("execute timeout!", exc_info=False) + raise ShellCommandUnresponsiveException(error_msg="execute timeout!") + except Exception as error: + device.log.error("execute_arkuix_command exception: {} command {}".format( + convert_serial(device.device_sn), run_command)) + LOG.exception(error, exc_info=False) + raise ExecuteTerminate(error_msg="execute_arkuix_command exception, reason: {}".format(error)) from error + finally: + stop_event.set() + if receiver: + receiver.__done__() + + @staticmethod + def kill_proc(proc, timeout, stop_event): + end_time = time.time() + timeout + while time.time() < end_time and not stop_event.is_set(): + time.sleep(1) + if proc.poll() is None: + proc.kill() + stop_event.set() + + def notify_finished(self): + if self.finished_observer: + self.finished_observer.notify_task_finished() + self.retry_times -= 1 + + def _get_shell_handler(self, listener): + parsers = get_plugin(Plugin.PARSER, CommonParserType.oh_jsunit) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = self.suites_name + parser_instance.listeners = listener + parser_instance.runner = self + parser_instances.append(parser_instance) + self.finished_observer = parser_instance + handler = ShellHandler(parser_instances) + return handler + + def add_arg(self, name, value): + if not name or not value: + return + self.arg_list[name] = value + + def remove_arg(self, name): + if not name: + return + if name in self.arg_list: + del self.arg_list[name] + + def _get_run_command(self, device): + if device.device_os_type == DeviceOsType.ios: + test_product = "app" + else: + test_product = "apk" + command = ["ace", "test", test_product, "--b", self.config.bundle_name, "--m", self.config.module_name, + "--unittest", "OpenHarmonyTestRunner"] + return command diff --git a/xdevice/plugins/ohos/src/ohos/drivers/drivers.py b/xdevice/plugins/ohos/src/ohos/drivers/drivers.py new file mode 100644 index 0000000..490eaae --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/drivers/drivers.py @@ -0,0 +1,1047 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import re +import time +import json +import shutil +import zipfile +import tempfile +import stat +from dataclasses import dataclass + +from xdevice import ConfigConst +from xdevice import ParamError +from xdevice import ExecuteTerminate +from xdevice import IDriver +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import JsonParser +from xdevice import ShellHandler +from xdevice import TestDescription +from xdevice import ResourceManager +from xdevice import get_device_log_file +from xdevice import check_result_report +from xdevice import get_kit_instances +from xdevice import get_config_value +from xdevice import do_module_kit_setup +from xdevice import do_module_kit_teardown +from xdevice import DeviceTestType +from xdevice import CommonParserType +from xdevice import FilePermission +from xdevice import CollectingTestListener +from xdevice import ShellCommandUnresponsiveException +from xdevice import HapNotSupportTest +from xdevice import HdcCommandRejectedException +from xdevice import HdcError +from xdevice import DeviceConnectorType +from xdevice import get_filename_extension +from xdevice import junit_para_parse +from xdevice import gtest_para_parse +from xdevice import reset_junit_para +from xdevice import disable_keyguard +from xdevice import unlock_screen +from xdevice import unlock_device +from xdevice import get_cst_time + +from ohos.environment.dmlib import process_command_ret +from ohos.environment.dmlib import DisplayOutputReceiver +from ohos.testkit.kit import junit_dex_para_parse +from ohos.parser.parser import _ACE_LOG_MARKER + +__all__ = ["CppTestDriver", "DexTestDriver", "HapTestDriver", + "JSUnitTestDriver", "JUnitTestDriver", "RemoteTestRunner", + "RemoteDexRunner"] +LOG = platform_logger("Drivers") +DEFAULT_TEST_PATH = "/%s/%s/" % ("data", "test") +ON_DEVICE_TEST_DIR_LOCATION = "/%s/%s/%s/" % ("data", "local", "tmp") + +FAILED_RUN_TEST_ATTEMPTS = 3 +TIME_OUT = 900 * 1000 + + +def get_xml_output(config, json_config): + xml_output = config.testargs.get("xml-output") + if not xml_output: + if get_config_value('xml-output', json_config.get_driver(), False): + xml_output = get_config_value('xml-output', + json_config.get_driver(), False) + else: + xml_output = "false" + else: + xml_output = xml_output[0] + xml_output = str(xml_output).lower() + return xml_output + + +def get_result_savepath(testsuit_path, result_rootpath): + findkey = "%stests%s" % (os.sep, os.sep) + filedir, _ = os.path.split(testsuit_path) + pos = filedir.find(findkey) + if -1 != pos: + subpath = filedir[pos + len(findkey):] + pos1 = subpath.find(os.sep) + if -1 != pos1: + subpath = subpath[pos1 + len(os.sep):] + result_path = os.path.join(result_rootpath, "result", subpath) + else: + result_path = os.path.join(result_rootpath, "result") + else: + result_path = os.path.join(result_rootpath, "result") + + if not os.path.exists(result_path): + os.makedirs(result_path) + + LOG.info("Result save path = %s" % result_path) + return result_path + + +# all testsuit common Unavailable test result xml +def _create_empty_result_file(filepath, filename, error_message): + error_message = str(error_message) + error_message = error_message.replace("\"", """) + error_message = error_message.replace("<", "<") + error_message = error_message.replace(">", ">") + error_message = error_message.replace("&", "&") + if filename.endswith(".hap"): + filename = filename.split(".")[0] + if not os.path.exists(filepath): + file_open = os.open(filepath, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + with os.fdopen(file_open, "w") as file_desc: + time_stamp = time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime()) + file_desc.write('\n') + file_desc.write('\n' % time_stamp) + file_desc.write( + ' \n' % + (filename, error_message)) + file_desc.write(' \n') + file_desc.write('\n') + file_desc.flush() + return + + +class ResultManager(object): + def __init__(self, testsuit_path, result_rootpath, device, + device_testpath): + self.testsuite_path = testsuit_path + self.result_rootpath = result_rootpath + self.device = device + self.device_testpath = device_testpath + self.testsuite_name = os.path.basename(self.testsuite_path) + self.is_coverage = False + + def set_is_coverage(self, is_coverage): + self.is_coverage = is_coverage + + def get_test_results(self, error_message=""): + # Get test result files + filepath = self.obtain_test_result_file() + if not os.path.exists(filepath): + _create_empty_result_file(filepath, self.testsuite_name, + error_message) + + # Get coverage data files + if self.is_coverage: + self.obtain_coverage_data() + + return filepath + + def obtain_test_result_file(self): + result_savepath = get_result_savepath(self.testsuite_path, + self.result_rootpath) + if self.testsuite_path.endswith('.hap'): + filepath = os.path.join(result_savepath, "%s.xml" % str( + self.testsuite_name).split(".")[0]) + + remote_result_name = "" + if self.device.is_file_exist(os.path.join(self.device_testpath, + "testcase_result.xml")): + remote_result_name = "testcase_result.xml" + elif self.device.is_file_exist(os.path.join(self.device_testpath, + "report.xml")): + remote_result_name = "report.xml" + + if remote_result_name: + self.device.pull_file( + os.path.join(self.device_testpath, remote_result_name), + filepath) + else: + LOG.error("%s no report file", self.device_testpath) + + else: + filepath = os.path.join(result_savepath, "%s.xml" % + self.testsuite_name) + remote_result_file = os.path.join(self.device_testpath, + "%s.xml" % self.testsuite_name) + + if self.device.is_file_exist(remote_result_file): + self.device.pull_file(remote_result_file, result_savepath) + else: + LOG.error("%s not exists", remote_result_file) + return filepath + + def is_exist_target_in_device(self, path, target): + command = "ls -l %s | grep %s" % (path, target) + + check_result = False + stdout_info = self.device.execute_shell_command(command) + if stdout_info != "" and stdout_info.find(target) != -1: + check_result = True + return check_result + + def obtain_coverage_data(self): + java_cov_path = os.path.abspath( + os.path.join(self.result_rootpath, "..", "coverage/data/exec")) + dst_target_name = "%s.exec" % self.testsuite_name + src_target_name = "jacoco.exec" + if self.is_exist_target_in_device(self.device_testpath, + src_target_name): + if not os.path.exists(java_cov_path): + os.makedirs(java_cov_path) + self.device.pull_file( + os.path.join(self.device_testpath, src_target_name), + os.path.join(java_cov_path, dst_target_name)) + + cxx_cov_path = os.path.abspath( + os.path.join(self.result_rootpath, "..", "coverage/data/cxx", + self.testsuite_name)) + target_name = "obj" + if self.is_exist_target_in_device(self.device_testpath, target_name): + if not os.path.exists(cxx_cov_path): + os.makedirs(cxx_cov_path) + src_file = os.path.join(self.device_testpath, target_name) + self.device.pull_file(src_file, cxx_cov_path) + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.cpp_test) +class CppTestDriver(IDriver): + """ + CppTestDriver is a Test that runs a native test package on given harmony + device. + """ + + def __init__(self): + self.result = "" + self.error_message = "" + self.config = None + self.rerun = True + self.rerun_all = True + self.runner = None + # log + self.device_log = None + self.hilog = None + self.log_proc = None + self.hilog_proc = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + LOG.debug("Start execute xdevice extension CppTest") + + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + self.result = "%s.xml" % \ + os.path.join(request.config.report_path, + "result", request.root.source.test_name) + + self.device_log = get_device_log_file( + request.config.report_path, + request.config.device.__get_serial__() + "_" + request. + get_module_name(), + "device_log") + + self.hilog = get_device_log_file( + request.config.report_path, + request.config.device.__get_serial__() + "_" + request. + get_module_name(), + "device_hilog") + + device_log_open = os.open(self.device_log, os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + hilog_open = os.open(self.hilog, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + self.config.device.device_log_collector.add_log_address(self.device_log, self.hilog) + with os.fdopen(device_log_open, "a") as log_file_pipe, \ + os.fdopen(hilog_open, "a") as hilog_file_pipe: + self.log_proc, self.hilog_proc = self.config.device.device_log_collector.\ + start_catch_device_log(log_file_pipe, hilog_file_pipe) + self._run_cpp_test(config_file, listeners=request.listeners, + request=request) + log_file_pipe.flush() + hilog_file_pipe.flush() + + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03404") + LOG.exception(self.error_message, exc_info=False, error_no="03404") + raise exception + + finally: + self.config.device.device_log_collector.remove_log_address(self.device_log, self.hilog) + self.config.device.device_log_collector.stop_catch_device_log(self.log_proc) + self.config.device.device_log_collector.stop_catch_device_log(self.hilog_proc) + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def _run_cpp_test(self, config_file, listeners=None, request=None): + try: + if not os.path.exists(config_file): + LOG.error("Error: Test cases don't exit %s." % config_file, + error_no="00102") + raise ParamError( + "Error: Test cases don't exit %s." % config_file, + error_no="00102") + + json_config = JsonParser(config_file) + kits = get_kit_instances(json_config, self.config.resource_path, + self.config.testcases_path) + + for listener in listeners: + listener.device_sn = self.config.device.device_sn + + self._get_driver_config(json_config) + do_module_kit_setup(request, kits) + self.runner = RemoteCppTestRunner(self.config) + self.runner.suite_name = request.root.source.test_name + + if hasattr(self.config, "history_report_path") and \ + self.config.testargs.get("test"): + self._do_test_retry(listeners, self.config.testargs) + else: + gtest_para_parse(self.config.testargs, self.runner, request) + self._do_test_run(listeners) + + finally: + do_module_kit_teardown(request) + + def _do_test_retry(self, listener, testargs): + for test in testargs.get("test"): + test_item = test.split("#") + if len(test_item) != 2: + continue + self.runner.add_instrumentation_arg( + "gtest_filter", "%s.%s" % (test_item[0], test_item[1])) + self.runner.run(listener) + + def _do_test_run(self, listener): + test_to_run = self._collect_test_to_run() + LOG.info("Collected test count is: %s" % (len(test_to_run) + if test_to_run else 0)) + if not test_to_run: + self.runner.run(listener) + else: + self._run_with_rerun(listener, test_to_run) + + def _collect_test_to_run(self): + if self.rerun: + self.runner.add_instrumentation_arg("gtest_list_tests", True) + run_results = self.runner.dry_run() + self.runner.remove_instrumentation_arg("gtest_list_tests") + return run_results + return None + + def _run_tests(self, listener): + test_tracker = CollectingTestListener() + listener_copy = listener.copy() + listener_copy.append(test_tracker) + self.runner.run(listener_copy) + test_run = test_tracker.get_current_run_results() + return test_run + + def _run_with_rerun(self, listener, expected_tests): + LOG.debug("Ready to run with rerun, expect run: %s" + % len(expected_tests)) + test_run = self._run_tests(listener) + LOG.debug("Run with rerun, has run: %s" % len(test_run) + if test_run else 0) + if len(test_run) < len(expected_tests): + expected_tests = TestDescription.remove_test(expected_tests, + test_run) + if not expected_tests: + LOG.debug("No tests to re-run, all tests executed at least " + "once.") + if self.rerun_all: + self._rerun_all(expected_tests, listener) + else: + self._rerun_serially(expected_tests, listener) + + def _rerun_all(self, expected_tests, listener): + tests = [] + for test in expected_tests: + tests.append("%s.%s" % (test.class_name, test.test_name)) + self.runner.add_instrumentation_arg("gtest_filter", ":".join(tests)) + LOG.debug("Ready to rerun file, expect run: %s" % len(expected_tests)) + test_run = self._run_tests(listener) + LOG.debug("Rerun file, has run: %s" % len(test_run)) + if len(test_run) < len(expected_tests): + expected_tests = TestDescription.remove_test(expected_tests, + test_run) + if not expected_tests: + LOG.debug("Rerun textFile success") + self._rerun_serially(expected_tests, listener) + + def _rerun_serially(self, expected_tests, listener): + LOG.debug("Rerun serially, expected run: %s" % len(expected_tests)) + for test in expected_tests: + self.runner.add_instrumentation_arg( + "gtest_filter", "%s.%s" % (test.class_name, test.test_name)) + self.runner.rerun(listener, test) + self.runner.remove_instrumentation_arg("gtest_filter") + + def _get_driver_config(self, json_config): + target_test_path = get_config_value('native-test-device-path', + json_config.get_driver(), False) + if target_test_path: + self.config.target_test_path = target_test_path + else: + self.config.target_test_path = DEFAULT_TEST_PATH + + self.config.module_name = get_config_value( + 'module-name', json_config.get_driver(), False) + + timeout_config = get_config_value('native-test-timeout', + json_config.get_driver(), False) + if timeout_config: + self.config.timeout = int(timeout_config) + else: + self.config.timeout = TIME_OUT + + rerun = get_config_value('rerun', json_config.get_driver(), False) + if isinstance(rerun, bool): + self.rerun = rerun + elif str(rerun).lower() == "false": + self.rerun = False + else: + self.rerun = True + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +class RemoteCppTestRunner: + def __init__(self, config): + self.arg_list = {} + self.suite_name = None + self.config = config + self.rerun_attempt = FAILED_RUN_TEST_ATTEMPTS + + def dry_run(self): + parsers = get_plugin(Plugin.PARSER, CommonParserType.cpptest_list) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + + command = "cd %s; chmod +x *; ./%s %s" \ + % (self.config.target_test_path, self.config.module_name, + self.get_args_command()) + + self.config.device.execute_shell_command( + command, timeout=self.config.timeout, receiver=handler, retry=0) + return parser_instances[0].tests + + def run(self, listener): + handler = self._get_shell_handler(listener) + command = "cd %s; chmod +x *; ./%s %s" \ + % (self.config.target_test_path, self.config.module_name, + self.get_args_command()) + + self.config.device.execute_shell_command( + command, timeout=self.config.timeout, receiver=handler, retry=0) + + def rerun(self, listener, test): + if self.rerun_attempt: + test_tracker = CollectingTestListener() + listener_copy = listener.copy() + listener_copy.append(test_tracker) + handler = self._get_shell_handler(listener_copy) + try: + command = "cd %s; chmod +x *; ./%s %s" \ + % (self.config.target_test_path, + self.config.module_name, + self.get_args_command()) + + self.config.device.execute_shell_command( + command, timeout=self.config.timeout, receiver=handler, + retry=0) + + except ShellCommandUnresponsiveException as _: + LOG.debug("Exception: ShellCommandUnresponsiveException") + finally: + if not len(test_tracker.get_current_run_results()): + LOG.debug("No test case is obtained finally") + self.rerun_attempt -= 1 + handler.parsers[0].mark_test_as_blocked(test) + else: + LOG.debug("Not execute and mark as blocked finally") + handler = self._get_shell_handler(listener) + handler.parsers[0].mark_test_as_blocked(test) + + def add_instrumentation_arg(self, name, value): + if not name or not value: + return + self.arg_list[name] = value + + def remove_instrumentation_arg(self, name): + if not name: + return + if name in self.arg_list: + del self.arg_list[name] + + def get_args_command(self): + args_commands = "" + for key, value in self.arg_list.items(): + if key == "gtest_list_tests": + args_commands = "%s --%s" % (args_commands, key) + else: + args_commands = "%s --%s=%s" % (args_commands, key, value) + return args_commands + + def _get_shell_handler(self, listener): + parsers = get_plugin(Plugin.PARSER, CommonParserType.cpptest) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suite_name = self.suite_name + parser_instance.listeners = listener + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + return handler + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.jsunit_test) +class JSUnitTestDriver(IDriver): + """ + JSUnitTestDriver is a Test that runs a native test package on given device. + """ + + def __init__(self): + self.xml_output = "false" + self.timeout = 30 * 1000 + self.start_time = None + self.result = "" + self.error_message = "" + self.kits = [] + self.config = None + # log + self.device_log = None + self.hilog = None + self.log_proc = None + self.hilog_proc = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + + device = request.config.environment.devices[0] + exe_out = device.execute_shell_command( + "param get const.product.software.version") + LOG.debug("Software version is {}".format(exe_out)) + self.run_js_outer(request) + + def generate_console_output(self, request, timeout): + LOG.info("prepare to read device log, may wait some time") + message_list = list() + label_list, suite_info, is_suites_end = self.read_device_log_timeout( + self.hilog, message_list, timeout) + if not is_suites_end: + message_list.append(_ACE_LOG_MARKER + ": [end] run suites end\n") + LOG.warning("there is no suites end") + if len(label_list[0]) > 0 and sum(label_list[0]) != 0: + # the problem happened! when the sum of label list is not zero + self._insert_suite_end(label_list, message_list) + + result_message = "".join(message_list) + message_list.clear() + expect_tests_dict = self._parse_suite_info(suite_info) + self._analyse_tests(request, result_message, expect_tests_dict) + + @classmethod + def _insert_suite_end(cls, label_list, message_list): + for i in range(len(label_list[0])): + if label_list[0][i] != 1: # skipp + continue + # check the start label, then peek next position + if i + 1 == len(label_list[0]): # next position at the tail + message_list.insert(-1, _ACE_LOG_MARKER + ": [suite end]\n") + LOG.warning("there is no suite end") + continue + if label_list[0][i + 1] != 1: # 0 present the end label + continue + message_list.insert(label_list[1][i + 1], + _ACE_LOG_MARKER + ": [suite end]\n") + LOG.warning("there is no suite end") + for j in range(i + 1, len(label_list[1])): + label_list[1][j] += 1 # move the index to next + + def _analyse_tests(self, request, result_message, expect_tests_dict): + exclude_list = self._make_exclude_list_file(request) + exclude_list.extend(self._get_retry_skip_list(expect_tests_dict)) + listener_copy = request.listeners.copy() + parsers = get_plugin( + Plugin.PARSER, CommonParserType.jsunit) + if parsers: + parsers = parsers[:1] + for listener in listener_copy: + listener.device_sn = self.config.device.device_sn + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = request.get_module_name() + parser_instance.listeners = listener_copy + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + handler.parsers[0].expect_tests_dict = expect_tests_dict + handler.parsers[0].exclude_list = exclude_list + process_command_ret(result_message, handler) + + def _get_retry_skip_list(self, expect_tests_dict): + # get already pass case + skip_list = [] + if hasattr(self.config, "history_report_path") and \ + self.config.testargs.get("test"): + for class_name in expect_tests_dict.keys(): + for test_desc in expect_tests_dict.get(class_name, list()): + test = "{}#{}".format(test_desc.class_name, test_desc.test_name) + if test not in self.config.testargs.get("test"): + skip_list.append(test) + LOG.debug("Retry skip list: {}, total skip case: {}". + format(skip_list, len(skip_list))) + return skip_list + + @classmethod + def _parse_suite_info(cls, suite_info): + tests_dict = dict() + test_count = 0 + if suite_info: + json_str = "".join(suite_info) + LOG.debug("Suites info: %s" % json_str) + try: + suite_dict_list = json.loads(json_str).get("suites", []) + for suite_dict in suite_dict_list: + for class_name, test_name_dict_list in suite_dict.items(): + tests_dict.update({class_name.strip(): []}) + for test_name_dict in test_name_dict_list: + for test_name in test_name_dict.values(): + test = TestDescription(class_name.strip(), + test_name.strip()) + tests_dict.get(class_name.strip()).append(test) + test_count += 1 + except json.decoder.JSONDecodeError as json_error: + LOG.warning("Suites info is invalid: %s" % json_error) + LOG.debug("Collect suite count is %s, test count is %s" % + (len(tests_dict), test_count)) + return tests_dict + + def read_device_log_timeout(self, device_log_file, + message_list, timeout): + LOG.info("The timeout is {} seconds".format(timeout)) + pattern = "^\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\s+(\\d+)" + while time.time() - self.start_time <= timeout: + with open(device_log_file, "r", encoding='utf-8', + errors='ignore') as file_read_pipe: + pid = "" + message_list.clear() + label_list = [[], []] # [-1, 1 ..] [line1, line2 ..] + suite_info = [] + while True: + try: + line = file_read_pipe.readline() + except UnicodeError as error: + LOG.warning("While read log file: %s" % error) + if not line: + time.sleep(5) # wait for log write to file + break + if line.lower().find(_ACE_LOG_MARKER + ":") != -1: + if "[suites info]" in line: + _, pos = re.match(".+\\[suites info]", line).span() + suite_info.append(line[pos:].strip()) + + if "[start] start run suites" in line: # 发现了任务开始标签 + pid, is_update = \ + self._init_suites_start(line, pattern, pid) + if is_update: + message_list.clear() + label_list[0].clear() + label_list[1].clear() + if not pid or pid not in line: + continue + message_list.append(line) + if "[suite end]" in line: + label_list[0].append(-1) + label_list[1].append(len(message_list) - 1) + if "[suite start]" in line: + label_list[0].append(1) + label_list[1].append(len(message_list) - 1) + if "[end] run suites end" in line: + LOG.info("Find the end mark then analysis result") + LOG.debug("current JSApp pid= %s" % pid) + return label_list, suite_info, True + else: + LOG.error("Hjsunit run timeout {}s reached".format(timeout)) + LOG.debug("current JSApp pid= %s" % pid) + return label_list, suite_info, False + + @classmethod + def _init_suites_start(cls, line, pattern, pid): + matcher = re.match(pattern, line.strip()) + if matcher and matcher.group(1): + pid = matcher.group(1) + return pid, True + return pid, False + + def run_js_outer(self, request): + try: + LOG.debug("Start execute xdevice extension JSUnit Test") + LOG.debug("Outer version about Community") + self.result = os.path.join( + request.config.report_path, "result", + '.'.join((request.get_module_name(), "xml"))) + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + suite_file = request.root.source.source_file + + if not suite_file: + raise ParamError( + "test source '%s' not exists" % + request.root.source.source_string, error_no="00110") + + LOG.debug("Test case file path: %s" % suite_file) + # avoid hilog service stuck issue + self.config.device.connector_command("shell stop_service hilogd", + timeout=30 * 1000) + self.config.device.connector_command("shell start_service hilogd", + timeout=30 * 1000) + time.sleep(10) + + self.config.device.set_device_report_path(request.config.report_path) + self.config.device.connector_command("shell hilog -r", timeout=30 * 1000) + self._run_jsunit_outer(config_file, request) + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=False, error_no="03409") + raise exception + finally: + serial = "{}_{}".format(str(self.config.device.__get_serial__()), time.time_ns()) + log_tar_file_name = "{}".format(str(serial).replace(":", "_")) + if hasattr(self.config, ConfigConst.device_log) and \ + self.config.device_log.get(ConfigConst.tag_enable) == ConfigConst.device_log_on: + self.config.device.device_log_collector.start_get_crash_log(log_tar_file_name, + module_name=request.get_module_name()) + self.config.device.device_log_collector.remove_log_address(self.device_log, self.hilog) + self.config.device.device_log_collector.stop_catch_device_log(self.log_proc) + self.config.device.device_log_collector.stop_catch_device_log(self.hilog_proc) + do_module_kit_teardown(request) + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def _run_jsunit_outer(self, config_file, request): + if not os.path.exists(config_file): + LOG.error("Error: Test cases don't exist %s." % config_file) + raise ParamError( + "Error: Test cases don't exist %s." % config_file, + error_no="00102") + + json_config = JsonParser(config_file) + self.kits = get_kit_instances(json_config, + self.config.resource_path, + self.config.testcases_path) + + package, ability_name = self._get_driver_config_outer(json_config) + self.config.device.connector_command("target mount") + do_module_kit_setup(request, self.kits) + + self.hilog = get_device_log_file( + request.config.report_path, + request.config.device.__get_serial__() + "_" + request. + get_module_name(), + "device_hilog") + + hilog_open = os.open(self.hilog, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + 0o755) + self.config.device.device_log_collector.add_log_address(self.device_log, self.hilog) + with os.fdopen(hilog_open, "a") as hilog_file_pipe: + if hasattr(self.config, ConfigConst.device_log) and \ + self.config.device_log.get(ConfigConst.tag_enable) == ConfigConst.device_log_on: + self.config.device.device_log_collector.clear_crash_log() + self.log_proc, self.hilog_proc = self.config.device.device_log_collector. \ + start_catch_device_log(hilog_file_pipe=hilog_file_pipe) + + # execute test case + command = "shell aa start -d 123 -a %s -b %s" \ + % (ability_name, package) + result_value = self.config.device.connector_command(command) + if result_value and "start ability successfully" in \ + str(result_value).lower(): + setattr(self, "start_success", True) + LOG.info("execute %s's testcase success. result value=%s" + % (package, result_value)) + else: + LOG.info("execute %s's testcase failed. result value=%s" + % (package, result_value)) + raise RuntimeError("hjsunit test run error happened!") + + self.start_time = time.time() + timeout_config = get_config_value('test-timeout', + json_config.get_driver(), + False, 60000) + timeout = int(timeout_config) / 1000 + self.generate_console_output(request, timeout) + + def _jsunit_clear_outer(self): + self.config.device.execute_shell_command( + "rm -r /%s/%s/%s/%s" % ("data", "local", "tmp", "ajur")) + + def _get_driver_config_outer(self, json_config): + package = get_config_value('package', json_config.get_driver(), False) + default_ability = "{}.MainAbility".format(package) + ability_name = get_config_value('abilityName', json_config. + get_driver(), False, default_ability) + self.xml_output = get_xml_output(self.config, json_config) + timeout_config = get_config_value('native-test-timeout', + json_config.get_driver(), False) + if timeout_config: + self.timeout = int(timeout_config) + + if not package: + raise ParamError("Can't find package in config file.", + error_no="03201") + return package, ability_name + + def _make_exclude_list_file(self, request): + filter_list = [] + if "all-test-file-exclude-filter" in self.config.testargs: + json_file_list = self.config.testargs.get( + "all-test-file-exclude-filter") + self.config.testargs.pop("all-test-file-exclude-filter") + if not json_file_list: + LOG.debug("all-test-file-exclude-filter value is empty!") + else: + if not os.path.isfile(json_file_list[0]): + LOG.warning( + "[{}] is not a valid file".format(json_file_list[0])) + return [] + file_open = os.open(json_file_list[0], os.O_RDONLY, + stat.S_IWUSR | stat.S_IRUSR) + with os.fdopen(file_open, "r") as file_handler: + json_data = json.load(file_handler) + exclude_list = json_data.get( + DeviceTestType.jsunit_test, []) + + for exclude in exclude_list: + if request.get_module_name() not in exclude: + continue + filter_list.extend(exclude.get(request.get_module_name())) + return filter_list + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.ltp_posix_test) +class LTPPosixTestDriver(IDriver): + def __init__(self): + self.timeout = 80 * 1000 + self.start_time = None + self.result = "" + self.error_message = "" + self.kits = [] + self.config = None + self.handler = None + # log + self.hilog = None + self.log_proc = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + LOG.debug("Start execute xdevice extension LTP Posix Test") + self.result = os.path.join( + request.config.report_path, "result", + '.'.join((request.get_module_name(), "xml"))) + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + suite_file = request.root.source.source_file + + if not suite_file: + raise ParamError( + "test source '%s' not exists" % + request.root.source.source_string, error_no="00110") + + LOG.debug("Test case file path: %s" % suite_file) + # avoid hilog service stuck issue + self.config.device.connector_command("shell stop_service hilogd", + timeout=30 * 1000) + self.config.device.connector_command("shell start_service hilogd", + timeout=30 * 1000) + time.sleep(10) + + self.config.device.connector_command("shell hilog -r", timeout=30 * 1000) + self._run_posix(config_file, request) + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=True, error_no="03409") + raise exception + finally: + self.config.device.device_log_collector.remove_log_address(None, self.hilog) + self.config.device.device_log_collector.stop_catch_device_log(self.log_proc) + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def _run_posix(self, config_file, request): + try: + if not os.path.exists(config_file): + LOG.error("Error: Test cases don't exist %s." % config_file) + raise ParamError( + "Error: Test cases don't exist %s." % config_file, + error_no="00102") + + json_config = JsonParser(config_file) + self.kits = get_kit_instances(json_config, + self.config.resource_path, + self.config.testcases_path) + self.config.device.connector_command("target mount") + test_list = None + dst = None + for kit in self.kits: + test_list, dst = kit.__setup__(request.config.device, + request=request) + # apply execute right + self.config.device.connector_command("shell chmod -R 777 {}".format(dst)) + + self.hilog = get_device_log_file( + request.config.report_path, + request.config.device.__get_serial__() + "_" + request. + get_module_name(), + "device_hilog") + + hilog_open = os.open(self.hilog, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + 0o755) + self.config.device.device_log_collector.add_log_address(None, self.hilog) + with os.fdopen(hilog_open, "a") as hilog_file_pipe: + _, self.log_proc = self.config.device.device_log_collector.\ + start_catch_device_log(hilog_file_pipe=hilog_file_pipe) + if hasattr(self.config, "history_report_path") and \ + self.config.testargs.get("test"): + self._do_test_retry(request, self.config.testargs) + else: + self._do_test_run(request, test_list) + finally: + do_module_kit_teardown(request) + + def _do_test_retry(self, request, testargs): + un_pass_list = [] + for test in testargs.get("test"): + test_item = test.split("#") + if len(test_item) != 2: + continue + un_pass_list.append(test_item[1]) + LOG.debug("LTP Posix un pass list: [{}]".format(un_pass_list)) + self._do_test_run(request, un_pass_list) + + def _do_test_run(self, request, test_list): + for test_bin in test_list: + if not test_bin.endswith(".run-test"): + continue + listeners = request.listeners + for listener in listeners: + listener.device_sn = self.config.device.device_sn + parsers = get_plugin(Plugin.PARSER, + "OpenSourceTest") + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suite_name = request.root.source. \ + test_name + parser_instance.test_name = test_bin.replace("./", "") + parser_instance.listeners = listeners + parser_instances.append(parser_instance) + self.handler = ShellHandler(parser_instances) + self.handler.add_process_method(_ltp_output_method) + result_message = self.config.device.connector_command( + "shell {}".format(test_bin)) + LOG.info("get result from command {}". + format(result_message)) + process_command_ret(result_message, self.handler) + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +def _lock_screen(device): + device.execute_shell_command("svc power stayon false") + time.sleep(1) + + +def _sleep_according_to_result(result): + if result: + time.sleep(1) + + +def _ltp_output_method(handler, output, end_mark="\n"): + content = output + if handler.unfinished_line: + content = "".join((handler.unfinished_line, content)) + handler.unfinished_line = "" + lines = content.split(end_mark) + if content.endswith(end_mark): + # get rid of the tail element of this list contains empty str + return lines[:-1] + else: + handler.unfinished_line = lines[-1] + # not return the tail element of this list contains unfinished str, + # so we set position -1 + return lines \ No newline at end of file diff --git a/xdevice/plugins/ohos/src/ohos/drivers/drivers_lite.py b/xdevice/plugins/ohos/src/ohos/drivers/drivers_lite.py new file mode 100644 index 0000000..9e3d7c0 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/drivers/drivers_lite.py @@ -0,0 +1,1203 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import shutil +import glob +import time +import stat + +from xdevice import UserConfigManager +from xdevice import ConfigConst +from xdevice import ExecuteTerminate +from xdevice import ParamError + +from xdevice import TestDescription +from xdevice import IDriver +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import platform_logger +from xdevice import DataHelper +from xdevice import JsonParser +from xdevice import get_config_value +from xdevice import get_kit_instances +from xdevice import check_result_report +from xdevice import get_device_log_file +from xdevice import get_filename_extension +from xdevice import get_file_absolute_path +from xdevice import get_test_component_version +from xdevice import SuiteReporter +from xdevice import ShellHandler +from xdevice import FilePermission +from xdevice import DeviceTestType +from xdevice import GTestConst +from xdevice import DeviceLabelType +from ohos.constants import ComType +from ohos.constants import ParserType +from ohos.constants import DeviceLiteKernel +from ohos.constants import CKit +from ohos.exception import LiteDeviceError +from ohos.exception import LiteDeviceExecuteCommandError +from ohos.executor.listener import CollectingLiteGTestListener +from ohos.testkit.kit_lite import DeployKit +from ohos.testkit.kit_lite import DeployToolKit +from ohos.environment.dmlib_lite import generate_report + +__all__ = ["CppTestDriver", "CTestDriver", "init_remote_server"] +LOG = platform_logger("DriversLite") +FAILED_RUN_TEST_ATTEMPTS = 2 +CPP_TEST_MOUNT_STOP_SIGN = "not mount properly, Test Stop" +CPP_TEST_NFS_SIGN = "execve: I/O error" + + +def get_nfs_server(request): + config_manager = UserConfigManager( + config_file=request.get(ConfigConst.configfile, ""), + env=request.get(ConfigConst.test_environment, "")) + remote_info = config_manager.get_user_config("testcases/server", + filter_name="NfsServer") + if not remote_info: + err_msg = "The name of remote nfs server does not match" + LOG.error(err_msg, error_no="00403") + raise ParamError(err_msg, error_no="00403") + return remote_info + + +def init_remote_server(lite_instance, request=None): + config_manager = UserConfigManager( + config_file=request.get(ConfigConst.configfile, ""), + env=request.get(ConfigConst.test_environment, "")) + linux_dict = config_manager.get_user_config("testcases/server") + + if linux_dict: + setattr(lite_instance, "linux_host", linux_dict.get("ip")) + setattr(lite_instance, "linux_port", linux_dict.get("port")) + setattr(lite_instance, "linux_directory", linux_dict.get("dir")) + + else: + error_message = "nfs server does not exist, please " \ + "check user_config.xml" + raise ParamError(error_message, error_no="00108") + + +def get_testcases(testcases_list): + cases_list = [] + for test in testcases_list: + test_item = test.split("#") + if len(test_item) == 1: + cases_list.append(test) + elif len(test_item) == 2: + cases_list.append(test_item[-1]) + return cases_list + + +def sort_by_length(file_name): + return len(file_name) + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.cpp_test_lite) +class CppTestDriver(IDriver): + """ + CppTest is a test that runs a native test package on given lite device. + """ + config = None + result = "" + error_message = "" + + def __init__(self): + self.rerun = True + self.file_name = "" + + def __check_environment__(self, device_options): + if len(device_options) != 1 or \ + device_options[0].label != DeviceLabelType.ipcamera: + self.error_message = "check environment failed" + return False + return True + + def __check_config__(self, config=None): + pass + + def __execute__(self, request): + kits = [] + device_log_file = get_device_log_file( + request.config.report_path, + request.get_devices()[0].__get_serial__()) + try: + self.config = request.config + self.init_cpp_config() + self.config.device = request.config.environment.devices[0] + init_remote_server(self, request=request) + config_file = request.root.source.config_file + json_config = JsonParser(config_file) + self._get_driver_config(json_config) + + bin_file = get_config_value('execute', json_config.get_driver(), + False) + kits = get_kit_instances(json_config, + request.config.resource_path, + request.config.testcases_path) + from xdevice import Scheduler + for kit in kits: + if not Scheduler.is_execute: + raise ExecuteTerminate("ExecuteTerminate", + error_no="00300") + kit.__setup__(request.config.device, request=request) + + command = self._get_execute_command(bin_file) + + self.set_file_name(request, command) + + if self.config.xml_output: + self.delete_device_xml(request, self.config.device_xml_path) + if os.path.exists(self.result): + os.remove(self.result) + if request.config.testargs.get("dry_run"): + self.config.dry_run = request.config.testargs.get( + "dry_run")[0].lower() + self.dry_run(command, request.listeners) + else: + self.run_cpp_test(command, request) + self.generate_device_xml(request, self.execute_bin) + + except (LiteDeviceError, Exception) as exception: + LOG.exception(exception, exc_info=False) + self.error_message = exception + finally: + device_log_file_open = os.open(device_log_file, os.O_WRONLY | + os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + with os.fdopen(device_log_file_open, "a") as file_name: + file_name.write(self.config.command_result) + file_name.flush() + LOG.info("-------------finally-----------------") + self._after_command(kits, request) + + def _get_execute_command(self, bin_file): + if self.config.device.get("device_kernel") == \ + DeviceLiteKernel.linux_kernel: + execute_dir = "/storage" + "/".join(bin_file.split("/")[0:-1]) + else: + execute_dir = "/".join(bin_file.split("/")[0:-1]) + self.execute_bin = bin_file.split("/")[-1] + + self.config.device.execute_command_with_timeout( + command="cd {}".format(execute_dir), timeout=1) + self.config.execute_bin_path = execute_dir + + if self.execute_bin.startswith("/"): + command = ".%s" % self.execute_bin + else: + command = "./%s" % self.execute_bin + + report_path = "/%s/%s/" % ("reports", self.execute_bin.split(".")[0]) + self.config.device_xml_path = (self.linux_directory + report_path). \ + replace("//", "/") + self.config.device_report_path = execute_dir + report_path + + return command + + def _get_driver_config(self, json_config): + xml_output = get_config_value('xml-output', + json_config.get_driver(), False) + + if isinstance(xml_output, bool): + self.config.xml_output = xml_output + elif str(xml_output).lower() == "false": + self.config.xml_output = False + else: + self.config.xml_output = True + + rerun = get_config_value('rerun', json_config.get_driver(), False) + if isinstance(rerun, bool): + self.rerun = rerun + elif str(rerun).lower() == "false": + self.rerun = False + else: + self.rerun = True + + timeout_config = get_config_value('timeout', + json_config.get_driver(), False) + if timeout_config: + self.config.timeout = int(timeout_config) // 1000 + else: + self.config.timeout = 900 + + def _after_command(self, kits, request): + if self.config.device.get("device_kernel") == \ + DeviceLiteKernel.linux_kernel: + self.config.device.execute_command_with_timeout( + command="cd /storage", timeout=1) + else: + self.config.device.execute_command_with_timeout( + command="cd /", timeout=1) + for kit in kits: + kit.__teardown__(request.config.device) + self.config.device.close() + self.delete_device_xml(request, self.linux_directory) + + report_name = "report" if request.root.source. \ + test_name.startswith("{") else get_filename_extension( + request.root.source.test_name)[0] + if not self.config.dry_run: + self.result = check_result_report( + request.config.report_path, self.result, self.error_message, + report_name) + + def generate_device_xml(self, request, execute_bin): + if self.config.xml_output: + self.download_nfs_xml(request, self.config.device_xml_path) + self.merge_xml(execute_bin) + + def dry_run(self, request, command, listener=None): + if self.config.xml_output: + collect_test_command = "%s --gtest_output=xml:%s " \ + "--gtest_list_tests" % \ + (command, self.config.device_report_path) + result, _, _ = self.config.device.execute_command_with_timeout( + command=collect_test_command, + case_type=DeviceTestType.cpp_test_lite, + timeout=15, receiver=None) + if CPP_TEST_MOUNT_STOP_SIGN in result: + tests = [] + return tests + tests = self.read_nfs_xml(request, self.config.device_xml_path) + self.delete_device_xml(request, self.config.device_xml_path) + return tests + + else: + parsers = get_plugin(Plugin.PARSER, ParserType.cpp_test_list_lite) + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = os.path.basename(self.result) + if listener: + parser_instance.listeners = listener + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + + collect_test_command = "%s --gtest_list_tests" % command + result, _, _ = self.config.device.execute_command_with_timeout( + command=collect_test_command, + case_type=DeviceTestType.cpp_test_lite, + timeout=15, receiver=handler) + self.config.command_result = "{}{}".format( + self.config.command_result, result) + if parser_instances[0].tests and \ + len(parser_instances[0].tests) > 0: + SuiteReporter.set_suite_list([item.test_name for item in + parser_instances[0].tests]) + else: + SuiteReporter.set_suite_list([]) + tests = parser_instances[0].tests + if not tests: + LOG.error("Collect test failed!", error_no="00402") + return parser_instances[0].tests + + def run_cpp_test(self, command, request): + if request.config.testargs.get("test"): + testcases_list = get_testcases( + request.config.testargs.get("test")) + for test in testcases_list: + command_case = "{} --gtest_filter=*{}".format( + command, test) + + if not self.config.xml_output: + self.run(command_case, request.listeners, timeout=15) + else: + command_case = "{} --gtest_output=xml:{}".format( + command_case, self.config.device_report_path) + self.run(command_case, None, timeout=15) + else: + self._do_test_run(command, request) + + def init_cpp_config(self): + setattr(self.config, "command_result", "") + setattr(self.config, "device_xml_path", "") + setattr(self.config, "dry_run", False) + + def merge_xml(self, execute_bin): + report_path = os.path.join(self.config.report_path, "result") + summary_result = DataHelper.get_summary_result( + report_path, self.result, key=sort_by_length, + file_prefix=execute_bin) + if summary_result: + SuiteReporter.append_report_result(( + os.path.join(report_path, "%s.xml" % execute_bin), + DataHelper.to_string(summary_result))) + else: + self.error_message = "The test case did not generate XML" + for xml_file in os.listdir(os.path.split(self.result)[0]): + if not xml_file.startswith(execute_bin): + continue + if xml_file != os.path.split(self.result)[1]: + os.remove(os.path.join(os.path.split( + self.result)[0], xml_file)) + + def set_file_name(self, request, command): + self.file_name = command.split(" ")[0].split("/")[-1].split(".")[0] + self.result = "%s.xml" % os.path.join(request.config.report_path, + "result", self.file_name) + + def run(self, command=None, listener=None, timeout=None): + if not timeout: + timeout = self.config.timeout + if listener: + parsers = get_plugin(Plugin.PARSER, ParserType.cpp_test_lite) + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suite_name = self.file_name + parser_instance.listeners = listener + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + else: + handler = None + result, _, error = self.config.device.execute_command_with_timeout( + command=command, case_type=DeviceTestType.cpp_test_lite, + timeout=timeout, receiver=handler) + self.config.command_result += result + if result.count(CPP_TEST_NFS_SIGN) >= 1: + _, _, error = self.config.device.execute_command_with_timeout( + command="ping %s" % self.linux_host, + case_type=DeviceTestType.cpp_test_lite, + timeout=5) + return error, result, handler + + def _do_test_run(self, command, request): + test_to_run = self._collect_test_to_run(request, command) + self._run_with_rerun(command, request, test_to_run) + + def _run_with_rerun(self, command, request, expected_tests): + if self.config.xml_output: + self.run("{} --gtest_output=xml:{}".format( + command, self.config.device_report_path)) + time.sleep(5) + test_rerun = True + if self.check_xml_exist(self.execute_bin + ".xml"): + test_rerun = False + test_run = self.read_nfs_xml(request, + self.config.device_xml_path, + test_rerun) + if len(test_run) < len(expected_tests): + expected_tests = TestDescription.remove_test(expected_tests, + test_run) + self._rerun_tests(command, expected_tests, None) + else: + test_tracker = CollectingLiteGTestListener() + listener = request.listeners + listener_copy = listener.copy() + listener_copy.append(test_tracker) + self.run(command, listener_copy) + test_run = test_tracker.get_current_run_results() + if len(test_run) != len(expected_tests): + expected_tests = TestDescription.remove_test(expected_tests, + test_run) + self._rerun_tests(command, expected_tests, listener) + + def _rerun_tests(self, command, expected_tests, listener): + if not expected_tests: + LOG.debug("No tests to re-run, all tests executed at least once.") + for test in expected_tests: + self._re_run(command, test, listener) + + def _re_run(self, command, test, listener): + if self.config.xml_output: + _, _, handler = self.run("{} {}=*{} --gtest_output=xml:{}".format( + command, GTestConst.exec_para_filter, test.test_name, + self.config.device_report_path), + listener, timeout=15) + else: + handler = None + for _ in range(FAILED_RUN_TEST_ATTEMPTS): + try: + listener_copy = listener.copy() + test_tracker = CollectingLiteGTestListener() + listener_copy.append(test_tracker) + _, _, handler = self.run("{} {}=*{}".format( + command, GTestConst.exec_para_filter, test.test_name), + listener_copy, timeout=15) + if len(test_tracker.get_current_run_results()): + return + except LiteDeviceError as _: + LOG.debug("Exception: ShellCommandUnresponsiveException") + handler.parsers[0].mark_test_as_failed(test) + + def _collect_test_to_run(self, request, command): + if self.rerun: + tests = self.dry_run(request, command) + return tests + return [] + + def download_nfs_xml(self, request, report_path): + remote_nfs = get_nfs_server(request) + if not remote_nfs: + err_msg = "The name of remote device {} does not match". \ + format(self.remote) + LOG.error(err_msg, error_no="00403") + raise TypeError(err_msg) + LOG.info("Trying to pull remote server: {}:{} report files to local " + "in dir {}".format + (remote_nfs.get("ip"), remote_nfs.get("port"), + os.path.dirname(self.result))) + result_dir = os.path.join(request.config.report_path, "result") + os.makedirs(result_dir, exist_ok=True) + try: + if remote_nfs["remote"] == "true": + import paramiko + client = paramiko.Transport((remote_nfs.get("ip"), + int(remote_nfs.get("port")))) + client.connect(username=remote_nfs.get("username"), + password=remote_nfs.get("password")) + sftp = paramiko.SFTPClient.from_transport(client) + files = sftp.listdir(report_path) + + for report_xml in files: + if report_xml.endswith(".xml"): + filepath = report_path + report_xml + try: + sftp.get(remotepath=filepath, + localpath=os.path.join(os.path.split( + self.result)[0], report_xml)) + except IOError as error: + LOG.error(error, error_no="00404") + client.close() + else: + if os.path.isdir(report_path): + for report_xml in os.listdir(report_path): + if report_xml.endswith(".xml"): + filepath = report_path + report_xml + shutil.copy(filepath, + os.path.join(os.path.split( + self.result)[0], report_xml)) + except (FileNotFoundError, IOError) as error: + LOG.error("Download xml failed %s" % error, error_no="00403") + + def check_xml_exist(self, xml_file, timeout=60): + ls_command = "ls %s" % self.config.device_report_path + start_time = time.time() + while time.time() - start_time < timeout: + result, _, _ = self.config.device.execute_command_with_timeout( + command=ls_command, case_type=DeviceTestType.cpp_test_lite, + timeout=5, receiver=None) + if xml_file in result: + return True + time.sleep(5) + if (self.execute_bin + "_1.xml") in result: + return False + return False + + def read_nfs_xml(self, request, report_path, is_true=False): + remote_nfs = get_nfs_server(request) + if not remote_nfs: + err_msg = "The name of remote device {} does not match". \ + format(self.remote) + LOG.error(err_msg, error_no="00403") + raise TypeError(err_msg) + tests = [] + execute_bin_xml = (self.execute_bin + "_1.xml") if is_true else ( + self.execute_bin + ".xml") + LOG.debug("run into :{}".format(is_true)) + file_path = os.path.join(report_path, execute_bin_xml) + if not self.check_xml_exist(execute_bin_xml): + return tests + + from xml.etree import ElementTree + try: + if remote_nfs["remote"] == "true": + import paramiko + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(hostname=remote_nfs.get("ip"), + port=int(remote_nfs.get("port")), + username=remote_nfs.get("username"), + password=remote_nfs.get("password")) + sftp_client = client.open_sftp() + remote_file = sftp_client.open(file_path) + try: + result = remote_file.read().decode() + suites_element = ElementTree.fromstring(result) + for suite_element in suites_element: + suite_name = suite_element.get("name", "") + for case in suite_element: + case_name = case.get("name") + test = TestDescription(suite_name, case_name) + if test not in tests: + tests.append(test) + finally: + remote_file.close() + client.close() + else: + if os.path.isdir(report_path): + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(file_path, flags, modes), + "r") as test_file: + result = test_file.read() + suites_element = ElementTree.fromstring(result) + for suite_element in suites_element: + suite_name = suite_element.get("name", "") + for case in suite_element: + case_name = case.get("name") + test = TestDescription(suite_name, case_name) + if test not in tests: + tests.append(test) + except (FileNotFoundError, IOError) as error: + LOG.error("Download xml failed %s" % error, error_no="00403") + except SyntaxError as error: + LOG.error("Parse xml failed %s" % error, error_no="00404") + return tests + + def delete_device_xml(self, request, report_path): + remote_nfs = get_nfs_server(request) + if not remote_nfs: + err_msg = "The name of remote device {} does not match". \ + format(self.remote) + LOG.error(err_msg, error_no="00403") + raise TypeError(err_msg) + LOG.info("Delete xml directory {} from remote server: {}" + "".format + (report_path, remote_nfs.get("ip"))) + if remote_nfs["remote"] == "true": + import paramiko + client = paramiko.Transport((remote_nfs.get("ip"), + int(remote_nfs.get("port")))) + client.connect(username=remote_nfs.get("username"), + password=remote_nfs.get("password")) + sftp = paramiko.SFTPClient.from_transport(client) + try: + sftp.stat(report_path) + files = sftp.listdir(report_path) + for report_xml in files: + if report_xml.endswith(".xml"): + filepath = "{}{}".format(report_path, report_xml) + try: + sftp.remove(filepath) + time.sleep(0.5) + except IOError as _: + pass + except FileNotFoundError as _: + pass + client.close() + else: + for report_xml in glob.glob(os.path.join(report_path, '*.xml')): + try: + os.remove(report_xml) + except Exception as exception: + LOG.error( + "remove {} Failed:{}".format(report_xml, exception)) + pass + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.ctest_lite) +class CTestDriver(IDriver): + """ + CTest is a test that runs a native test package on given lite device. + """ + config = None + result = "" + error_message = "" + version_cmd = "AT+CSV" + + def __init__(self): + self.file_name = "" + self.run_third = False + self.kit_type = None + self.auto_deploy = None + + def __check_environment__(self, device_options): + if len(device_options) != 1 or \ + device_options[0].label != DeviceLabelType.wifiiot: + self.error_message = "check environment failed" + return False + return True + + def __check_config__(self, config=None): + del config + self.config = None + + def __execute__(self, request): + from xdevice import Variables + try: + self.config = request.config + self.config.device = request.config.environment.devices[0] + current_dir = request.config.resource_path if \ + request.config.resource_path else Variables.exec_dir + if request.root.source.source_file.strip(): + source = os.path.join(current_dir, + request.root.source.source_file.strip()) + self.file_name = os.path.basename( + request.root.source.source_file.strip()).split(".")[0] + else: + source = request.root.source.source_string.strip() + json_config = JsonParser(source) + kit_instances = get_kit_instances(json_config, + request.config.resource_path, + request.config.testcases_path) + for (_, kit_info) in zip(kit_instances, json_config.get_kits()): + self.auto_deploy = kit_info.get("auto_deploy", "") + self.kit_type = kit_info.get("type", "") + if self.kit_type == CKit.deploytool: + self.run_third = True + LOG.info("Kit type:{}".format(self.kit_type)) + LOG.info("Auto deploy:{}".format(self.auto_deploy)) + LOG.info("Run third:{}".format(self.run_third)) + if self.run_third: + LOG.info("Run the third-party vendor part") + if self.auto_deploy in ["False", "flase"]: + self._run_ctest_third_party(source=source, request=request) + else: + self._run_ctest_upgrade_party(source=source, request=request) + else: + LOG.debug("Run ctest") + self._run_ctest(source=source, request=request) + + except (LiteDeviceExecuteCommandError, Exception) as exception: + LOG.error(exception, error_no=getattr(exception, "error_no", + "00000")) + self.error_message = exception + finally: + report_name = "report" if request.root.source. \ + test_name.startswith("{") else get_filename_extension( + request.root.source.test_name)[0] + self.result = check_result_report( + request.config.report_path, self.result, self.error_message, + report_name) + + def _run_ctest(self, source=None, request=None, timeout=90): + parser_instances = [] + parsers = get_plugin(Plugin.PARSER, ParserType.ctest_lite) + try: + if not source: + LOG.error("Error: source don't exist %s." % source, + error_no="00101") + return + + version = get_test_component_version(self.config) + + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = self.file_name + parser_instance.product_info.setdefault("Version", version) + parser_instance.listeners = request.listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + + reset_cmd = self._reset_device(request, source) + self.result = "%s.xml" % os.path.join( + request.config.report_path, "result", self.file_name) + self.config.device.device.com_dict.get( + ComType.deploy_com).connect() + result, _, error = self.config.device.device. \ + execute_command_with_timeout( + command=reset_cmd, case_type=DeviceTestType.ctest_lite, + key=ComType.deploy_com, timeout=timeout, receiver=handler) + device_log_file = get_device_log_file(request.config.report_path, + request.config.device. + __get_serial__()) + device_log_file_open = \ + os.open(device_log_file, os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + with os.fdopen(device_log_file_open, "a") as file_name: + file_name.write("{}{}".format( + "\n".join(result.split("\n")[0:-1]), "\n")) + file_name.flush() + finally: + self.config.device.device.com_dict.get( + ComType.deploy_com).close() + + def _run_ctest_third_party(self, source=None, request=None, timeout=5): + parser_instances = [] + parsers = get_plugin(Plugin.PARSER, ParserType.ctest_lite) + try: + if not source: + LOG.error("Error: source don't exist %s." % source, + error_no="00101") + return + + version = get_test_component_version(self.config) + + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = self.file_name + parser_instance.product_info.setdefault("Version", version) + parser_instance.listeners = request.listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + + while True: + input_burning = input("Please enter 'y' or 'n' " + "after the burning is complete," + "enter 'quit' to exit:") + if input_burning.lower().strip() in ["y", "yes"]: + LOG.info("Burning succeeded.") + break + elif input_burning.lower().strip() in ["n", "no"]: + LOG.info("Burning failed.") + elif input_burning.lower().strip() == "quit": + break + else: + LOG.info({"The input parameter is incorrect,please" + " enter 'y' or 'n' after the burning is " + "complete,enter 'quit' to exit."}) + LOG.info("Please press the reset button on the device ") + time.sleep(5) + self.result = "%s.xml" % os.path.join( + request.config.report_path, "result", self.file_name) + self.config.device.device.com_dict.get( + ComType.deploy_com).connect() + + LOG.debug("Device com:{}".format(self.config.device.device)) + result, _, error = self.config.device.device. \ + execute_command_with_timeout( + command=[], case_type=DeviceTestType.ctest_lite, + key=ComType.deploy_com, timeout=timeout, receiver=handler) + device_log_file = get_device_log_file(request.config.report_path, + request.config.device. + __get_serial__()) + device_log_file_open = \ + os.open(device_log_file, os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + with os.fdopen(device_log_file_open, "a") as file_name: + file_name.write("{}{}".format( + "\n".join(result.split("\n")[0:-1]), "\n")) + file_name.flush() + finally: + self.config.device.device.com_dict.get( + ComType.deploy_com).close() + + def _run_ctest_upgrade_party(self, source=None, request=None, timeout=90): + parser_instances = [] + parsers = get_plugin(Plugin.PARSER, ParserType.ctest_lite) + try: + if not source: + LOG.error("Error: source don't exist %s." % source, + error_no="00101") + return + + version = get_test_component_version(self.config) + + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = self.file_name + parser_instance.product_info.setdefault("Version", version) + parser_instance.listeners = request.listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + result = self._reset_third_device(request, source) + self.result = "%s.xml" % os.path.join( + request.config.report_path, "result", self.file_name) + if isinstance(result, list): + self.config.device.device.com_dict.get( + ComType.deploy_com).connect() + LOG.debug("Device com:{}".format(self.config.device.device)) + result, _, error = self.config.device.device. \ + execute_command_with_timeout( + command=request, case_type=DeviceTestType.ctest_lite, + key=ComType.deploy_com, timeout=timeout, receiver=handler) + else: + handler.__read__(result) + handler.__done__() + device_log_file = get_device_log_file(request.config.report_path, + request.config.device. + __get_serial__()) + device_log_file_open = \ + os.open(device_log_file, os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + with os.fdopen(device_log_file_open, "a") as file_name: + file_name.write("{}{}".format( + "\n".join(result.split("\n")[0:-1]), "\n")) + file_name.flush() + finally: + self.config.device.device.com_dict.get( + ComType.deploy_com).close() + + def _reset_device(self, request, source): + json_config = JsonParser(source) + reset_cmd = [] + kit_instances = get_kit_instances(json_config, + request.config.resource_path, + request.config.testcases_path) + from xdevice import Scheduler + for (kit_instance, kit_info) in zip(kit_instances, + json_config.get_kits()): + if not isinstance(kit_instance, DeployKit): + continue + if not self.file_name: + self.file_name = get_config_value( + 'burn_file', kit_info)[0].split("\\")[-1].split(".")[0] + reset_cmd = kit_instance.burn_command + if not Scheduler.is_execute: + raise ExecuteTerminate("ExecuteTerminate", + error_no="00300") + kit_instance.__setup__( + self.config.device) + reset_cmd = [int(item, 16) for item in reset_cmd] + return reset_cmd + + def _reset_third_device(self, request, source): + json_config = JsonParser(source) + reset_cmd = [] + kit_instances = get_kit_instances(json_config, + request.config.resource_path, + request.config.testcases_path) + from xdevice import Scheduler + for (kit_instance, kit_info) in zip(kit_instances, + json_config.get_kits()): + if not isinstance(kit_instance, DeployToolKit): + continue + if not self.file_name: + self.file_name = get_config_value( + 'burn_file', kit_info)[0].split("\\")[-1].split(".")[0] + if not Scheduler.is_execute: + raise ExecuteTerminate("ExecuteTerminate", + error_no="00300") + reset_cmd = kit_instance.__setup__( + self.config.device) + return reset_cmd + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.open_source_test) +class OpenSourceTestDriver(IDriver): + """ + OpenSourceTest is a test that runs a native test package on given + device lite device. + """ + config = None + result = "" + error_message = "" + has_param = False + + def __init__(self): + self.rerun = True + self.file_name = "" + self.handler = None + + def __check_environment__(self, device_options): + if len(device_options) != 1 or \ + device_options[0].label != DeviceLabelType.ipcamera: + self.error_message = "check environment failed" + return False + return True + + def __check_config__(self, config=None): + pass + + def __execute__(self, request): + kits = [] + try: + self.config = request.config + setattr(self.config, "command_result", "") + self.config.device = request.config.environment.devices[0] + init_remote_server(self, request) + config_file = request.root.source.config_file + json_config = JsonParser(config_file) + pre_cmd = get_config_value('pre_cmd', json_config.get_driver(), + False) + execute_dir = get_config_value('execute', json_config.get_driver(), + False) + kits = get_kit_instances(json_config, + request.config.resource_path, + request.config.testcases_path) + from xdevice import Scheduler + for kit in kits: + if not Scheduler.is_execute: + raise ExecuteTerminate("ExecuteTerminate", + error_no="00300") + copy_list = kit.__setup__(request.config.device, + request=request) + + self.file_name = request.root.source.test_name + self.set_file_name(request, request.root.source.test_name) + self.config.device.execute_command_with_timeout( + command=pre_cmd, timeout=1) + self.config.device.execute_command_with_timeout( + command="cd {}".format(execute_dir), timeout=1) + device_log_file = get_device_log_file( + request.config.report_path, + request.config.device.__get_serial__()) + device_log_file_open = \ + os.open(device_log_file, os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + with os.fdopen(device_log_file_open, "a") as file_name: + for test_bin in copy_list: + if not test_bin.endswith(".run-test"): + continue + if test_bin.startswith("/"): + command = ".%s" % test_bin + else: + command = "./%s" % test_bin + self._do_test_run(command, request) + file_name.write(self.config.command_result) + file_name.flush() + + except (LiteDeviceExecuteCommandError, Exception) as exception: + LOG.error(exception, error_no=getattr(exception, "error_no", + "00000")) + self.error_message = exception + finally: + LOG.info("-------------finally-----------------") + # umount the dirs already mount + for kit in kits: + kit.__teardown__(request.config.device) + self.config.device.close() + report_name = "report" if request.root.source. \ + test_name.startswith("{") else get_filename_extension( + request.root.source.test_name)[0] + self.result = check_result_report( + request.config.report_path, self.result, self.error_message, + report_name) + + def set_file_name(self, request, bin_file): + self.result = "%s.xml" % os.path.join( + request.config.report_path, "result", bin_file) + + def run(self, command=None, listener=None, timeout=20): + parsers = get_plugin(Plugin.PARSER, + ParserType.open_source_test) + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suite_name = self.file_name + parser_instance.test_name = command.replace("./", "") + parser_instance.listeners = listener + parser_instances.append(parser_instance) + self.handler = ShellHandler(parser_instances) + for _ in range(3): + result, _, error = self.config.device.execute_command_with_timeout( + command=command, case_type=DeviceTestType.open_source_test, + timeout=timeout, receiver=self.handler) + self.config.command_result = result + if "pass" in result.lower(): + break + return error, result, self.handler + + def _do_test_run(self, command, request): + listeners = request.listeners + for listener in listeners: + listener.device_sn = self.config.device.device_sn + error, _, _ = self.run(command, listeners, timeout=60) + if error: + LOG.error( + "Execute %s failed" % command, error_no="00402") + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.build_only_test) +class BuildOnlyTestDriver(IDriver): + """ + BuildOnlyTest is a test that runs a native test package on given + device lite device. + """ + config = None + result = "" + error_message = "" + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + self.config = request.config + self.config.device = request.config.environment.devices[0] + self.file_name = request.root.source.test_name + self.config_file = request.root.source.config_file + self.testcases_path = request.config.testcases_path + file_path = self._get_log_file() + result_list = self._get_result_list(file_path) + if len(result_list) == 0: + LOG.error( + "Error: source don't exist %s." % request.root.source. + source_file, error_no="00101") + return + total_result = '' + for result in result_list: + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(result, flags, modes), "r", + encoding="utf-8") as file_content: + result = file_content.read() + if not result.endswith('\n'): + result = '%s\n' % result + total_result = '{}{}'.format(total_result, result) + parsers = get_plugin(Plugin.PARSER, ParserType.build_only_test) + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suite_name = self.file_name + parser_instance.listeners = request.listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + generate_report(handler, total_result) + + @classmethod + def _get_result_list(cls, file_path): + result_list = list() + for root_path, dirs_path, file_names in os.walk(file_path): + for file_name in file_names: + if file_name == "logfile": + result_list.append(os.path.join(root_path, file_name)) + return result_list + + def _get_log_file(self): + json_config = JsonParser(self.config_file) + log_path = get_config_value('log_path', json_config.get_driver(), + False) + log_path = str(log_path.replace("/", "", 1)) if log_path.startswith( + "/") else str(log_path) + LOG.debug("The log path is:%s" % log_path) + file_path = get_file_absolute_path(log_path, + paths=[self.testcases_path]) + LOG.debug("The file path is:%s" % file_path) + return file_path + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.jsunit_test_lite) +class JSUnitTestLiteDriver(IDriver): + """ + JSUnitTestDriver is a Test that runs a native test package on given device. + """ + + def __init__(self): + self.result = "" + self.error_message = "" + self.kits = [] + self.config = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def _get_driver_config(self, json_config): + bundle_name = get_config_value('bundle-name', + json_config.get_driver(), False) + if not bundle_name: + raise ParamError("Can't find bundle-name in config file.", + error_no="00108") + else: + self.config.bundle_name = bundle_name + + ability = get_config_value('ability', + json_config.get_driver(), False) + if not ability: + self.config.ability = "default" + else: + self.config.ability = ability + + def __execute__(self, request): + try: + LOG.debug("Start execute xdevice extension JSUnit Test") + + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + suite_file = request.root.source.source_file + + if not suite_file: + raise ParamError( + "test source '%s' not exists" % + request.root.source.source_string, error_no="00101") + + if not os.path.exists(config_file): + LOG.error("Error: Test cases don't exist %s." % config_file, + error_no="00101") + raise ParamError( + "Error: Test cases don't exist %s." % config_file, + error_no="00101") + + self.file_name = os.path.basename( + request.root.source.source_file.strip()).split(".")[0] + + self.result = "%s.xml" % os.path.join( + request.config.report_path, "result", self.file_name) + + json_config = JsonParser(config_file) + self.kits = get_kit_instances(json_config, + self.config.resource_path, + self.config.testcases_path) + + self._get_driver_config(json_config) + from xdevice import Scheduler + for kit in self.kits: + if not Scheduler.is_execute: + raise ExecuteTerminate("ExecuteTerminate", + error_no="00300") + if kit.__class__.__name__ == CKit.liteinstall: + kit.bundle_name = self.config.bundle_name + kit.__setup__(self.config.device, request=request) + + self._run_jsunit(request) + + except Exception as exception: + self.error_message = exception + finally: + report_name = "report" if request.root.source. \ + test_name.startswith("{") else get_filename_extension( + request.root.source.test_name)[0] + + self.result = check_result_report( + request.config.report_path, self.result, self.error_message, + report_name) + + for kit in self.kits: + kit.__teardown__(self.config.device) + self.config.device.close() + + def _run_jsunit(self, request): + parser_instances = [] + parsers = get_plugin(Plugin.PARSER, ParserType.jsuit_test_lite) + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = self.file_name + parser_instance.listeners = request.listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + + command = "./bin/aa start -p %s -n %s" % \ + (self.config.bundle_name, self.config.ability) + result, _, error = self.config.device.execute_command_with_timeout( + command=command, timeout=300, receiver=handler) + device_log_file = get_device_log_file(request.config.report_path, + request.config.device. + __get_serial__()) + device_log_file_open =\ + os.open(device_log_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + with os.fdopen(device_log_file_open, "a") as file_name: + file_name.write("{}{}".format( + "\n".join(result.split("\n")[0:-1]), "\n")) + file_name.flush() + + def __result__(self): + return self.result if os.path.exists(self.result) else "" diff --git a/xdevice/plugins/ohos/src/ohos/drivers/openharmony.py b/xdevice/plugins/ohos/src/ohos/drivers/openharmony.py new file mode 100644 index 0000000..234132f --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/drivers/openharmony.py @@ -0,0 +1,1263 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import time +import json +import stat +import shutil +import re +from datetime import datetime +from enum import Enum + +from xdevice import ConfigConst +from xdevice import ParamError +from xdevice import IDriver +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import JsonParser +from xdevice import ShellHandler +from xdevice import TestDescription +from xdevice import get_device_log_file +from xdevice import check_result_report +from xdevice import get_kit_instances +from xdevice import get_config_value +from xdevice import do_module_kit_setup +from xdevice import do_module_kit_teardown +from xdevice import DeviceTestType +from xdevice import CommonParserType +from xdevice import FilePermission +from xdevice import ResourceManager +from xdevice import get_file_absolute_path +from xdevice import exec_cmd + +from ohos.executor.listener import CollectingPassListener +from ohos.constants import CKit +from ohos.environment.dmlib import process_command_ret + +__all__ = ["OHJSUnitTestDriver", "OHKernelTestDriver", + "OHYaraTestDriver", "oh_jsunit_para_parse"] + +TIME_OUT = 300 * 1000 + +LOG = platform_logger("OpenHarmony") + + +def oh_jsunit_para_parse(runner, junit_paras): + junit_paras = dict(junit_paras) + test_type_list = ["function", "performance", "reliability", "security"] + size_list = ["small", "medium", "large"] + level_list = ["0", "1", "2", "3"] + for para_name in junit_paras.keys(): + para_name = para_name.strip() + para_values = junit_paras.get(para_name, []) + if para_name == "class": + runner.add_arg(para_name, ",".join(para_values)) + elif para_name == "notClass": + runner.add_arg(para_name, ",".join(para_values)) + elif para_name == "testType": + if para_values[0] not in test_type_list: + continue + # function/performance/reliability/security + runner.add_arg(para_name, para_values[0]) + elif para_name == "size": + if para_values[0] not in size_list: + continue + # size small/medium/large + runner.add_arg(para_name, para_values[0]) + elif para_name == "level": + if para_values[0] not in level_list: + continue + # 0/1/2/3/4 + runner.add_arg(para_name, para_values[0]) + elif para_name == "stress": + runner.add_arg(para_name, para_values[0]) + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.oh_kernel_test) +class OHKernelTestDriver(IDriver): + """ + OpenHarmonyKernelTest + """ + def __init__(self): + self.timeout = 30 * 1000 + self.result = "" + self.error_message = "" + self.kits = [] + self.config = None + self.runner = None + # log + self.device_log = None + self.hilog = None + self.log_proc = None + self.hilog_proc = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + LOG.debug("Start to Execute OpenHarmony Kernel Test") + + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + + self.result = "%s.xml" % \ + os.path.join(request.config.report_path, + "result", request.get_module_name()) + self.device_log = get_device_log_file( + request.config.report_path, + request.config.device.__get_serial__(), + "device_log") + + self.hilog = get_device_log_file( + request.config.report_path, + request.config.device.__get_serial__(), + "device_hilog") + + device_log_open = os.open(self.device_log, os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + hilog_open = os.open(self.hilog, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + self.config.device.device_log_collector.add_log_address(self.device_log, self.hilog) + with os.fdopen(device_log_open, "a") as log_file_pipe, \ + os.fdopen(hilog_open, "a") as hilog_file_pipe: + self.log_proc, self.hilog_proc = self.config.device.device_log_collector.\ + start_catch_device_log(log_file_pipe, hilog_file_pipe) + self._run_oh_kernel(config_file, request.listeners, request) + log_file_pipe.flush() + hilog_file_pipe.flush() + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=False, error_no="03409") + raise exception + finally: + do_module_kit_teardown(request) + self.config.device.device_log_collector.remove_log_address(self.device_log, self.hilog) + self.config.device.device_log_collector.stop_catch_device_log(self.log_proc) + self.config.device.device_log_collector.stop_catch_device_log(self.hilog_proc) + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def _run_oh_kernel(self, config_file, listeners=None, request=None): + try: + json_config = JsonParser(config_file) + kits = get_kit_instances(json_config, self.config.resource_path, + self.config.testcases_path) + self._get_driver_config(json_config) + do_module_kit_setup(request, kits) + self.runner = OHKernelTestRunner(self.config) + self.runner.suite_name = request.get_module_name() + self.runner.run(listeners) + finally: + do_module_kit_teardown(request) + + def _get_driver_config(self, json_config): + target_test_path = get_config_value('native-test-device-path', + json_config.get_driver(), False) + test_suite_name = get_config_value('test-suite-name', + json_config.get_driver(), False) + test_suites_list = get_config_value('test-suites-list', + json_config.get_driver(), False) + timeout_limit = get_config_value('timeout-limit', + json_config.get_driver(), False) + conf_file = get_config_value('conf-file', + json_config.get_driver(), False) + self.config.arg_list = {} + if target_test_path: + self.config.target_test_path = target_test_path + if test_suite_name: + self.config.arg_list["test-suite-name"] = test_suite_name + if test_suites_list: + self.config.arg_list["test-suites-list"] = test_suites_list + if timeout_limit: + self.config.arg_list["timeout-limit"] = timeout_limit + if conf_file: + self.config.arg_list["conf-file"] = conf_file + timeout_config = get_config_value('shell-timeout', + json_config.get_driver(), False) + if timeout_config: + self.config.timeout = int(timeout_config) + else: + self.config.timeout = TIME_OUT + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +class OHKernelTestRunner: + def __init__(self, config): + self.suite_name = None + self.config = config + self.arg_list = config.arg_list + + def run(self, listeners): + handler = self._get_shell_handler(listeners) + # hdc shell cd /data/local/tmp/OH_kernel_test; + # sh runtest test -t OpenHarmony_RK3568_config + # -n OpenHarmony_RK3568_skiptest -l 60 + command = "cd %s; chmod +x *; sh runtest test %s" % ( + self.config.target_test_path, self.get_args_command()) + self.config.device.execute_shell_command( + command, timeout=self.config.timeout, receiver=handler, retry=0) + + def _get_shell_handler(self, listeners): + parsers = get_plugin(Plugin.PARSER, CommonParserType.oh_kernel_test) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = self.suite_name + parser_instance.listeners = listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + return handler + + def get_args_command(self): + args_commands = "" + for key, value in self.arg_list.items(): + if key == "test-suite-name" or key == "test-suites-list": + args_commands = "%s -t %s" % (args_commands, value) + elif key == "conf-file": + args_commands = "%s -n %s" % (args_commands, value) + elif key == "timeout-limit": + args_commands = "%s -l %s" % (args_commands, value) + return args_commands + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.oh_jsunit_test) +class OHJSUnitTestDriver(IDriver): + """ + OHJSUnitTestDriver is a Test that runs a native test package on + given device. + """ + + def __init__(self): + self.timeout = 80 * 1000 + self.start_time = None + self.result = "" + self.error_message = "" + self.kits = [] + self.config = None + self.runner = None + self.rerun = True + self.rerun_all = True + # log + self.device_log = None + self.hilog = None + self.log_proc = None + self.hilog_proc = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + LOG.debug("Start execute OpenHarmony JSUnitTest") + self.result = os.path.join( + request.config.report_path, "result", + '.'.join((request.get_module_name(), "xml"))) + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + suite_file = request.root.source.source_file + + if not suite_file: + raise ParamError( + "test source '%s' not exists" % + request.root.source.source_string, error_no="00110") + LOG.debug("Test case file path: %s" % suite_file) + self.config.device.set_device_report_path(request.config.report_path) + self.hilog = get_device_log_file(request.config.report_path, + request.config.device.__get_serial__() + "_" + request. + get_module_name(), + "device_hilog") + + hilog_open = os.open(self.hilog, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + 0o755) + self.config.device.device_log_collector.add_log_address(self.device_log, self.hilog) + self.config.device.execute_shell_command(command="hilog -r") + with os.fdopen(hilog_open, "a") as hilog_file_pipe: + if hasattr(self.config, ConfigConst.device_log) \ + and self.config.device_log.get(ConfigConst.tag_enable) == ConfigConst.device_log_on \ + and hasattr(self.config.device, "clear_crash_log"): + self.config.device.device_log_collector.clear_crash_log() + self.log_proc, self.hilog_proc = self.config.device.device_log_collector.\ + start_catch_device_log(hilog_file_pipe=hilog_file_pipe) + self._run_oh_jsunit(config_file, request) + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=True, error_no="03409") + raise exception + finally: + try: + self._handle_logs(request) + finally: + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def __dry_run_execute__(self, request): + LOG.debug("Start dry run xdevice JSUnit Test") + self.config = request.config + self.config.device = request.config.environment.devices[0] + config_file = request.root.source.config_file + suite_file = request.root.source.source_file + + if not suite_file: + raise ParamError( + "test source '%s' not exists" % + request.root.source.source_string, error_no="00110") + LOG.debug("Test case file path: %s" % suite_file) + self._dry_run_oh_jsunit(config_file, request) + + def _dry_run_oh_jsunit(self, config_file, request): + try: + if not os.path.exists(config_file): + LOG.error("Error: Test cases don't exist %s." % config_file) + raise ParamError( + "Error: Test cases don't exist %s." % config_file, + error_no="00102") + json_config = JsonParser(config_file) + self.kits = get_kit_instances(json_config, + self.config.resource_path, + self.config.testcases_path) + + self._get_driver_config(json_config) + self.config.device.connector_command("target mount") + do_module_kit_setup(request, self.kits) + self.runner = OHJSUnitTestRunner(self.config) + self.runner.suites_name = request.get_module_name() + # execute test case + self._get_runner_config(json_config) + oh_jsunit_para_parse(self.runner, self.config.testargs) + + test_to_run = self._collect_test_to_run() + LOG.info("Collected suite count is: {}, test count is: {}". + format(len(self.runner.expect_tests_dict.keys()), + len(test_to_run) if test_to_run else 0)) + finally: + do_module_kit_teardown(request) + + def _run_oh_jsunit(self, config_file, request): + try: + if not os.path.exists(config_file): + LOG.error("Error: Test cases don't exist %s." % config_file) + raise ParamError( + "Error: Test cases don't exist %s." % config_file, + error_no="00102") + json_config = JsonParser(config_file) + self.kits = get_kit_instances(json_config, + self.config.resource_path, + self.config.testcases_path) + + self._get_driver_config(json_config) + self.config.device.connector_command("target mount") + self._start_smart_perf() + do_module_kit_setup(request, self.kits) + self.runner = OHJSUnitTestRunner(self.config) + self.runner.suites_name = request.get_module_name() + self._get_runner_config(json_config) + if hasattr(self.config, "history_report_path") and \ + self.config.testargs.get("test"): + self._do_test_retry(request.listeners, self.config.testargs) + else: + if self.rerun: + self.runner.retry_times = self.runner.MAX_RETRY_TIMES + # execute test case + self._do_tf_suite() + self._make_exclude_list_file(request) + oh_jsunit_para_parse(self.runner, self.config.testargs) + self._do_test_run(listener=request.listeners) + + finally: + do_module_kit_teardown(request) + + def _get_driver_config(self, json_config): + package = get_config_value('package-name', + json_config.get_driver(), False) + module = get_config_value('module-name', + json_config.get_driver(), False) + bundle = get_config_value('bundle-name', + json_config. get_driver(), False) + is_rerun = get_config_value('rerun', json_config.get_driver(), False) + + self.config.package_name = package + self.config.module_name = module + self.config.bundle_name = bundle + self.rerun = True if is_rerun == 'true' else False + + if not package and not module: + raise ParamError("Neither package nor module is found" + " in config file.", error_no="03201") + timeout_config = get_config_value("shell-timeout", + json_config.get_driver(), False) + if timeout_config: + self.config.timeout = int(timeout_config) + else: + self.config.timeout = TIME_OUT + + def _get_runner_config(self, json_config): + test_timeout = get_config_value('test-timeout', + json_config.get_driver(), False) + if test_timeout: + self.runner.add_arg("wait_time", int(test_timeout)) + + testcase_timeout = get_config_value('testcase-timeout', + json_config.get_driver(), False) + if testcase_timeout: + self.runner.add_arg("timeout", int(testcase_timeout)) + self.runner.compile_mode = get_config_value( + 'compile-mode', json_config.get_driver(), False) + + def _do_test_run(self, listener): + test_to_run = self._collect_test_to_run() + LOG.info("Collected suite count is: {}, test count is: {}". + format(len(self.runner.expect_tests_dict.keys()), + len(test_to_run) if test_to_run else 0)) + if not test_to_run or not self.rerun: + self.runner.run(listener) + self.runner.notify_finished() + else: + self._run_with_rerun(listener, test_to_run) + + def _collect_test_to_run(self): + run_results = self.runner.dry_run() + return run_results + + def _run_tests(self, listener): + test_tracker = CollectingPassListener() + listener_copy = listener.copy() + listener_copy.append(test_tracker) + self.runner.run(listener_copy) + test_run = test_tracker.get_current_run_results() + return test_run + + def _run_with_rerun(self, listener, expected_tests): + LOG.debug("Ready to run with rerun, expect run: %s" + % len(expected_tests)) + test_run = self._run_tests(listener) + self.runner.retry_times -= 1 + LOG.debug("Run with rerun, has run: %s" % len(test_run) + if test_run else 0) + if len(test_run) < len(expected_tests): + expected_tests = TestDescription.remove_test(expected_tests, + test_run) + if not expected_tests: + LOG.debug("No tests to re-run twice,please check") + self.runner.notify_finished() + else: + self._rerun_twice(expected_tests, listener) + else: + LOG.debug("Rerun once success") + self.runner.notify_finished() + + def _rerun_twice(self, expected_tests, listener): + tests = [] + for test in expected_tests: + tests.append("%s#%s" % (test.class_name, test.test_name)) + self.runner.add_arg("class", ",".join(tests)) + LOG.debug("Ready to rerun twice, expect run: %s" % len(expected_tests)) + test_run = self._run_tests(listener) + self.runner.retry_times -= 1 + LOG.debug("Rerun twice, has run: %s" % len(test_run)) + if len(test_run) < len(expected_tests): + expected_tests = TestDescription.remove_test(expected_tests, + test_run) + if not expected_tests: + LOG.debug("No tests to re-run third,please check") + self.runner.notify_finished() + else: + self._rerun_third(expected_tests, listener) + else: + LOG.debug("Rerun twice success") + self.runner.notify_finished() + + def _rerun_third(self, expected_tests, listener): + tests = [] + for test in expected_tests: + tests.append("%s#%s" % (test.class_name, test.test_name)) + self.runner.add_arg("class", ",".join(tests)) + LOG.debug("Rerun to rerun third, expect run: %s" % len(expected_tests)) + self._run_tests(listener) + LOG.debug("Rerun third success") + self.runner.notify_finished() + + def _make_exclude_list_file(self, request): + if "all-test-file-exclude-filter" in self.config.testargs: + json_file_list = self.config.testargs.get( + "all-test-file-exclude-filter") + self.config.testargs.pop("all-test-file-exclude-filter") + if not json_file_list: + LOG.warning("all-test-file-exclude-filter value is empty!") + else: + if not os.path.isfile(json_file_list[0]): + LOG.warning( + "[{}] is not a valid file".format(json_file_list[0])) + return + file_open = os.open(json_file_list[0], os.O_RDONLY, + stat.S_IWUSR | stat.S_IRUSR) + with os.fdopen(file_open, "r") as file_handler: + json_data = json.load(file_handler) + exclude_list = json_data.get( + DeviceTestType.oh_jsunit_test, []) + filter_list = [] + for exclude in exclude_list: + if request.get_module_name() not in exclude: + continue + filter_list.extend(exclude.get(request.get_module_name())) + if not isinstance(self.config.testargs, dict): + return + if 'notClass' in self.config.testargs.keys(): + filter_list.extend(self.config.testargs.get('notClass', [])) + self.config.testargs.update({"notClass": filter_list}) + + def _do_test_retry(self, listener, testargs): + tests_dict = dict() + case_list = list() + for test in testargs.get("test"): + test_item = test.split("#") + if len(test_item) != 2: + continue + case_list.append(test) + if test_item[0] not in tests_dict: + tests_dict.update({test_item[0] : []}) + tests_dict.get(test_item[0]).append( + TestDescription(test_item[0], test_item[1])) + self.runner.add_arg("class", ",".join(case_list)) + self.runner.expect_tests_dict = tests_dict + self.config.testargs.pop("test") + self.runner.run(listener) + self.runner.notify_finished() + + def _do_tf_suite(self): + if hasattr(self.config, "tf_suite") and \ + self.config.tf_suite.get("cases", []): + case_list = self.config["tf_suite"]["cases"] + self.config.testargs.update({"class": case_list}) + + def _start_smart_perf(self): + if not hasattr(self.config, ConfigConst.kits_in_module): + return + if CKit.smartperf not in self.config.get(ConfigConst.kits_in_module): + return + sp_kits = get_plugin(Plugin.TEST_KIT, CKit.smartperf)[0] + sp_kits.target_name = self.config.bundle_name + param_config = self.config.get(ConfigConst.kits_params).get( + CKit.smartperf, "") + sp_kits.__check_config__(param_config) + self.kits.insert(0, sp_kits) + + def _handle_logs(self, request): + serial = "{}_{}".format(str(self.config.device.__get_serial__()), time.time_ns()) + log_tar_file_name = "{}".format(str(serial).replace(":", "_")) + if hasattr(self.config, ConfigConst.device_log) and \ + self.config.device_log.get(ConfigConst.tag_enable) == ConfigConst.device_log_on \ + and hasattr(self.config.device, "start_get_crash_log"): + self.config.device.device_log_collector.\ + start_get_crash_log(log_tar_file_name, module_name=request.get_module_name()) + self.config.device.device_log_collector.\ + remove_log_address(self.device_log, self.hilog) + self.config.device.device_log_collector.\ + stop_catch_device_log(self.log_proc) + self.config.device.device_log_collector.\ + stop_catch_device_log(self.hilog_proc) + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +class OHJSUnitTestRunner: + MAX_RETRY_TIMES = 3 + + def __init__(self, config): + self.arg_list = {} + self.suites_name = None + self.config = config + self.rerun_attemp = 3 + self.suite_recorder = {} + self.finished = False + self.expect_tests_dict = dict() + self.finished_observer = None + self.retry_times = 1 + self.compile_mode = "" + + def dry_run(self): + parsers = get_plugin(Plugin.PARSER, CommonParserType.oh_jsunit_list) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + command = self._get_dry_run_command() + self.config.device.execute_shell_command( + command, timeout=self.config.timeout, receiver=handler, retry=0) + self.expect_tests_dict = parser_instances[0].tests_dict + return parser_instances[0].tests + + def run(self, listener): + handler = self._get_shell_handler(listener) + command = self._get_run_command() + self.config.device.execute_shell_command( + command, timeout=self.config.timeout, receiver=handler, retry=0) + + def notify_finished(self): + if self.finished_observer: + self.finished_observer.notify_task_finished() + self.retry_times -= 1 + + def _get_shell_handler(self, listener): + parsers = get_plugin(Plugin.PARSER, CommonParserType.oh_jsunit) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = self.suites_name + parser_instance.listeners = listener + parser_instance.runner = self + parser_instances.append(parser_instance) + self.finished_observer = parser_instance + handler = ShellHandler(parser_instances) + return handler + + def add_arg(self, name, value): + if not name or not value: + return + self.arg_list[name] = value + + def remove_arg(self, name): + if not name: + return + if name in self.arg_list: + del self.arg_list[name] + + def get_args_command(self): + args_commands = "" + for key, value in self.arg_list.items(): + if "wait_time" == key: + args_commands = "%s -w %s " % (args_commands, value) + else: + args_commands = "%s -s %s %s " % (args_commands, key, value) + return args_commands + + def _get_run_command(self): + command = "" + if self.config.package_name: + # aa test -p ${packageName} -b ${bundleName}-s + # unittest OpenHarmonyTestRunner + command = "aa test -p {} -b {} -s unittest OpenHarmonyTestRunner" \ + " {}".format(self.config.package_name, + self.config.bundle_name, + self.get_args_command()) + elif self.config.module_name: + # aa test -m ${moduleName} -b ${bundleName} + # -s unittest OpenHarmonyTestRunner + command = "aa test -m {} -b {} -s unittest {} {}".format( + self.config.module_name, self.config.bundle_name, + self.get_oh_test_runner_path(), self.get_args_command()) + return command + + def _get_dry_run_command(self): + command = "" + if self.config.package_name: + command = "aa test -p {} -b {} -s unittest OpenHarmonyTestRunner" \ + " {} -s dryRun true".format(self.config.package_name, + self.config.bundle_name, + self.get_args_command()) + elif self.config.module_name: + command = "aa test -m {} -b {} -s unittest {}" \ + " {} -s dryRun true".format(self.config.module_name, + self.config.bundle_name, + self.get_oh_test_runner_path(), + self.get_args_command()) + + return command + + def get_oh_test_runner_path(self): + if self.compile_mode == "esmodule": + return "/ets/testrunner/OpenHarmonyTestRunner" + else: + return "OpenHarmonyTestRunner" + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.oh_rust_test) +class OHRustTestDriver(IDriver): + def __init__(self): + self.result = "" + self.error_message = "" + self.config = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + LOG.debug("Start to execute open harmony rust test") + self.config = request.config + self.config.device = request.config.environment.devices[0] + self.config.target_test_path = "/system/bin" + + suite_file = request.root.source.source_file + LOG.debug("Testsuite filepath:{}".format(suite_file)) + + if not suite_file: + LOG.error("test source '{}' not exists".format( + request.root.source.source_string)) + return + + self.result = "{}.xml".format( + os.path.join(request.config.report_path, + "result", request.get_module_name())) + self.config.device.set_device_report_path(request.config.report_path) + self.config.device.device_log_collector.start_hilog_task() + self._init_oh_rust() + self._run_oh_rust(suite_file, request) + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=False, error_no="03409") + finally: + serial = "{}_{}".format(str(request.config.device.__get_serial__()), + time.time_ns()) + log_tar_file_name = "{}".format(str(serial).replace(":", "_")) + self.config.device.device_log_collector.stop_hilog_task( + log_tar_file_name, module_name=request.get_module_name()) + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def _init_oh_rust(self): + self.config.device.connector_command("target mount") + self.config.device.execute_shell_command( + "mount -o rw,remount,rw /") + + def _run_oh_rust(self, suite_file, request=None): + # push testsuite file + self.config.device.push_file(suite_file, self.config.target_test_path) + # push resource file + resource_manager = ResourceManager() + resource_data_dict, resource_dir = \ + resource_manager.get_resource_data_dic(suite_file) + resource_manager.process_preparer_data(resource_data_dict, + resource_dir, + self.config.device) + for listener in request.listeners: + listener.device_sn = self.config.device.device_sn + + parsers = get_plugin(Plugin.PARSER, CommonParserType.oh_rust) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suite_name = request.get_module_name() + parser_instance.listeners = request.listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + + command = "cd {}; chmod +x *; ./{}".format( + self.config.target_test_path, os.path.basename(suite_file)) + self.config.device.execute_shell_command( + command, timeout=TIME_OUT, receiver=handler, retry=0) + resource_manager.process_cleaner_data(resource_data_dict, resource_dir, + self.config.device) + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + +class OHYaraConfig(Enum): + HAP_FILE = "hap-file" + BUNDLE_NAME = "bundle-name" + CLEANUP_APPS = "cleanup-apps" + + OS_FULLNAME_LIST = "osFullNameList" + VULNERABILITIES = "vulnerabilities" + VUL_ID = "vul_id" + OPENHARMONY_SA = "openharmony-sa" + AFFECTED_VERSION = "affected_versions" + MONTH = "month" + SEVERITY = "severity" + VUL_DESCRIPTION = "vul_description" + DISCLOSURE = "disclosure" + AFFECTED_FILES = "affected_files" + YARA_RULES = "yara_rules" + + PASS = "pass" + FAIL = "fail" + BLOCK = "block" + + ERROR_MSG_001 = "The patch label is longer than two months (60 days), " \ + "which violates the OHCA agreement [https://compatibility.openharmony.cn/]." + ERROR_MSG_002 = "This test case is beyond the patch label scope and does not need to be executed." + ERROR_MSG_003 = "Modify the code according to the patch requirements: " + + +class VulItem: + vul_id = "" + month = "" + severity = "" + vul_description = dict() + disclosure = dict() + affected_files = "" + affected_versions = "" + yara_rules = "" + trace = "" + final_risk = OHYaraConfig.PASS.value + complete = False + + +@Plugin(type=Plugin.DRIVER, id=DeviceTestType.oh_yara_test) +class OHYaraTestDriver(IDriver): + def __init__(self): + self.result = "" + self.error_message = "" + self.config = None + self.tool_hap_info = dict() + self.security_patch = None + self.system_version = None + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + LOG.debug("Start to execute open harmony yara test") + self.result = os.path.join( + request.config.report_path, "result", + '.'.join((request.get_module_name(), "xml"))) + self.config = request.config + self.config.device = request.config.environment.devices[0] + + config_file = request.root.source.config_file + suite_file = request.root.source.source_file + + if not suite_file: + raise ParamError( + "test source '%s' not exists" % + request.root.source.source_string, error_no="00110") + LOG.debug("Test case file path: %s" % suite_file) + self.config.device.set_device_report_path(request.config.report_path) + self._run_oh_yara(config_file, request) + + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=False, error_no="03409") + finally: + if self.tool_hap_info.get(OHYaraConfig.CLEANUP_APPS.value): + cmd = ["uninstall", self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value)] + result = self.config.device.connector_command(cmd) + LOG.debug("Try uninstall tools hap, bundle name is {}, result is {}".format( + self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value), result)) + + serial = "{}_{}".format(str(request.config.device.__get_serial__()), + time.time_ns()) + log_tar_file_name = "{}".format(str(serial).replace(":", "_")) + self.config.device.device_log_collector.stop_hilog_task( + log_tar_file_name, module_name=request.get_module_name()) + + self.result = check_result_report( + request.config.report_path, self.result, self.error_message) + + def _get_driver_config(self, json_config): + yara_bin = get_config_value('yara-bin', + json_config.get_driver(), False) + version_mapping_file = get_config_value('version-mapping-file', + json_config.get_driver(), False) + vul_info_file = get_config_value('vul-info-file', + json_config.get_driver(), False) + # get absolute file path + self.config.yara_bin = get_file_absolute_path(yara_bin) + self.config.version_mapping_file = get_file_absolute_path(version_mapping_file) + self.config.vul_info_file = get_file_absolute_path(vul_info_file, [self.config.testcases_path]) + + # get tool hap info + # default value + self.tool_hap_info = { + OHYaraConfig.HAP_FILE.value: "sststool.hap", + OHYaraConfig.BUNDLE_NAME.value: "com.example.sststool", + OHYaraConfig.CLEANUP_APPS.value: "true" + } + tool_hap_info = get_config_value('tools-hap-info', + json_config.get_driver(), False) + if tool_hap_info: + self.tool_hap_info[OHYaraConfig.HAP_FILE.value] = \ + tool_hap_info.get(OHYaraConfig.HAP_FILE.value, "sststool.hap") + self.tool_hap_info[OHYaraConfig.BUNDLE_NAME.value] = \ + tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value, "com.example.sststool") + self.tool_hap_info[OHYaraConfig.CLEANUP_APPS.value] = \ + tool_hap_info.get(OHYaraConfig.CLEANUP_APPS.value, "true") + + def _run_oh_yara(self, config_file, request=None): + message_list = list() + + json_config = JsonParser(config_file) + self._get_driver_config(json_config) + + # get device info + self.security_patch = self.config.device.execute_shell_command( + "param get const.ohos.version.security_patch").strip() + self.system_version = self.config.device.execute_shell_command( + "param get const.ohos.fullname").strip() + + if "fail" in self.system_version: + self._get_full_name_by_tool_hap() + + vul_items = self._get_vul_items() + # if security patch expire, case fail + current_date_str = datetime.now().strftime('%Y-%m') + if self._check_if_expire_or_risk(current_date_str): + LOG.info("Security patch has expired. Set all case fail.") + for _, item in enumerate(vul_items): + item.complete = True + item.final_risk = OHYaraConfig.FAIL.value + item.trace = "{}{}".format(item.trace, OHYaraConfig.ERROR_MSG_001.value) + else: + LOG.info("Security patch is shorter than two months. Start yara test.") + # parse version mapping file + mapping_info = self._do_parse_json(self.config.version_mapping_file) + os_full_name_list = mapping_info.get(OHYaraConfig.OS_FULLNAME_LIST.value, None) + # check if system version in version mapping list + vul_version = os_full_name_list.get(self.system_version, None) + # not in the maintenance scope, skip all case + if vul_version is None: + LOG.debug("The system version is not in the maintenance scope, skip it. " + "system versions is {}".format(self.system_version)) + else: + for _, item in enumerate(vul_items): + LOG.debug("Affected files: {}".format(item.affected_files)) + for index, affected_file in enumerate(item.affected_files): + has_inter = False + for i, _ in enumerate(item.affected_versions): + if self._check_if_intersection(vul_version, item.affected_versions[i]): + has_inter = True + break + if not has_inter: + LOG.debug("Yara rule [{}] affected versions has no intersection " + "in mapping version, skip it. Mapping version is {}, " + "affected versions is {}".format(item.vul_id, vul_version, + item.affected_versions)) + continue + local_path = os.path.join(request.config.report_path, OHYaraConfig.AFFECTED_FILES.value, + request.get_module_name(), item.yara_rules[index].split('.')[0]) + if not os.path.exists(local_path): + os.makedirs(local_path) + yara_file = get_file_absolute_path(item.yara_rules[index], [self.config.testcases_path]) + self.config.device.pull_file(affected_file, local_path) + affected_file = os.path.join(local_path, os.path.basename(affected_file)) + if not os.path.exists(affected_file): + LOG.debug("affected file [{}] is not exist, skip it.".format(item.affected_files[index])) + item.final_risk = OHYaraConfig.PASS.value + continue + cmd = [self.config.yara_bin, yara_file, affected_file] + result = exec_cmd(cmd) + LOG.debug("Yara result: {}, affected file: {}".format(result, item.affected_files[index])) + if "testcase pass" in result: + item.final_risk = OHYaraConfig.PASS.value + break + else: + if self._check_if_expire_or_risk(item.month, check_risk=True): + item.trace = "{}{}".format(OHYaraConfig.ERROR_MSG_003.value, + item.disclosure.get("zh", "")) + item.final_risk = OHYaraConfig.FAIL.value + else: + item.final_risk = OHYaraConfig.BLOCK.value + item.trace = "{}{}".format(item.trace, OHYaraConfig.ERROR_MSG_002.value) + # if no risk delete files, if rule has risk keep it + if item.final_risk != OHYaraConfig.FAIL.value: + local_path = os.path.join(request.config.report_path, OHYaraConfig.AFFECTED_FILES.value, + request.get_module_name(), item.yara_rules[index].split('.')[0]) + if os.path.exists(local_path): + LOG.debug( + "Yara rule [{}] has no risk, remove affected files.".format( + item.yara_rules[index])) + shutil.rmtree(local_path) + item.complete = True + self._generate_yara_report(request, vul_items, message_list) + self._generate_xml_report(request, vul_items, message_list) + + def _check_if_expire_or_risk(self, date_str, expire_time=2, check_risk=False): + from dateutil.relativedelta import relativedelta + self.security_patch = self.security_patch.replace(' ', '') + self.security_patch = self.security_patch.replace('/', '-') + # get current date + source_date = datetime.strptime(date_str, '%Y-%m') + security_patch_date = datetime.strptime(self.security_patch[:-3], '%Y-%m') + # check if expire 2 months + rd = relativedelta(source_date, security_patch_date) + months = rd.months + (rd.years * 12) + if check_risk: + # vul time before security patch time no risk + LOG.debug("Security patch time: {}, vul time: {}, delta_months: {}" + .format(self.security_patch[:-3], date_str, months)) + if months > 0: + return False + else: + return True + else: + # check if security patch time expire current time 2 months + LOG.debug("Security patch time: {}, current time: {}, delta_months: {}" + .format(self.security_patch[:-3], date_str, months)) + if months > expire_time: + return True + else: + return False + + @staticmethod + def _check_if_intersection(source_version, dst_version): + # para dst_less_sor control if dst less than source + def _do_check(soruce, dst, dst_less_sor=True): + if re.match(r'^\d{1,3}.\d{1,3}.\d{1,3}', soruce) and \ + re.match(r'^\d{1,3}.\d{1,3}.\d{1,3}', dst): + source_vers = soruce.split(".") + dst_vers = dst.split(".") + for index, _ in enumerate(source_vers): + if dst_less_sor: + # check if all source number less than dst number + if int(source_vers[index]) < int(dst_vers[index]): + return False + else: + # check if all source number larger than dst number + if int(source_vers[index]) > int(dst_vers[index]): + return False + return True + return False + + source_groups = source_version.split("-") + dst_groups = dst_version.split("-") + if source_version == dst_version: + return True + elif len(source_groups) == 1 and len(dst_groups) == 1: + return source_version == dst_version + elif len(source_groups) == 1 and len(dst_groups) == 2: + return _do_check(source_groups[0], dst_groups[0]) and \ + _do_check(source_groups[0], dst_groups[1], dst_less_sor=False) + elif len(source_groups) == 2 and len(dst_groups) == 1: + return _do_check(source_groups[0], dst_groups[0], dst_less_sor=False) and \ + _do_check(source_groups[1], dst_groups[0]) + elif len(source_groups) == 2 and len(dst_groups) == 2: + return _do_check(source_groups[0], dst_groups[1], dst_less_sor=False) and \ + _do_check(source_groups[1], dst_groups[0]) + return False + + def _get_vul_items(self): + vul_items = list() + vul_info = self._do_parse_json(self.config.vul_info_file) + vulnerabilities = vul_info.get(OHYaraConfig.VULNERABILITIES.value, []) + for _, vul in enumerate(vulnerabilities): + affected_versions = vul.get(OHYaraConfig.AFFECTED_VERSION.value, []) + item = VulItem() + item.vul_id = vul.get(OHYaraConfig.VUL_ID.value, dict()).get(OHYaraConfig.OPENHARMONY_SA.value, "") + item.affected_versions = affected_versions + item.month = vul.get(OHYaraConfig.MONTH.value, "") + item.severity = vul.get(OHYaraConfig.SEVERITY.value, "") + item.vul_description = vul.get(OHYaraConfig.VUL_DESCRIPTION.value, "") + item.disclosure = vul.get(OHYaraConfig.DISCLOSURE.value, "") + item.affected_files = \ + vul["affected_device"]["standard"]["linux"]["arm"]["scan_strategy"]["ists"]["yara"].get( + OHYaraConfig.AFFECTED_FILES.value, []) + item.yara_rules = \ + vul["affected_device"]["standard"]["linux"]["arm"]["scan_strategy"]["ists"]["yara"].get( + OHYaraConfig.YARA_RULES.value, []) + vul_items.append(item) + LOG.debug("Vul size is {}".format(len(vul_items))) + return vul_items + + @staticmethod + def _do_parse_json(file_path): + json_content = None + if not os.path.exists(file_path): + raise ParamError("The json file {} does not exist".format( + file_path), error_no="00110") + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(file_path, flags, modes), + "r", encoding="utf-8") as file_content: + json_content = json.load(file_content) + if json_content is None: + raise ParamError("The json file {} parse error".format( + file_path), error_no="00110") + return json_content + + def _get_full_name_by_tool_hap(self): + # check if tool hap has installed + result = self.config.device.execute_shell_command( + "bm dump -a | grep {}".format(self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value))) + LOG.debug(result) + if self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value) not in result: + hap_path = get_file_absolute_path(self.tool_hap_info.get(OHYaraConfig.HAP_FILE.value)) + self.config.device.push_file(hap_path, "/data/local/tmp") + result = self.config.device.execute_shell_command( + "bm install -p /data/local/tmp/{}".format(os.path.basename(hap_path))) + LOG.debug(result) + self.config.device.execute_shell_command( + "mkdir -p /data/app/el2/100/base/{}/haps/entry/files".format( + self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value))) + self.config.device.execute_shell_command( + "aa start -a {}.MainAbility -b {}".format( + self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value), + self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value))) + time.sleep(1) + self.system_version = self.config.device.execute_shell_command( + "cat /data/app/el2/100/base/{}/haps/entry/files/osFullNameInfo.txt".format( + self.tool_hap_info.get(OHYaraConfig.BUNDLE_NAME.value))).replace('"', '') + LOG.debug(self.system_version) + + def _generate_yara_report(self, request, vul_items, result_message): + import csv + result_message.clear() + yara_report = os.path.join(request.config.report_path, "vul_info_{}.csv" + .format(request.config.device.device_sn)) + if os.path.exists(yara_report): + data = [] + else: + data = [ + ["设备版本号:", self.system_version, "设备安全补丁标签:", self.security_patch], + ["漏洞编号", "严重程度", "披露时间", "检测结果", "修复建议", "漏洞描述"] + ] + fd = os.open(yara_report, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o755) + for _, item in enumerate(vul_items): + data.append([item.vul_id, item.severity, + item.month, item.final_risk, + item.disclosure.get("zh", ""), item.vul_description.get("zh", "")]) + result = "{}|{}|{}|{}|{}|{}|{}\n".format( + item.vul_id, item.severity, + item.month, item.final_risk, + item.disclosure.get("zh", ""), item.vul_description.get("zh", ""), + item.trace) + result_message.append(result) + with os.fdopen(fd, "a", newline='') as file_handler: + writer = csv.writer(file_handler) + writer.writerows(data) + + def _generate_xml_report(self, request, vul_items, message_list): + result_message = "".join(message_list) + listener_copy = request.listeners.copy() + parsers = get_plugin( + Plugin.PARSER, CommonParserType.oh_yara) + if parsers: + parsers = parsers[:1] + for listener in listener_copy: + listener.device_sn = self.config.device.device_sn + parser_instances = [] + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suites_name = request.get_module_name() + parser_instance.vul_items = vul_items + parser_instance.listeners = listener_copy + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + process_command_ret(result_message, handler) + + def __result__(self): + return self.result if os.path.exists(self.result) else "" + + @Plugin(type=Plugin.DRIVER, id=DeviceTestType.validator_test) + class ValidatorTestDriver(IDriver): + + def __init__(self): + self.error_message = "" + self.xml_path = "" + self.result = "" + self.config = None + self.kits = [] + + def __check_environment__(self, device_options): + pass + + def __check_config__(self, config): + pass + + def __execute__(self, request): + try: + self.result = os.path.join( + request.config.report_path, "result", + ".".join((request.get_module_name(), "xml"))) + self.config = request.config + self.config.device = request.config.environment.devices[0] + config_file = request.root.source.config_file + self._run_validate_test(config_file, request) + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=True, error_no="03409") + raise exception + finally: + self.result = check_result_report(request.config.report_path, + self.result, self.error_message) + + def _run_validate_test(self, config_file, request): + is_update = False + try: + if "update" in self.config.testargs.keys(): + if dict(self.config.testargs).get("update")[0] == "true": + is_update = True + json_config = JsonParser(config_file) + self.kits = get_kit_instances(json_config, self.config.resource_path, + self.config.testcases_path) + self._get_driver_config(json_config) + if is_update: + do_module_kit_setup(request, self.kits) + while True: + print("Is test finished?Y/N") + usr_input = input(">>>") + if usr_input == "Y" or usr_input == "y": + LOG.debug("Finish current test") + break + else: + print("continue") + LOG.debug("Your input is:{}, continue".format(usr_input)) + if self.xml_path: + result_dir = os.path.join(request.config.report_path, "result") + if not os.path.exists(result_dir): + os.makedirs(result_dir) + self.config.device.pull_file(self.xml_path, self.result) + finally: + if is_update: + do_module_kit_teardown(request) + + def _get_driver_config(self, json_config): + self.xml_path = get_config_value("xml_path", json_config.get_driver(), False) + def __result__(self): + return self.result if os.path.exists(self.result) else "" diff --git a/xdevice/plugins/ohos/src/ohos/environment/__init__.py b/xdevice/plugins/ohos/src/ohos/environment/__init__.py new file mode 100644 index 0000000..160d3a7 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/environment/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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/xdevice/plugins/ohos/src/ohos/environment/device.py b/xdevice/plugins/ohos/src/ohos/environment/device.py new file mode 100644 index 0000000..97217f8 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/environment/device.py @@ -0,0 +1,1085 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import re +import time +import os +import threading +import platform +import subprocess +import sys +from xdevice import DeviceOsType, FilePermission +from xdevice import ProductForm +from xdevice import ReportException +from xdevice import IDevice +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import exec_cmd +from xdevice import ConfigConst +from xdevice import HdcError +from xdevice import DeviceAllocationState +from xdevice import DeviceConnectorType +from xdevice import TestDeviceState +from xdevice import AdvanceDeviceOption +from xdevice import convert_serial +from xdevice import check_path_legal +from xdevice import start_standing_subprocess +from xdevice import stop_standing_subprocess +from xdevice import get_cst_time + +from ohos.environment.dmlib import HdcHelper +from ohos.environment.dmlib import CollectingOutputReceiver + +__all__ = ["Device"] +TIMEOUT = 300 * 1000 +RETRY_ATTEMPTS = 2 +DEFAULT_UNAVAILABLE_TIMEOUT = 20 * 1000 +BACKGROUND_TIME = 2 * 60 * 1000 +LOG = platform_logger("Device") +DEVICETEST_HAP_PACKAGE_NAME = "com.ohos.devicetest" +UITEST_NAME = "uitest" +UITEST_SINGLENESS = "singleness" +UITEST_PATH = "/system/bin/uitest" +UITEST_SHMF = "/data/app/el2/100/base/{}/cache/shmf".format(DEVICETEST_HAP_PACKAGE_NAME) +UITEST_COMMAND = "{} start-daemon 0123456789 &".format(UITEST_PATH) +NATIVE_CRASH_PATH = "/data/log/faultlog/temp" +JS_CRASH_PATH = "/data/log/faultlog/faultlogger" +ROOT_PATH = "/data/log/faultlog" + + +def perform_device_action(func): + def callback_to_outer(device, msg): + # callback to decc ui + if getattr(device, "callback_method", None): + device.callback_method(msg) + + def device_action(self, *args, **kwargs): + if not self.get_recover_state(): + LOG.debug("Device {} {} is false".format(self.device_sn, + ConfigConst.recover_state)) + return None + # avoid infinite recursion, such as device reboot + abort_on_exception = bool(kwargs.get("abort_on_exception", False)) + if abort_on_exception: + result = func(self, *args, **kwargs) + return result + + tmp = int(kwargs.get("retry", RETRY_ATTEMPTS)) + retry = tmp + 1 if tmp > 0 else 1 + exception = None + for _ in range(retry): + try: + result = func(self, *args, **kwargs) + return result + except ReportException as error: + self.log.exception("Generate report error!", exc_info=False) + exception = error + except (ConnectionResetError, # pylint:disable=undefined-variable + ConnectionRefusedError, # pylint:disable=undefined-variable + ConnectionAbortedError) as error: # pylint:disable=undefined-variable + self.log.error("error type: {}, error: {}".format + (error.__class__.__name__, error)) + # check hdc if is running + if not HdcHelper.check_if_hdc_running(): + LOG.debug("{} not running, set device {} {} false".format( + HdcHelper.CONNECTOR_NAME, self.device_sn, ConfigConst.recover_state)) + self.set_recover_state(False) + callback_to_outer(self, "recover failed") + raise error + callback_to_outer(self, "error:{}, prepare to recover".format(error)) + if not self.recover_device(): + LOG.debug("Set device {} {} false".format( + self.device_sn, ConfigConst.recover_state)) + self.set_recover_state(False) + callback_to_outer(self, "recover failed") + raise error + exception = error + callback_to_outer(self, "recover success") + except HdcError as error: + self.log.error("error type: {}, error: {}".format(error.__class__.__name__, error)) + callback_to_outer(self, "error:{}, prepare to recover".format(error)) + if not self.recover_device(): + LOG.debug("Set device {} {} false".format( + self.device_sn, ConfigConst.recover_state)) + self.set_recover_state(False) + callback_to_outer(self, "recover failed") + raise error + exception = error + callback_to_outer(self, "recover success") + except Exception as error: + self.log.exception("error type: {}, error: {}".format( + error.__class__.__name__, error), exc_info=False) + exception = error + raise exception + + return device_action + + +@Plugin(type=Plugin.DEVICE, id=DeviceOsType.default) +class Device(IDevice): + """ + Class representing a device. + + Each object of this class represents one device in xDevice, + including handles to hdc, fastboot, and test agent (DeviceTest.apk). + + Attributes: + device_sn: A string that's the serial number of the device. + """ + + device_sn = None + host = None + port = None + usb_type = DeviceConnectorType.hdc + is_timeout = False + device_hilog_proc = None + device_os_type = DeviceOsType.default + test_device_state = None + device_allocation_state = DeviceAllocationState.available + label = None + log = platform_logger("Device") + device_state_monitor = None + reboot_timeout = 2 * 60 * 1000 + _device_log_collector = None + + _proxy = None + _abc_proxy = None + _is_abc = False + initdevice = True + d_port = 8011 + abc_d_port = 8012 + _uitestdeamon = None + rpc_timeout = 300 + device_id = None + reconnecttimes = 0 + _h_port = None + oh_module_package = None + module_ablity_name = None + _device_report_path = None + _webview = None + + model_dict = { + 'default': ProductForm.phone, + 'car': ProductForm.car, + 'tv': ProductForm.television, + 'watch': ProductForm.watch, + 'tablet': ProductForm.tablet, + 'nosdcard': ProductForm.phone + } + + def __init__(self): + self.extend_value = {} + self.device_lock = threading.RLock() + self.forward_ports = [] + self.forward_ports_abc = [] + self.proxy_listener = None + + def __eq__(self, other): + return self.device_sn == other.__get_serial__() and \ + self.device_os_type == other.device_os_type + + def __set_serial__(self, device_sn=""): + self.device_sn = device_sn + return self.device_sn + + def __get_serial__(self): + return self.device_sn + + def get(self, key=None, default=None): + if not key: + return default + value = getattr(self, key, None) + if value: + return value + else: + return self.extend_value.get(key, default) + + def recover_device(self): + if not self.get_recover_state(): + LOG.debug("Device %s %s is false, cannot recover device" % ( + self.device_sn, ConfigConst.recover_state)) + return False + + result = self.device_state_monitor.wait_for_device_available() + if result: + self.device_log_collector.restart_catch_device_log() + return result + + def get_device_type(self): + self.label = self.model_dict.get("default", None) + + def get_property(self, prop_name, retry=RETRY_ATTEMPTS, + abort_on_exception=False): + """ + Hdc command, ddmlib function. + """ + command = "param get %s" % prop_name + stdout = self.execute_shell_command(command, timeout=5 * 1000, + output_flag=False, + retry=retry, + abort_on_exception=abort_on_exception).strip() + if stdout: + LOG.debug(stdout) + return stdout + + @perform_device_action + def connector_command(self, command, **kwargs): + timeout = int(kwargs.get("timeout", TIMEOUT)) / 1000 + error_print = bool(kwargs.get("error_print", True)) + join_result = bool(kwargs.get("join_result", False)) + timeout_msg = '' if timeout == 300.0 else \ + " with timeout %ss" % timeout + if self.host != "127.0.0.1": + cmd = [HdcHelper.CONNECTOR_NAME, "-s", "{}:{}".format(self.host, self.port), + "-t", self.device_sn] + else: + cmd = [HdcHelper.CONNECTOR_NAME, "-t", self.device_sn] + LOG.debug("{} execute command {} {}{}".format(convert_serial(self.device_sn), + HdcHelper.CONNECTOR_NAME, + command, timeout_msg)) + if isinstance(command, list): + cmd.extend(command) + else: + command = command.strip() + cmd.extend(command.split(" ")) + result = exec_cmd(cmd, timeout, error_print, join_result) + if not result: + return result + is_print = bool(kwargs.get("is_print", True)) + if is_print: + for line in str(result).split("\n"): + if line.strip(): + LOG.debug(line.strip()) + return result + + @perform_device_action + def execute_shell_command(self, command, timeout=TIMEOUT, + receiver=None, **kwargs): + if not receiver: + collect_receiver = CollectingOutputReceiver() + HdcHelper.execute_shell_command( + self, command, timeout=timeout, + receiver=collect_receiver, **kwargs) + return collect_receiver.output + else: + return HdcHelper.execute_shell_command( + self, command, timeout=timeout, + receiver=receiver, **kwargs) + + def execute_shell_cmd_background(self, command, timeout=TIMEOUT, + receiver=None): + status = HdcHelper.execute_shell_command(self, command, + timeout=timeout, + receiver=receiver) + + self.wait_for_device_not_available(DEFAULT_UNAVAILABLE_TIMEOUT) + self.device_state_monitor.wait_for_device_available(BACKGROUND_TIME) + cmd = "target mount" + self.connector_command(cmd) + self.device_log_collector.restart_catch_device_log() + return status + + def wait_for_device_not_available(self, wait_time): + return self.device_state_monitor.wait_for_device_not_available( + wait_time) + + def _wait_for_device_online(self, wait_time=None): + return self.device_state_monitor.wait_for_device_online(wait_time) + + def _do_reboot(self): + HdcHelper.reboot(self) + self.wait_for_boot_completion() + + def _reboot_until_online(self): + self._do_reboot() + + def reboot(self): + self._reboot_until_online() + self.enable_hdc_root() + self.device_log_collector.restart_catch_device_log() + + @perform_device_action + def install_package(self, package_path, command=""): + if package_path is None: + raise HdcError( + "install package: package path cannot be None!") + return HdcHelper.install_package(self, package_path, command) + + @perform_device_action + def uninstall_package(self, package_name): + return HdcHelper.uninstall_package(self, package_name) + + @perform_device_action + def push_file(self, local, remote, **kwargs): + """ + Push a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + local = "\"{}\"".format(local) + remote = "\"{}\"".format(remote) + if local is None: + raise HdcError("XDevice Local path cannot be None!") + + remote_is_dir = kwargs.get("remote_is_dir", False) + if remote_is_dir: + ret = self.execute_shell_command("test -d %s && echo 0" % remote) + if not (ret != "" and len(str(ret).split()) != 0 and + str(ret).split()[0] == "0"): + self.execute_shell_command("mkdir -p %s" % remote) + + if self.host != "127.0.0.1": + self.connector_command("file send {} {}".format(local, remote)) + else: + is_create = kwargs.get("is_create", False) + timeout = kwargs.get("timeout", TIMEOUT) + HdcHelper.push_file(self, local, remote, is_create=is_create, + timeout=timeout) + if not self.is_file_exist(remote): + LOG.error("Push %s to %s failed" % (local, remote)) + raise HdcError("push %s to %s failed" % (local, remote)) + + @perform_device_action + def pull_file(self, remote, local, **kwargs): + """ + Pull a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + local = "\"{}\"".format(local) + remote = "\"{}\"".format(remote) + if self.host != "127.0.0.1": + self.connector_command("file recv {} {}".format(remote, local)) + else: + is_create = kwargs.get("is_create", False) + timeout = kwargs.get("timeout", TIMEOUT) + HdcHelper.pull_file(self, remote, local, is_create=is_create, + timeout=timeout) + + def enable_hdc_root(self): + return True + + def is_directory(self, path): + path = check_path_legal(path) + output = self.execute_shell_command("ls -ld {}".format(path)) + if output and output.startswith('d'): + return True + return False + + def is_file_exist(self, file_path): + file_path = check_path_legal(file_path) + output = self.execute_shell_command("ls {}".format(file_path)) + if output and "No such file or directory" not in output: + return True + return False + + def get_recover_result(self, retry=RETRY_ATTEMPTS): + command = "param get bootevent.boot.completed" + stdout = self.execute_shell_command(command, timeout=5 * 1000, + output_flag=False, retry=retry, + abort_on_exception=True).strip() + if stdout: + if "fail" in stdout: + cmd = [HdcHelper.CONNECTOR_NAME, "list", "targets"] + + stdout = exec_cmd(cmd) + LOG.debug("exec cmd list targets:{},current device_sn:{}".format(stdout, self.device_sn)) + if stdout and (self.device_sn in stdout): + stdout = "true" + LOG.debug(stdout) + return stdout + + def set_recover_state(self, state): + with self.device_lock: + setattr(self, ConfigConst.recover_state, state) + if not state: + self.test_device_state = TestDeviceState.NOT_AVAILABLE + self.device_allocation_state = DeviceAllocationState.unavailable + # do proxy clean + if self.proxy_listener is not None: + if self._abc_proxy or (not self.is_abc and self.proxy): + self.proxy_listener(is_exception=True) + + def get_recover_state(self, default_state=True): + with self.device_lock: + state = getattr(self, ConfigConst.recover_state, default_state) + return state + + def close(self): + self.reconnecttimes = 0 + + def reset(self): + self.log.debug("start reset device...") + if self._proxy is not None: + self._proxy.close() + self._proxy = None + if self._uitestdeamon is not None: + self._uitestdeamon = None + if self.is_abc: + self.stop_harmony_rpc(kill_all=False) + else: + self.remove_ports() + self.stop_harmony_rpc(kill_all=True) + # do proxy clean + if self.proxy_listener is not None: + self.proxy_listener(is_exception=False) + self.device_log_collector.stop_restart_catch_device_log() + + @property + def is_abc(self): + # _is_abc init in device test driver + return self._is_abc + + @property + def proxy(self): + """The first rpc session initiated on this device. None if there isn't + one. + """ + try: + if self._proxy is None: + # check uitest + self.check_uitest_status() + self._proxy = self.get_harmony() + except Exception as error: + self._proxy = None + self.log.error("DeviceTest-10012 proxy:%s" % str(error)) + return self._proxy + + @property + def abc_proxy(self): + """The first rpc session initiated on this device. None if there isn't + one. + """ + try: + if self._abc_proxy is None: + # check uitest + self.check_uitest_status() + self._abc_proxy = self.get_harmony(start_abc=True) + except Exception as error: + self._abc_proxy = None + self.log.error("DeviceTest-10012 abc_proxy:%s" % str(error)) + return self._abc_proxy + + @property + def uitestdeamon(self): + from devicetest.controllers.uitestdeamon import \ + UiTestDeamon + if self._uitestdeamon is None: + self._uitestdeamon = UiTestDeamon(self) + return self._uitestdeamon + + @classmethod + def set_module_package(cls, module_packag): + cls.oh_module_package = module_packag + + @classmethod + def set_moudle_ablity_name(cls, module_ablity_name): + cls.module_ablity_name = module_ablity_name + + @property + def is_oh(self): + return True + + def get_harmony(self, start_abc=False): + if self.initdevice: + if start_abc: + self.start_abc_rpc(re_install_rpc=True) + else: + self.start_harmony_rpc(re_install_rpc=True) + self._h_port = self.get_local_port(start_abc) + port = self.d_port if not start_abc else self.abc_d_port + # clear old port,because abc and fast mode will not remove port + cmd = "fport tcp:{} tcp:{}".format( + self._h_port, port) + self.connector_command(cmd) + self.log.info( + "get_proxy d_port:{} {}".format(self._h_port, port)) + rpc_proxy = None + try: + from devicetest.controllers.openharmony import OpenHarmony + rpc_proxy = OpenHarmony(port=self._h_port, addr=self.host, device=self) + except Exception as error: + self.log.error(' proxy init error: {}.'.format(str(error))) + return rpc_proxy + + def start_uitest(self): + result = "" + if self.is_abc: + result = self.execute_shell_command("{} start-daemon singleness &".format(UITEST_PATH)) + else: + share_mem_mode = False + # uitest基础版本号,比该版本号大的用共享内存的方式进行启动 + base_version = [3, 2, 2, 2] + uitest_version = self.execute_shell_command("{} --version".format(UITEST_PATH)) + if uitest_version and re.match(r'^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}', uitest_version): + uitest_version = uitest_version.split(".") + for index, _ in enumerate(uitest_version): + if int(uitest_version[index]) > base_version[index]: + share_mem_mode = True + break + if share_mem_mode: + if not self.is_file_exist(UITEST_SHMF): + self.log.debug('Path {} not exist, create it.'.format(UITEST_SHMF)) + self.execute_shell_command("echo abc > {}".format(UITEST_SHMF)) + self.execute_shell_command("chmod -R 666 {}".format(UITEST_SHMF)) + result = self.execute_shell_command("{} start-daemon {} &".format(UITEST_PATH, UITEST_SHMF)) + else: + result = self.execute_shell_command(UITEST_COMMAND) + self.log.debug('start uitest, {}'.format(result)) + + def start_harmony_rpc(self, re_install_rpc=False): + if self.check_rpc_status(check_abc=False): + if (hasattr(sys, ConfigConst.env_pool_cache) and + getattr(sys, ConfigConst.env_pool_cache, False)) \ + or not re_install_rpc: + self.log.debug('Harmony rpc already start!!!!') + return + from devicetest.core.error_message import ErrorMessage + if re_install_rpc: + try: + from devicetest.controllers.openharmony import OpenHarmony + OpenHarmony.install_harmony_rpc(self) + except ImportError as error: # pylint:disable=undefined-variable + self.log.debug(str(error)) + self.log.error('please check devicetest extension module is exist.') + raise Exception(ErrorMessage.Error_01437.Topic) + except Exception as error: + self.log.debug(str(error)) + self.log.error('root device init RPC error.') + raise Exception(ErrorMessage.Error_01437.Topic) + if not self.is_abc: + self.stop_harmony_rpc() + else: + self.log.debug('Abc mode, kill hap if hap is running.') + self.stop_harmony_rpc(kill_all=False) + cmd = "aa start -a {}.ServiceAbility -b {}".format(DEVICETEST_HAP_PACKAGE_NAME, DEVICETEST_HAP_PACKAGE_NAME) + result = self.execute_shell_command(cmd) + self.log.debug('start devicetest ability, {}'.format(result)) + if not self.is_abc: + self.start_uitest() + time.sleep(1) + if not self.check_rpc_status(check_abc=False): + raise Exception("harmony rpc not running") + + def start_abc_rpc(self, re_install_rpc=False): + if re_install_rpc: + try: + from devicetest.controllers.openharmony import OpenHarmony + OpenHarmony.init_abc_resource(self) + except ImportError as error: # pylint:disable=undefined-variable + self.log.debug(str(error)) + self.log.error('please check devicetest extension module is exist.') + raise error + except Exception as error: + self.log.debug(str(error)) + self.log.error('root device init abc RPC error.') + raise error + if self.is_abc and self.check_rpc_status(check_abc=True): + self.log.debug('Harmony abc rpc already start!!!!') + return + self.start_uitest() + time.sleep(1) + if not self.check_rpc_status(check_abc=True): + raise Exception("harmony abc rpc not running") + + def stop_harmony_rpc(self, kill_all=True): + # abc妯″紡涓嬩粎鏉€鎺塪evicetest锛屽惁鍒欓兘鏉€鎺? + proc_pids = self.get_devicetest_proc_pid() + if not kill_all: + proc_pids.pop() + for pid in proc_pids: + if pid != "": + cmd = 'kill {}'.format(pid) + self.execute_shell_command(cmd) + + # check uitest if running well, otherwise kill it first + def check_uitest_status(self): + self.log.debug('Check uitest running status.') + proc_pids = self.get_devicetest_proc_pid(print_info=False) + if self.is_abc and proc_pids[1] != "": + if proc_pids[0] != "": + self.execute_shell_command('kill {}'.format(proc_pids[0])) + self.execute_shell_command('kill {}'.format(proc_pids[1])) + self.log.debug('Uitest is running in normal mode, current mode is abc, wait it exit.') + if not self.is_abc and proc_pids[2] != "": + if proc_pids[0] != "": + self.execute_shell_command('kill {}'.format(proc_pids[0])) + self.execute_shell_command('kill {}'.format(proc_pids[2])) + self.log.debug('Uitest is running in abc mode, current mode is normal, wait it exit.') + self.log.debug('Finish check uitest running status.') + + def get_devicetest_proc_pid(self, print_info=True): + uitest = "{} start-daemon".format(UITEST_NAME) + cmd = 'ps -ef | grep -E \'{}|{}|{}\''.format(DEVICETEST_HAP_PACKAGE_NAME, UITEST_NAME, UITEST_SINGLENESS) + proc_running = self.execute_shell_command(cmd).strip() + proc_running = proc_running.split("\n") + proc_pids = [""] * 3 + result = [] + for data in proc_running: + if DEVICETEST_HAP_PACKAGE_NAME in data and "grep" not in data and UITEST_NAME not in data: + result.append("{} running status: {}".format(DEVICETEST_HAP_PACKAGE_NAME, data)) + data = data.split() + proc_pids[0] = data[1] + if uitest in data and "grep" not in data: + if UITEST_SINGLENESS in data: + result.append("{} running status: {}".format(UITEST_SINGLENESS, data)) + data = data.split() + proc_pids[2] = data[1] + else: + result.append("{} running status: {}".format(UITEST_NAME, data)) + data = data.split() + proc_pids[1] = data[1] + if print_info: + self.log.debug("\n".join(result)) + return proc_pids + + def is_harmony_rpc_running(self, check_abc=False): + proc_pids = self.get_devicetest_proc_pid(print_info=False) + if not self.is_abc: + self.log.debug('is_proc_running: agent pid: {}, uitest pid: {}'.format(proc_pids[0], proc_pids[1])) + if proc_pids[0] != "" and proc_pids[1] != "": + return True + else: + if check_abc: + self.log.debug('is_proc_running: uitest pid: {}'.format(proc_pids[2])) + if proc_pids[2] != "": + return True + else: + self.log.debug('is_proc_running: agent pid: {}'.format(proc_pids[0])) + if proc_pids[0] != "": + return True + return False + + def is_harmony_rpc_socket_running(self, port, check_server=True): + out = self.execute_shell_command("netstat -anp | grep {}".format(port)) + self.log.debug(out) + if out: + out = out.split("\n") + for data in out: + if check_server: + if "LISTEN" in data and str(port) in data: + return True + else: + if "hdcd" in data and str(port) in data: + return True + return False + + def check_rpc_status(self, check_abc=False, check_server=True): + port = self.d_port if not check_abc else self.abc_d_port + if self.is_harmony_rpc_running(check_abc) and \ + self.is_harmony_rpc_socket_running(port, check_server=check_server): + self.log.debug('Harmony rpc is running!!!! If is check abc: {}'.format(check_abc)) + return True + self.log.debug('Harmony rpc is not running!!!! If is check abc: {}'.format(check_abc)) + return False + + def install_app(self, remote_path, command): + try: + ret = self.execute_shell_command( + "pm install %s %s" % (command, remote_path)) + if ret is not None and str( + ret) != "" and "Unknown option: -g" in str(ret): + return self.execute_shell_command( + "pm install -r %s" % remote_path) + return ret + except Exception as error: + self.log.error("%s, maybe there has a warning box appears " + "when installing RPC." % error) + return False + + def uninstall_app(self, package_name): + try: + ret = self.execute_shell_command("pm uninstall %s" % package_name) + self.log.debug(ret) + return ret + except Exception as err: + self.log.error('DeviceTest-20013 uninstall: %s' % str(err)) + return False + + def reconnect(self, waittime=60): + ''' + @summary: Reconnect the device. + ''' + if not self.wait_for_boot_completion(): + raise Exception("Reconnect timed out.") + if self._proxy: + self.start_harmony_rpc(re_install_rpc=True) + self._h_port = self.get_local_port(start_abc=False) + cmd = "fport tcp:{} tcp:{}".format( + self._h_port, self.d_port) + self.connector_command(cmd) + try: + self._proxy.init(port=self._h_port, addr=self.host, device=self) + except Exception as _: + time.sleep(3) + self._proxy.init(port=self._h_port, addr=self.host, device=self) + # do proxy clean + if not self.is_abc and self.proxy_listener is not None: + self.proxy_listener(is_exception=True) + if self.is_abc and self._abc_proxy: + self.start_abc_rpc(re_install_rpc=True) + self._h_port = self.get_local_port(start_abc=True) + cmd = "fport tcp:{} tcp:{}".format( + self._h_port, self.abc_d_port) + self.connector_command(cmd) + try: + self._abc_proxy.init(port=self._h_port, addr=self.host, device=self) + except Exception as _: + time.sleep(3) + self._abc_proxy.init(port=self._h_port, addr=self.host, device=self) + # do proxt clean + if self.proxy_listener is not None: + self.proxy_listener(is_exception=True) + + if self._uitestdeamon is not None: + self._uitestdeamon.init(self) + + if self._proxy: + return self._proxy + return None + + def wait_for_boot_completion(self): + """Waits for the device to boot up. + + Returns: + True if the device successfully finished booting, False otherwise. + """ + return self.device_state_monitor.wait_for_device_available(self.reboot_timeout) + + def get_local_port(self, start_abc): + from devicetest.utils.util import get_forward_port + host = self.host + port = None + h_port = get_forward_port(self, host, port) + if start_abc: + self.forward_ports_abc.append(h_port) + else: + self.forward_ports.append(h_port) + self.log.info("tcp forward port: {} for {}".format( + h_port, convert_serial(self.device_sn))) + return h_port + + def remove_ports(self): + for port in self.forward_ports: + cmd = "fport rm tcp:{} tcp:{}".format( + port, self.d_port) + self.connector_command(cmd) + for port in self.forward_ports_abc: + cmd = "fport rm tcp:{} tcp:{}".format( + port, self.abc_d_port) + self.connector_command(cmd) + self.forward_ports.clear() + self.forward_ports_abc.clear() + + def remove_history_ports(self, port): + cmd = "fport ls" + res = self.connector_command(cmd, is_print=False) + res = res.split("\n") + for data in res: + if str(port) in data: + data = data.split('\t') + cmd = "fport rm {}".format(data[0][1:-1]) + self.connector_command(cmd, is_print=False) + + @classmethod + def check_recover_result(cls, recover_result): + return "true" in recover_result + + def take_picture(self, name): + ''' + @summary: 截取手机屏幕图片并保存 + @param name: 保存的图片名称,通过getTakePicturePath方法获取保存全路径 + ''' + path = "" + try: + if self._device_report_path is None: + from xdevice import EnvPool + self._device_report_path = EnvPool.report_path + temp_path = os.path.join(self._device_report_path, "temp") + if not os.path.exists(temp_path): + os.makedirs(temp_path) + path = os.path.join(temp_path, name) + picture_name = os.path.basename(name) + out = self.execute_shell_command("snapshot_display -f /data/local/tmp/{}".format(picture_name)) + self.log.debug("result: {}".format(out)) + if "error" in out and "success" not in out: + return False + else: + self.pull_file("/data/local/tmp/{}".format(picture_name), path) + except Exception as error: + self.log.error("devicetest take_picture: {}".format(str(error))) + return path + + def execute_shell_in_daemon(self, command): + if self.host != "127.0.0.1": + cmd = [HdcHelper.CONNECTOR_NAME, "-s", "{}:{}".format( + self.host, self.port), "-t", self.device_sn, "shell"] + else: + cmd = [HdcHelper.CONNECTOR_NAME, "-t", self.device_sn, "shell"] + LOG.debug("{} execute command {} {} in daemon".format( + convert_serial(self.device_sn), HdcHelper.CONNECTOR_NAME, command)) + if isinstance(command, list): + cmd.extend(command) + else: + command = command.strip() + cmd.extend(command.split(" ")) + sys_type = platform.system() + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + shell=False, + preexec_fn=None if sys_type == "Windows" + else os.setsid, + close_fds=True) + return process + + @property + def webview(self): + from devicetest.controllers.web.webview import WebView + if self._webview is None: + self._webview = WebView(self) + return self._webview + + @property + def device_log_collector(self): + if self._device_log_collector is None: + self._device_log_collector = DeviceLogCollector(self) + return self._device_log_collector + + def set_device_report_path(self, path): + self._device_log_path = path + + def get_device_report_path(self): + return self._device_log_path + + +class DeviceLogCollector: + hilog_file_address = [] + log_file_address = [] + device = None + restart_proc = [] + + def __init__(self, device): + self.device = device + + def restart_catch_device_log(self): + for _, path in enumerate(self.hilog_file_address): + hilog_open = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + with os.fdopen(hilog_open, "a") as hilog_file_pipe: + _, proc = self.start_catch_device_log(hilog_file_pipe=hilog_file_pipe) + self.restart_proc.append(proc) + + def stop_restart_catch_device_log(self): + # when device free stop restart log proc + for _, proc in enumerate(self.restart_proc): + self.stop_catch_device_log(proc) + self.restart_proc.clear() + self.hilog_file_address.clear() + self.log_file_address.clear() + + def start_catch_device_log(self, log_file_pipe=None, + hilog_file_pipe=None): + """ + Starts hdc log for each device in separate subprocesses and save + the logs in files. + """ + self._sync_device_time() + device_hilog_proc = None + if hilog_file_pipe: + command = "hilog" + if self.device.host != "127.0.0.1": + cmd = [HdcHelper.CONNECTOR_NAME, "-s", "{}:{}".format(self.device.host, self.device.port), + "-t", self.device.device_sn, "shell", command] + else: + cmd = [HdcHelper.CONNECTOR_NAME, "-t", self.device.device_sn, "shell", command] + LOG.info("execute command: %s" % " ".join(cmd).replace( + self.device.device_sn, convert_serial(self.device.device_sn))) + device_hilog_proc = start_standing_subprocess( + cmd, hilog_file_pipe) + return None, device_hilog_proc + + def stop_catch_device_log(self, proc): + """ + Stops all hdc log subprocesses. + """ + if proc: + stop_standing_subprocess(proc) + self.device.log.debug("Stop catch device hilog.") + + def start_hilog_task(self, log_size="10M"): + log_size = log_size.upper() + if re.search("^\d+[K?]$", log_size) is None \ + and re.search("^\d+[M?]$", log_size) is None: + self.device.log.debug("hilog task Invalid size string {}. Use default 10M".format(log_size)) + log_size = "10M" + matcher = re.match("^\d+", log_size) + if log_size.endswith("K") and int(matcher.group(0)) < 64: + self.device.log.debug("hilog task file size should be " + "in range [64.0K, 512.0M], use min value 64K, now is {}".format(log_size)) + log_size = "64K" + if log_size.endswith("M") and int(matcher.group(0)) > 512: + self.device.log.debug("hilog task file size should be " + "in range [64.0K, 512.0M], use min value 512M, now is {}".format(log_size)) + log_size = "512M" + + self._sync_device_time() + self.clear_crash_log() + # 先停止一下 + cmd = "hilog -w stop" + self.device.execute_shell_command(cmd) + # 清空日志 + cmd = "hilog -r" + self.device.execute_shell_command(cmd) + cmd = "rm -rf /data/log/hilog/*.gz" + self.device.execute_shell_command(cmd) + # 开始日志任务 设置落盘文件个数最大值1000, 单个文件20M,链接https://gitee.com/openharmony/hiviewdfx_hilog + cmd = "hilog -w start -l {} -n 1000".format(log_size) + out = self.device.execute_shell_command(cmd) + LOG.info("Execute command: {}, result is {}".format(cmd, out)) + + def stop_hilog_task(self, log_name, **kwargs): + cmd = "hilog -w stop" + out = self.device.execute_shell_command(cmd) + module_name = kwargs.get("module_name", None) + if module_name: + path = "{}/log/{}".format(self.device.get_device_report_path(), module_name) + else: + path = "{}/log/".format(self.device.get_device_report_path()) + if not os.path.exists(path): + os.makedirs(path) + self.device.pull_file("/data/log/hilog/", path) + # HDC不支持创建绝对路径,拉取文件夹出来后重命名文件夹 + try: + new_hilog_dir = "{}/log/{}/hilog_{}".format(self.device.get_device_report_path(), module_name, log_name) + os.rename("{}/log/{}/hilog".format(self.device.get_device_report_path(), module_name), new_hilog_dir) + # 拉出来的文件夹权限可能是650,更改为755,解决在线日志浏览报403问题 + os.chmod(new_hilog_dir, FilePermission.mode_755) + except Exception as e: + self.device.log.warning("Rename hilog folder {}_hilog failed. error: {}".format(log_name, e)) + # 把hilog文件夹下所有文件拉出来 由于hdc不支持整个文件夹拉出只能采用先压缩再拉取文件 + cmd = "cd /data/log/hilog && tar -zcvf /data/log/{}_hilog.tar.gz *".format(log_name) + out = self.device.execute_shell_command(cmd) + LOG.info("Execute command: {}, result is {}".format(cmd, out)) + if out is not None and "No space left on device" not in out: + self.device.pull_file("/data/log/{}_hilog.tar.gz".format(log_name), path) + cmd = "rm -rf /data/log/{}_hilog.tar.gz".format(log_name) + self.device.execute_shell_command(cmd) + # 获取crash日志 + self.start_get_crash_log(log_name, module_name=module_name) + # 获取额外路径的日志 + self.pull_extra_log_files(log_name, module_name, kwargs.get("extras_dirs", None)) + + def _get_log(self, log_cmd, *params): + def filter_by_name(log_name, args): + for starts_name in args: + if log_name.startswith(starts_name): + return True + return False + + data_list = list() + log_name_array = list() + log_result = self.device.execute_shell_command(log_cmd) + if log_result is not None and len(log_result) != 0: + log_name_array = log_result.strip().replace("\r", "").split("\n") + for log_name in log_name_array: + log_name = log_name.strip() + if len(params) == 0 or \ + filter_by_name(log_name, params): + data_list.append(log_name) + return data_list + + def get_cur_crash_log(self, crash_path, log_name): + log_name_map = {'cppcrash': NATIVE_CRASH_PATH, + "jscrash": JS_CRASH_PATH, + "SERVICE_BLOCK": ROOT_PATH, + "appfreeze": ROOT_PATH} + if not os.path.exists(crash_path): + os.makedirs(crash_path) + if "Not support std mode" in log_name: + return + + def get_log_path(logname): + name_array = logname.split("-") + if len(name_array) <= 1: + return ROOT_PATH + return log_name_map.get(name_array[0]) + + log_path = get_log_path(log_name) + temp_path = "%s/%s" % (log_path, log_name) + self.device.pull_file(temp_path, crash_path) + LOG.debug("Finish pull file: %s" % log_name) + + def start_get_crash_log(self, task_name, **kwargs): + module_name = kwargs.get("module_name", None) + log_array = list() + native_crash_cmd = "ls {}".format(NATIVE_CRASH_PATH) + js_crash_cmd = '"ls {} | grep jscrash"'.format(JS_CRASH_PATH) + block_crash_cmd = '"ls {}"'.format(ROOT_PATH) + # 获取crash日志文件 + log_array.extend(self._get_log(native_crash_cmd, "cppcrash")) + log_array.extend(self._get_log(js_crash_cmd, "jscrash")) + log_array.extend(self._get_log(block_crash_cmd, "SERVICE_BLOCK", "appfreeze")) + LOG.debug("crash log file {}, length is {}".format(str(log_array), str(len(log_array)))) + if module_name: + crash_path = "{}/log/{}/{}_crash_log/".format(self.device.get_device_report_path(), module_name, task_name) + else: + crash_path = "{}/log/crash_log_{}/".format(self.device.get_device_report_path(), task_name) + for log_name in log_array: + log_name = log_name.strip() + self.get_cur_crash_log(crash_path, log_name) + + def clear_crash_log(self): + clear_block_crash_cmd = "rm -f {}/*".format(ROOT_PATH) + clear_native_crash_cmd = "rm -f {}/*".format(NATIVE_CRASH_PATH) + clear_debug_crash_cmd = "rm -f {}/debug/*".format(ROOT_PATH) + clear_js_crash_cmd = "rm -f {}/*".format(JS_CRASH_PATH) + self.device.execute_shell_command(clear_block_crash_cmd) + self.device.execute_shell_command(clear_native_crash_cmd) + self.device.execute_shell_command(clear_debug_crash_cmd) + self.device.execute_shell_command(clear_js_crash_cmd) + + def _sync_device_time(self): + # 先同步PC和设备的时间 + iso_time_format = '%Y-%m-%d %H:%M:%S' + cur_time = get_cst_time().strftime(iso_time_format) + self.device.execute_shell_command("date '{}'".format(cur_time)) + self.device.execute_shell_command("hwclock --systohc") + + def add_log_address(self, log_file_address, hilog_file_address): + # record to restart catch log when reboot device + if log_file_address: + self.log_file_address.append(log_file_address) + if hilog_file_address: + self.hilog_file_address.append(hilog_file_address) + + def remove_log_address(self, log_file_address, hilog_file_address): + if log_file_address and log_file_address in self.log_file_address: + self.log_file_address.remove(log_file_address) + if hilog_file_address and hilog_file_address in self.hilog_file_address: + self.hilog_file_address.remove(hilog_file_address) + + def pull_extra_log_files(self, task_name, module_name, dirs: str): + if dirs is None: + return + dir_list = dirs.split(";") + if len(dir_list) > 0: + extra_log_path = "{}/log/{}/extra_log_{}/".format(self.device.get_device_report_path(), + module_name, task_name) + if not os.path.exists(extra_log_path): + os.makedirs(extra_log_path) + for dir_path in dir_list: + self.device.pull_file(dir_path, extra_log_path) diff --git a/xdevice/plugins/ohos/src/ohos/environment/device_lite.py b/xdevice/plugins/ohos/src/ohos/environment/device_lite.py new file mode 100644 index 0000000..1d9649a --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/environment/device_lite.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import re +import telnetlib +import time +import os +import threading + +from xdevice import DeviceOsType +from xdevice import ConfigConst +from xdevice import DeviceLabelType +from xdevice import ModeType +from xdevice import IDevice +from xdevice import platform_logger +from xdevice import DeviceAllocationState +from xdevice import Plugin +from xdevice import exec_cmd +from xdevice import convert_serial +from xdevice import convert_ip +from xdevice import check_mode + +from ohos.exception import LiteDeviceConnectError +from ohos.exception import LiteDeviceTimeout +from ohos.exception import LiteParamError +from ohos.environment.dmlib_lite import LiteHelper +from ohos.constants import ComType + +LOG = platform_logger("DeviceLite") +TIMEOUT = 90 +RETRY_ATTEMPTS = 0 +HDC = "litehdc.exe" +DEFAULT_BAUD_RATE = 115200 + + +def get_hdc_path(): + from xdevice import Variables + user_path = os.path.join(Variables.exec_dir, "resource/tools") + top_user_path = os.path.join(Variables.top_dir, "config") + config_path = os.path.join(Variables.res_dir, "config") + paths = [user_path, top_user_path, config_path] + + file_path = "" + for path in paths: + if os.path.exists(os.path.abspath(os.path.join( + path, HDC))): + file_path = os.path.abspath(os.path.join( + path, HDC)) + break + + if os.path.exists(file_path): + return file_path + else: + raise LiteParamError("litehdc.exe not found", error_no="00108") + + +def parse_available_com(com_str): + com_str = com_str.replace("\r", " ") + com_list = com_str.split("\n") + for index, item in enumerate(com_list): + com_list[index] = item.strip().strip(b'\x00'.decode()) + return com_list + + +def perform_device_action(func): + def device_action(self, *args, **kwargs): + if not self.get_recover_state(): + LOG.debug("Device %s %s is false" % (self.device_sn, + ConfigConst.recover_state)) + return "", "", "" + + tmp = int(kwargs.get("retry", RETRY_ATTEMPTS)) + retry = tmp + 1 if tmp > 0 else 1 + exception = None + for num in range(retry): + try: + result = func(self, *args, **kwargs) + return result + except LiteDeviceTimeout as error: + LOG.error(error) + exception = error + if num: + self.recover_device() + except Exception as error: + LOG.error(error) + exception = error + raise exception + + return device_action + + +@Plugin(type=Plugin.DEVICE, id=DeviceOsType.lite) +class DeviceLite(IDevice): + """ + Class representing an device lite device. + + Each object of this class represents one device lite device in xDevice. + + Attributes: + device_connect_type: A string that's the type of lite device + """ + device_os_type = DeviceOsType.lite + device_allocation_state = DeviceAllocationState.available + + def __init__(self): + self.device_sn = "" + self.label = "" + self.device_connect_type = "" + self.device_kernel = "" + self.device = None + self.ifconfig = None + self.device_id = None + self.extend_value = {} + self.device_lock = threading.RLock() + + def __set_serial__(self, device=None): + for item in device: + if "ip" in item.keys() and "port" in item.keys(): + self.device_sn = "remote_%s_%s" % \ + (item.get("ip"), item.get("port")) + break + elif "type" in item.keys() and "com" in item.keys(): + self.device_sn = "local_%s" % item.get("com") + break + + def __get_serial__(self): + return self.device_sn + + def get(self, key=None, default=None): + if not key: + return default + value = getattr(self, key, None) + if value: + return value + else: + return self.extend_value.get(key, default) + + def __set_device_kernel__(self, kernel_type=""): + self.device_kernel = kernel_type + + def __get_device_kernel__(self): + return self.device_kernel + + @staticmethod + def _check_watchgt(device): + for item in device: + if "label" not in item.keys(): + error_message = "watchGT local label does not exist" + raise LiteParamError(error_message, error_no="00108") + if "com" not in item.keys() or ("com" in item.keys() and + not item.get("com")): + error_message = "watchGT local com cannot be " \ + "empty, please check" + raise LiteParamError(error_message, error_no="00108") + else: + hdc = get_hdc_path() + result = exec_cmd([hdc]) + com_list = parse_available_com(result) + if item.get("com").upper() in com_list: + return True + else: + error_message = "watchGT local com does not exist" + raise LiteParamError(error_message, error_no="00108") + + @staticmethod + def _check_wifiiot_config(device): + com_type_set = set() + for item in device: + if "label" not in item.keys(): + if "com" not in item.keys() or ("com" in item.keys() and + not item.get("com")): + error_message = "wifiiot local com cannot be " \ + "empty, please check" + raise LiteParamError(error_message, error_no="00108") + + if "type" not in item.keys() or ("type" not in item.keys() and + not item.get("type")): + error_message = "wifiiot com type cannot be " \ + "empty, please check" + raise LiteParamError(error_message, error_no="00108") + else: + com_type_set.add(item.get("type")) + if len(com_type_set) < 2: + error_message = "wifiiot need cmd com and deploy com" \ + " at the same time, please check" + raise LiteParamError(error_message, error_no="00108") + + @staticmethod + def _check_ipcamera_local(device): + for item in device: + if "label" not in item.keys(): + if "com" not in item.keys() or ("com" in item.keys() and + not item.get("com")): + error_message = "ipcamera local com cannot be " \ + "empty, please check" + raise LiteParamError(error_message, error_no="00108") + + @staticmethod + def _check_ipcamera_remote(device=None): + for item in device: + if "label" not in item.keys(): + if "port" in item.keys() and item.get("port") and not item.get( + "port").isnumeric(): + error_message = "ipcamera remote port should be " \ + "a number, please check" + raise LiteParamError(error_message, error_no="00108") + elif "port" not in item.keys(): + error_message = "ipcamera remote port cannot be" \ + " empty, please check" + raise LiteParamError(error_message, error_no="00108") + + + def __check_config__(self, device=None): + self.set_connect_type(device) + if self.label == DeviceLabelType.wifiiot: + self._check_wifiiot_config(device) + elif self.label == DeviceLabelType.ipcamera and \ + self.device_connect_type == "local": + self._check_ipcamera_local(device) + elif self.label == DeviceLabelType.ipcamera and \ + self.device_connect_type == "remote": + self._check_ipcamera_remote(device) + elif self.label == DeviceLabelType.watch_gt: + self._check_watchgt(device) + + def set_connect_type(self, device): + pattern = r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[' \ + r'01]?\d\d?)$' + for item in device: + if "label" in item.keys(): + self.label = item.get("label") + if "com" in item.keys(): + self.device_connect_type = "local" + if "ip" in item.keys(): + if re.match(pattern, item.get("ip")): + self.device_connect_type = "remote" + else: + error_message = "Remote device ip not in right" \ + "format, please check user_config.xml" + raise LiteParamError(error_message, error_no="00108") + if not self.label: + error_message = "device label cannot be empty, " \ + "please check" + raise LiteParamError(error_message, error_no="00108") + else: + if self.label != DeviceLabelType.wifiiot and \ + self.label != DeviceLabelType.ipcamera and \ + self.label != DeviceLabelType.watch_gt: + error_message = "device label should be ipcamera or" \ + " wifiiot, please check" + raise LiteParamError(error_message, error_no="00108") + if not self.device_connect_type: + error_message = "device com or ip cannot be empty, " \ + "please check" + raise LiteParamError(error_message, error_no="00108") + + def __init_device__(self, device): + self.__check_config__(device) + self.__set_serial__(device) + if self.device_connect_type == "remote": + self.device = CONTROLLER_DICT.get("remote")(device) + else: + self.device = CONTROLLER_DICT.get("local")(device) + + self.ifconfig = device[1].get("ifconfig") + + def connect(self): + """ + Connect the device + + """ + try: + self.device.connect() + except LiteDeviceConnectError as _: + if check_mode(ModeType.decc): + LOG.debug("Set device %s recover state to false" % + self.device_sn) + self.device_allocation_state = DeviceAllocationState.unusable + self.set_recover_state(False) + raise + + @perform_device_action + def execute_command_with_timeout(self, command="", case_type="", + timeout=TIMEOUT, **kwargs): + """Executes command on the device. + + Args: + command: the command to execute + case_type: CTest or CppTest + timeout: timeout for read result + **kwargs: receiver - parser handler input + + Returns: + (filter_result, status, error_message) + + filter_result: command execution result + status: true or false + error_message: command execution error message + """ + receiver = kwargs.get("receiver", None) + if self.device_connect_type == "remote": + LOG.info("%s execute command shell %s with timeout %ss" % + (convert_serial(self.__get_serial__()), command, + str(timeout))) + filter_result, status, error_message = \ + self.device.execute_command_with_timeout( + command=command, + timeout=timeout, + receiver=receiver) + elif self.device_connect_type == "agent": + filter_result, status, error_message = \ + self.device.execute_command_with_timeout( + command=command, + case_type=case_type, + timeout=timeout, + receiver=receiver, type="cmd") + else: + filter_result, status, error_message = \ + self.device.execute_command_with_timeout( + command=command, + case_type=case_type, + timeout=timeout, + receiver=receiver) + if not receiver: + LOG.debug("%s execute result:%s" % ( + convert_serial(self.__get_serial__()), filter_result)) + if not status: + LOG.debug( + "%s error_message:%s" % (convert_serial(self.__get_serial__()), + error_message)) + return filter_result, status, error_message + + def recover_device(self): + self.reboot() + + def reboot(self): + self.connect() + filter_result, status, error_message = self. \ + execute_command_with_timeout(command="reset", timeout=30) + if not filter_result: + if check_mode(ModeType.decc): + LOG.debug("Set device %s recover state to false" % + self.device_sn) + self.device_allocation_state = DeviceAllocationState.unusable + self.set_recover_state(False) + if self.ifconfig: + enter_result, _, _ = self.execute_command_with_timeout(command='\r', + timeout=15) + if " #" in enter_result or "OHOS #" in enter_result: + LOG.info("Reset device %s success" % self.device_sn) + self.execute_command_with_timeout(command=self.ifconfig, + timeout=5) + elif "hisilicon #" in enter_result: + LOG.info("Reset device %s fail" % self.device_sn) + + ifconfig_result, _, _ = self.execute_command_with_timeout( + command="ifconfig", + timeout=5) + + def close(self): + """ + Close the telnet connection with device server or close the local + serial + """ + self.device.close() + + def set_recover_state(self, state): + with self.device_lock: + setattr(self, ConfigConst.recover_state, state) + + def get_recover_state(self, default_state=True): + with self.device_lock: + state = getattr(self, ConfigConst.recover_state, default_state) + return state + + +class RemoteController: + """ + Class representing an device lite remote device. + Each object of this class represents one device lite remote device + in xDevice. + """ + + def __init__(self, device): + self.host = device[1].get("ip") + self.port = int(device[1].get("port")) + self.telnet = None + + def connect(self): + """ + Connect the device server + + """ + try: + if self.telnet: + return self.telnet + self.telnet = telnetlib.Telnet(self.host, self.port, + timeout=TIMEOUT) + except Exception as err_msgs: + error_message = "Connect remote lite device failed, host is %s, " \ + "port is %s, error is %s" % \ + (convert_ip(self.host), self.port, str(err_msgs)) + raise LiteDeviceConnectError(error_message, error_no="00401") + time.sleep(2) + self.telnet.set_debuglevel(0) + return self.telnet + + def execute_command_with_timeout(self, command="", timeout=TIMEOUT, + receiver=None): + """ + Executes command on the device. + + Parameters: + command: the command to execute + timeout: timeout for read result + receiver: parser handler + """ + return LiteHelper.execute_remote_cmd_with_timeout( + self.telnet, command, timeout, receiver) + + def close(self): + """ + Close the telnet connection with device server + """ + try: + if not self.telnet: + return + self.telnet.close() + self.telnet = None + except (ConnectionError, Exception) as _: + error_message = "Remote device is disconnected abnormally" + LOG.error(error_message, error_no="00401") + + +class LocalController: + def __init__(self, device): + """ + Init Local device. + Parameters: + device: local device + """ + self.com_dict = {} + for item in device: + if "com" in item.keys(): + if "type" in item.keys() and ComType.cmd_com == item.get( + "type"): + self.com_dict[ComType.cmd_com] = ComController(item) + elif "type" in item.keys() and ComType.deploy_com == item.get( + "type"): + self.com_dict[ComType.deploy_com] = ComController(item) + + def connect(self, key=ComType.cmd_com): + """ + Open serial. + """ + self.com_dict.get(key).connect() + + def close(self, key=ComType.cmd_com): + """ + Close serial. + """ + if self.com_dict and self.com_dict.get(key): + self.com_dict.get(key).close() + + def execute_command_with_timeout(self, **kwargs): + """ + Execute command on the serial and read all the output from the serial. + """ + args = kwargs + key = args.get("key", ComType.cmd_com) + command = args.get("command", None) + case_type = args.get("case_type", "") + receiver = args.get("receiver", None) + timeout = args.get("timeout", TIMEOUT) + return self.com_dict.get(key).execute_command_with_timeout( + command=command, case_type=case_type, + timeout=timeout, receiver=receiver) + + +class ComController: + def __init__(self, device): + """ + Init serial. + Parameters: + device: local com + """ + self.is_open = False + self.com = None + self.serial_port = device.get("com", None) + self.baud_rate = int(device.get("baud_rate", DEFAULT_BAUD_RATE)) + self.timeout = int(device.get("timeout", TIMEOUT)) + self.usb_port = device.get("usb_port", None) + + def connect(self): + """ + Open serial. + """ + try: + if not self.is_open: + import serial + self.com = serial.Serial(self.serial_port, + baudrate=self.baud_rate, + timeout=self.timeout) + self.is_open = True + except Exception as error_msg: + error = "connect %s serial failed, please make sure this port is" \ + " not occupied, error is %s[00401]" % \ + (self.serial_port, str(error_msg)) + raise LiteDeviceConnectError(error, error_no="00401") + + def close(self): + """ + Close serial. + """ + try: + if not self.com: + return + if self.is_open: + self.com.close() + self.is_open = False + except (ConnectionError, Exception) as _: + error_message = "Local device is disconnected abnormally" + LOG.error(error_message, error_no="00401") + + def execute_command_with_timeout(self, **kwargs): + """ + Execute command on the serial and read all the output from the serial. + """ + return LiteHelper.execute_local_cmd_with_timeout(self.com, **kwargs) + + def execute_command(self, command): + """ + Execute command on the serial and read all the output from the serial. + """ + LiteHelper.execute_local_command(self.com, command) + + +CONTROLLER_DICT = { + "local": LocalController, + "remote": RemoteController, +} diff --git a/xdevice/plugins/ohos/src/ohos/environment/dmlib.py b/xdevice/plugins/ohos/src/ohos/environment/dmlib.py new file mode 100644 index 0000000..6a33c14 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/environment/dmlib.py @@ -0,0 +1,1183 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import platform +import socket +import struct +import threading +import time +import shutil +import stat +from dataclasses import dataclass + +from xdevice import DeviceOsType +from xdevice import ReportException +from xdevice import ExecuteTerminate +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import IShellReceiver +from xdevice import exec_cmd +from xdevice import get_file_absolute_path +from xdevice import FilePermission +from xdevice import DeviceError +from xdevice import HdcError +from xdevice import HdcCommandRejectedException +from xdevice import ShellCommandUnresponsiveException +from xdevice import DeviceState +from xdevice import convert_serial +from xdevice import is_proc_running +from xdevice import convert_ip +from xdevice import create_dir + +ID_OKAY = b'OKAY' +ID_FAIL = b'FAIL' +ID_STAT = b'STAT' +ID_RECV = b'RECV' +ID_DATA = b'DATA' +ID_DONE = b'DONE' +ID_SEND = b'SEND' +ID_LIST = b'LIST' +ID_DENT = b'DENT' + +DEFAULT_ENCODING = "ISO-8859-1" +SYNC_DATA_MAX = 64 * 1024 +REMOTE_PATH_MAX_LENGTH = 1024 +SOCK_DATA_MAX = 256 + +INSTALL_TIMEOUT = 2 * 60 * 1000 +DEFAULT_TIMEOUT = 40 * 1000 + +MAX_CONNECT_ATTEMPT_COUNT = 10 +DATA_UNIT_LENGTH = 4 +HEXADECIMAL_NUMBER = 16 +SPECIAL_FILE_MODE = 41471 +FORMAT_BYTES_LENGTH = 4 +DEFAULT_OFFSET_OF_INT = 4 + +INVALID_MODE_CODE = -1 +DEFAULT_STD_PORT = 8710 +HDC_NAME = "hdc" +HDC_STD_NAME = "hdc_std" +LOG = platform_logger("Hdc") + + +class HdcMonitor: + """ + A Device monitor. + This monitor connects to the Device Connector, gets device and + debuggable process information from it. + """ + MONITOR_MAP = {} + + def __init__(self, host="127.0.0.1", port=None, device_connector=None): + self.channel = dict() + self.channel.setdefault("host", host) + self.channel.setdefault("port", port) + self.main_hdc_connection = None + self.connection_attempt = 0 + self.is_stop = False + self.monitoring = False + self.server = device_connector + self.devices = [] + self.last_msg_len = 0 + self.changed = True + + @staticmethod + def get_instance(host, port=None, device_connector=None): + if host not in HdcMonitor.MONITOR_MAP: + monitor = HdcMonitor(host, port, device_connector) + HdcMonitor.MONITOR_MAP[host] = monitor + LOG.debug("HdcMonitor map add host %s, map is %s" % + (host, HdcMonitor.MONITOR_MAP)) + + return HdcMonitor.MONITOR_MAP[host] + + def start(self): + """ + Starts the monitoring. + """ + try: + server_thread = threading.Thread(target=self.loop_monitor, + name="HdcMonitor", args=()) + server_thread.setDaemon(True) + server_thread.start() + except FileNotFoundError as _: + LOG.error("HdcMonitor can't find connector, init device " + "environment failed!") + + def init_hdc(self, connector_name=HDC_NAME): + env_hdc = shutil.which(connector_name) + # if not, add xdevice's own hdc path to environ path. + # tell if hdc has already been in the environ path. + if env_hdc is None: + LOG.error("Can not find {} or {} environment variable, " + "please set it first!".format(HDC_NAME, HDC_STD_NAME)) + if not is_proc_running(connector_name): + port = DEFAULT_STD_PORT + self.start_hdc( + connector=connector_name, + local_port=self.channel.setdefault( + "port", port)) + time.sleep(1) + + def _init_hdc_connection(self): + if self.main_hdc_connection is not None: + return + # set all devices disconnect + devices = [item for item in self.devices] + devices.reverse() + for local_device1 in devices: + local_device1.device_state = DeviceState.OFFLINE + self.server.device_changed(local_device1) + + connector_name = HDC_STD_NAME if HdcHelper.is_hdc_std() else HDC_NAME + self.init_hdc(connector_name) + self.connection_attempt = 0 + self.monitoring = False + while self.main_hdc_connection is None: + self.main_hdc_connection = self.open_hdc_connection() + if self.main_hdc_connection is None: + self.connection_attempt += 1 + if self.connection_attempt > MAX_CONNECT_ATTEMPT_COUNT: + self.is_stop = True + LOG.error( + "HdcMonitor attempt %s, can't connect to hdc " + "for Device List Monitoring" % + str(self.connection_attempt)) + raise HdcError( + "HdcMonitor cannot connect hdc server(%s %s)," + " please check!" % + (self.channel.get("host"), + str(self.channel.get("post")))) + + LOG.debug( + "HdcMonitor Connection attempts: %s" % + str(self.connection_attempt)) + time.sleep(2) + + def stop(self): + """ + Stops the monitoring. + """ + for host in HdcMonitor.MONITOR_MAP: + LOG.debug("HdcMonitor stop host %s" % host) + monitor = HdcMonitor.MONITOR_MAP[host] + try: + monitor.is_stop = True + if monitor.main_hdc_connection is not None: + monitor.main_hdc_connection.shutdown(2) + monitor.main_hdc_connection.close() + monitor.main_hdc_connection = None + except (socket.error, socket.gaierror, socket.timeout) as _: + LOG.error("HdcMonitor close socket exception") + HdcMonitor.MONITOR_MAP.clear() + LOG.debug("HdcMonitor {} monitor stop!".format(HdcHelper.CONNECTOR_NAME)) + LOG.debug("HdcMonitor map is %s" % HdcMonitor.MONITOR_MAP) + + def loop_monitor(self): + """ + Monitors the devices. This connects to the Debug Bridge + """ + LOG.debug("current connector name is %s" % HdcHelper.CONNECTOR_NAME) + while not self.is_stop: + try: + if self.main_hdc_connection is None: + self._init_hdc_connection() + if self.main_hdc_connection is not None: + LOG.debug( + "HdcMonitor Connected to hdc for device " + "monitoring, main_hdc_connection is %s" % + self.main_hdc_connection) + + self.list_targets() + time.sleep(1) + except (HdcError, Exception) as _: + self.handle_exception_monitor_loop() + time.sleep(2) + + def handle_exception_monitor_loop(self): + LOG.debug("Handle exception monitor loop: %s" % + self.main_hdc_connection) + if self.main_hdc_connection is None: + return + LOG.debug("Handle exception monitor loop, main hdc connection closed, " + "main hdc connection: %s" % self.main_hdc_connection) + self.main_hdc_connection.close() + self.main_hdc_connection = None + + def _get_device_instance(self, items, os_type): + device = get_plugin(plugin_type=Plugin.DEVICE, plugin_id=os_type)[0] + device_instance = device.__class__() + device_instance.__set_serial__(items[0]) + device_instance.host = self.channel.get("host") + device_instance.port = self.channel.get("port") + if self.changed: + LOG.debug("Dmlib get device instance %s %s %s" % + (device_instance.device_sn, + device_instance.host, device_instance.port)) + device_instance.device_state = DeviceState.get_state(items[3]) + return device_instance + + def update_devices(self, param_array_list): + devices = [item for item in self.devices] + devices.reverse() + for local_device1 in devices: + k = 0 + for local_device2 in param_array_list: + if local_device1.device_sn == local_device2.device_sn and \ + local_device1.device_os_type == \ + local_device2.device_os_type: + k = 1 + if local_device1.device_state != \ + local_device2.device_state: + local_device1.device_state = local_device2.device_state + self.server.device_changed(local_device1) + param_array_list.remove(local_device2) + break + + if k == 0: + self.devices.remove(local_device1) + self.server.device_disconnected(local_device1) + for local_device in param_array_list: + self.devices.append(local_device) + self.server.device_connected(local_device) + + def open_hdc_connection(self): + """ + Attempts to connect to the debug bridge server. Return a connect socket + if success, null otherwise. + """ + sock = None + try: + LOG.debug( + "HdcMonitor connecting to hdc for Device List Monitoring") + LOG.debug("HdcMonitor socket connection host: %s, port: %s" % + (str(convert_ip(self.channel.get("host"))), + str(int(self.channel.get("port"))))) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((self.channel.get("host"), + int(self.channel.get("port")))) + return sock + + except (socket.error, socket.gaierror, socket.timeout) as exception: + LOG.error("HdcMonitor hdc socket connection Error: %s, " + "host is %s, port is %s" % (str(exception), + self.channel.get("host"), + self.channel.get("port"))) + return None + + def start_hdc(self, connector=HDC_NAME, kill=False, local_port=None): + """Starts the hdc host side server. + + Args: + connector: connector type, like "hdc" + kill: if True, kill exist host side server + local_port: local port to start host side server + + Returns: + None + """ + if kill: + LOG.debug("HdcMonitor {} kill".format(connector)) + exec_cmd([connector, "kill"]) + LOG.debug("HdcMonitor {} start".format(connector)) + exec_cmd( + [connector, "-l5", "start"], + error_print=False, redirect=True) + + def list_targets(self): + if self.main_hdc_connection: + self.server.monitor_lock.acquire(timeout=1) + try: + self.monitoring_list_targets() + len_buf = HdcHelper.read(self.main_hdc_connection, + DATA_UNIT_LENGTH) + length = struct.unpack("!I", len_buf)[0] + if length >= 0: + if self.last_msg_len != length: + LOG.debug("had received length is: %s" % length) + self.last_msg_len = length + self.changed = True + else: + self.changed = False + self.connection_attempt = 0 + self.process_incoming_target_data(length) + except Exception as e: + LOG.error(e) + raise e + finally: + self.server.monitor_lock.release() + + def monitoring_list_targets(self): + if not self.monitoring: + HdcHelper.handle_shake(self.main_hdc_connection) + request = HdcHelper.form_hdc_request("alive") + HdcHelper.write(self.main_hdc_connection, request) + self.monitoring = True + request = HdcHelper.form_hdc_request('list targets -v') + HdcHelper.write(self.main_hdc_connection, request) + + def process_incoming_target_data(self, length): + local_array_list = [] + data_buf = HdcHelper.read(self.main_hdc_connection, length) + data_str = HdcHelper.reply_to_string(data_buf) + if 'Empty' not in data_str: + lines = data_str.split('\n') + for line in lines: + items = line.strip().split('\t') + # Example: sn USB Offline localhost hdc + if not items[0] or len(items) < 5: + continue + device_instance = self._get_device_instance( + items, DeviceOsType.default) + local_array_list.append(device_instance) + else: + if self.changed: + LOG.debug("please check device actually.[%s]" % data_str.strip()) + self.update_devices(local_array_list) + + @staticmethod + def peek_hdc(): + LOG.debug("Peek running process to check expect connector.") + # if not find hdc_std, try find hdc + connector_name = HDC_STD_NAME + env_hdc = shutil.which(connector_name) + # if not, add xdevice's own hdc path to environ path. + # tell if hdc has already been in the environ path. + if env_hdc is None: + connector_name = HDC_NAME + LOG.debug("Peak end") + return connector_name + + +@dataclass +class HdcResponse: + """Response from HDC.""" + okay = ID_OKAY # first 4 bytes in response were "OKAY"? + message = "" # diagnostic string if okay is false + + +class SyncService: + """ + Sync service class to push/pull to/from devices/emulators, + through the debug bridge. + """ + + def __init__(self, device, host=None, port=None): + self.device = device + self.host = host + self.port = port + self.sock = None + + def open_sync(self, timeout=DEFAULT_TIMEOUT): + """ + Opens the sync connection. This must be called before any calls to + push[File] / pull[File]. + Return true if the connection opened, false if hdc refuse the + connection. This can happen device is invalid. + """ + LOG.debug("Open sync, timeout=%s" % int(timeout / 1000)) + self.sock = HdcHelper.socket(host=self.host, port=self.port, + timeout=timeout) + HdcHelper.set_device(self.device, self.sock) + + request = HdcHelper.form_hdc_request("sync:") + HdcHelper.write(self.sock, request) + + resp = HdcHelper.read_hdc_response(self.sock) + if not resp.okay: + self.device.log.error( + "Got unhappy response from HDC sync req: %s" % resp.message) + raise HdcError( + "Got unhappy response from HDC sync req: %s" % resp.message) + + def close(self): + """ + Closes the connection. + """ + if self.sock is not None: + try: + self.sock.close() + except socket.error as error: + LOG.error("Socket close error: %s" % error, error_no="00420") + finally: + self.sock = None + + def pull_file(self, remote, local, is_create=False): + """ + Pulls a file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + mode = self.read_mode(remote) + self.device.log.debug("Remote file %s mode is %d" % (remote, mode)) + if mode == 0: + raise HdcError("Remote object doesn't exist!") + + if str(mode).startswith("168"): + if is_create: + remote_file_split = os.path.split(remote)[-1] \ + if os.path.split(remote)[-1] else os.path.split(remote)[-2] + remote_file_basename = os.path.basename(remote_file_split) + new_local = os.path.join(local, remote_file_basename) + create_dir(new_local) + else: + new_local = local + + collect_receiver = CollectingOutputReceiver() + HdcHelper.execute_shell_command(self.device, "ls %s" % remote, + receiver=collect_receiver) + files = collect_receiver.output.split() + for file_name in files: + self.pull_file("%s/%s" % (remote, file_name), + new_local, is_create=True) + elif mode == SPECIAL_FILE_MODE: + self.device.log.info("skipping special file '%s'" % remote) + else: + if os.path.isdir(local): + local = os.path.join(local, os.path.basename(remote)) + + self.do_pull_file(remote, local) + + def do_pull_file(self, remote, local): + """ + Pulls a remote file + """ + self.device.log.info( + "%s pull %s to %s" % (convert_serial(self.device.device_sn), + remote, local)) + remote_path_content = remote.encode(DEFAULT_ENCODING) + if len(remote_path_content) > REMOTE_PATH_MAX_LENGTH: + raise HdcError("Remote path is too long.") + + msg = self.create_file_req(ID_RECV, remote_path_content) + HdcHelper.write(self.sock, msg) + pull_result = HdcHelper.read(self.sock, DATA_UNIT_LENGTH * 2) + if not self.check_result(pull_result, ID_DATA) and \ + not self.check_result(pull_result, ID_DONE): + raise HdcError(self.read_error_message(pull_result)) + if platform.system() == "Windows": + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_BINARY + else: + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + pulled_file_open = os.open(local, flags, FilePermission.mode_755) + with os.fdopen(pulled_file_open, "wb") as pulled_file: + while True: + if self.check_result(pull_result, ID_DONE): + break + + if not self.check_result(pull_result, ID_DATA): + raise HdcError(self.read_error_message(pull_result)) + + try: + length = self.swap32bit_from_array( + pull_result, DEFAULT_OFFSET_OF_INT) + except IndexError as index_error: + self.device.log.debug("do_pull_file: %s" % + str(pull_result)) + if pull_result == ID_DATA: + pull_result = self.sock.recv(DATA_UNIT_LENGTH) + self.device.log.debug( + "do_pull_file: %s" % str(pull_result)) + length = self.swap32bit_from_array(pull_result, 0) + self.device.log.debug("do_pull_file: %s" % str(length)) + else: + raise IndexError(str(index_error)) from index_error + + if length > SYNC_DATA_MAX: + raise HdcError("Receiving too much data.") + + pulled_file.write(HdcHelper.read(self.sock, length)) + pulled_file.flush() + pull_result = self.sock.recv(DATA_UNIT_LENGTH * 2) + + def push_file(self, local, remote, is_create=False): + """ + Push a single file. + The top directory won't be created if is_create is False (by default) + and vice versa + """ + if not os.path.exists(local): + raise HdcError("Local path doesn't exist.") + + if os.path.isdir(local): + if is_create: + local_file_split = os.path.split(local)[-1] \ + if os.path.split(local)[-1] else os.path.split(local)[-2] + local_file_basename = os.path.basename(local_file_split) + remote = "{}/{}".format( + remote, local_file_basename) + HdcHelper.execute_shell_command( + self.device, "mkdir -p %s" % remote) + + for child in os.listdir(local): + file_path = os.path.join(local, child) + if os.path.isdir(file_path): + self.push_file( + file_path, "%s/%s" % (remote, child), + is_create=False) + else: + self.do_push_file(file_path, "%s/%s" % (remote, child)) + else: + self.do_push_file(local, remote) + + def do_push_file(self, local, remote): + """ + Push a single file + + Args: + ------------ + local : string + the local file to push + remote : string + the remote file (length max is 1024) + """ + mode = self.read_mode(remote) + self.device.log.debug("Remote file %s mode is %d" % (remote, mode)) + self.device.log.debug("%s execute command: hdc push %s %s" % ( + convert_serial(self.device.device_sn), local, remote)) + if str(mode).startswith("168"): + remote = "%s/%s" % (remote, os.path.basename(local)) + + try: + try: + remote_path_content = remote.encode(DEFAULT_ENCODING) + except UnicodeEncodeError as _: + remote_path_content = remote.encode("UTF-8") + if len(remote_path_content) > REMOTE_PATH_MAX_LENGTH: + raise HdcError("Remote path is too long.") + + # create the header for the action + # and send it. We use a custom try/catch block to make the + # difference between file and network IO exceptions. + msg = self.create_send_file_req(ID_SEND, remote_path_content, + FilePermission.mode_644) + + HdcHelper.write(self.sock, msg) + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(local, flags, modes), "rb") as test_file: + while True: + if platform.system() == "Linux": + data = test_file.read(1024 * 4) + else: + data = test_file.read(SYNC_DATA_MAX) + + if not data: + break + + buf = struct.pack( + "%ds%ds%ds" % (len(ID_DATA), FORMAT_BYTES_LENGTH, + len(data)), ID_DATA, + self.swap32bits_to_bytes(len(data)), data) + self.sock.send(buf) + except Exception as exception: + self.device.log.error("exception %s" % exception) + raise exception + + msg = self.create_req(ID_DONE, int(time.time())) + HdcHelper.write(self.sock, msg) + result = HdcHelper.read(self.sock, DATA_UNIT_LENGTH * 2) + if not self.check_result(result, ID_OKAY): + self.device.log.error("exception %s" % result) + raise HdcError(self.read_error_message(result)) + + def read_mode(self, path): + """ + Returns the mode of the remote file. + Return an Integer containing the mode if all went well or null + """ + msg = self.create_file_req(ID_STAT, path) + HdcHelper.write(self.sock, msg) + + # read the result, in a byte array containing 4 ints + stat_result = HdcHelper.read(self.sock, DATA_UNIT_LENGTH * 4) + if not self.check_result(stat_result, ID_STAT): + return INVALID_MODE_CODE + + return self.swap32bit_from_array(stat_result, DEFAULT_OFFSET_OF_INT) + + def create_file_req(self, command, path): + """ + Creates the data array for a file request. This creates an array with a + 4 byte command + the remote file name. + + Args: + ------------ + command : + the 4 byte command (ID_STAT, ID_RECV, ...) + path : string + The path, as a byte array, of the remote file on which to execute + the command. + + return: + ------------ + return the byte[] to send to the device through hdc + """ + if isinstance(path, str): + try: + path = path.encode(DEFAULT_ENCODING) + except UnicodeEncodeError as _: + path = path.encode("UTF-8") + + return struct.pack( + "%ds%ds%ds" % (len(command), FORMAT_BYTES_LENGTH, len(path)), + command, self.swap32bits_to_bytes(len(path)), path) + + def create_send_file_req(self, command, path, mode=0o644): + # make the mode into a string + mode_str = ",%s" % str(mode & FilePermission.mode_777) + mode_content = mode_str.encode(DEFAULT_ENCODING) + return struct.pack( + "%ds%ds%ds%ds" % (len(command), FORMAT_BYTES_LENGTH, len(path), + len(mode_content)), + command, self.swap32bits_to_bytes(len(path) + len(mode_content)), + path, mode_content) + + def create_req(self, command, value): + """ + Create a command with a code and an int values + """ + return struct.pack("%ds%ds" % (len(command), FORMAT_BYTES_LENGTH), + command, self.swap32bits_to_bytes(value)) + + @staticmethod + def check_result(result, code): + """ + Checks the result array starts with the provided code + + Args: + ------------ + result : + the result array to check + path : string + the 4 byte code + + return: + ------------ + bool + return true if the code matches + """ + return result[0:4] == code[0:4] + + def read_error_message(self, result): + """ + Reads an error message from the opened Socket. + + Args: + ------------ + result : + the current hdc result. Must contain both FAIL and the length of + the message. + """ + if self.check_result(result, ID_FAIL): + length = self.swap32bit_from_array(result, 4) + if length > 0: + return str(HdcHelper.read(self.sock, length)) + + return None + + @staticmethod + def swap32bits_to_bytes(value): + """ + Swaps an unsigned value around, and puts the result in an bytes that + can be sent to a device. + + Args: + ------------ + value : + the value to swap. + """ + return bytes([value & 0x000000FF, + (value & 0x0000FF00) >> 8, + (value & 0x00FF0000) >> 16, + (value & 0xFF000000) >> 24]) + + @staticmethod + def swap32bit_from_array(value, offset): + """ + Reads a signed 32 bit integer from an array coming from a device. + + Args: + ------------ + value : + the array containing the int + offset: + the offset in the array at which the int starts + + Return: + ------------ + int + the integer read from the array + """ + result = 0 + result |= (int(value[offset])) & 0x000000FF + result |= (int(value[offset + 1]) & 0x000000FF) << 8 + result |= (int(value[offset + 2]) & 0x000000FF) << 16 + result |= (int(value[offset + 3]) & 0x000000FF) << 24 + + return result + + +class HdcHelper: + CONNECTOR_NAME = "" + + @staticmethod + def check_if_hdc_running(timeout=30): + LOG.debug("Check if {} is running, timeout is {}s".format( + HdcHelper.CONNECTOR_NAME, timeout)) + index = 1 + while index < timeout: + if is_proc_running(HdcHelper.CONNECTOR_NAME): + return True + index = index + 1 + time.sleep(1) + return False + + @staticmethod + def push_file(device, local, remote, is_create=False, + timeout=DEFAULT_TIMEOUT): + device.log.info("{} execute command: {} file send {} to {}".format( + convert_serial(device.device_sn), HdcHelper.CONNECTOR_NAME, local, remote)) + HdcHelper._operator_file("file send", device, local, remote, timeout) + + @staticmethod + def pull_file(device, remote, local, is_create=False, + timeout=DEFAULT_TIMEOUT): + device.log.info("{} execute command: {} file recv {} to {}".format( + convert_serial(device.device_sn), HdcHelper.CONNECTOR_NAME, remote, local)) + HdcHelper._operator_file("file recv", device, remote, local, timeout) + + @staticmethod + def _install_remote_package(device, remote_file_path, command): + receiver = CollectingOutputReceiver() + cmd = "bm install -p %s %s" % (command.strip(), remote_file_path) + HdcHelper.execute_shell_command(device, cmd, INSTALL_TIMEOUT, receiver) + return receiver.output + + @staticmethod + def install_package(device, package_file_path, command): + device.log.info("%s install %s" % (convert_serial(device.device_sn), + package_file_path)) + remote_file_path = "/data/local/tmp/%s" % os.path.basename( + package_file_path) + + HdcHelper.push_file(device, package_file_path, remote_file_path) + + result = HdcHelper._install_remote_package(device, remote_file_path, + command) + HdcHelper.execute_shell_command(device, "rm %s " % remote_file_path) + return result + + @staticmethod + def uninstall_package(device, package_name): + receiver = CollectingOutputReceiver() + command = "bm uninstall -n %s " % package_name + device.log.info("%s %s" % (convert_serial(device.device_sn), command)) + HdcHelper.execute_shell_command(device, command, INSTALL_TIMEOUT, + receiver) + return receiver.output + + @staticmethod + def reboot(device, into=None): + device.log.info("{} execute command: {} target boot".format(convert_serial(device.device_sn), + HdcHelper.CONNECTOR_NAME)) + with HdcHelper.socket(host=device.host, port=device.port) as sock: + HdcHelper.handle_shake(sock, device.device_sn) + request = HdcHelper.form_hdc_request("target boot") + HdcHelper.write(sock, request) + + @staticmethod + def execute_shell_command(device, command, timeout=DEFAULT_TIMEOUT, + receiver=None, **kwargs): + """ + Executes a shell command on the device and retrieve the output. + + Args: + ------------ + device : IDevice + on which to execute the command. + command : string + the shell command to execute + timeout : int + max time between command output. If more time passes between + command output, the method will throw + ShellCommandUnresponsiveException (ms). + """ + try: + if not timeout: + timeout = DEFAULT_TIMEOUT + + with HdcHelper.socket(host=device.host, port=device.port, + timeout=timeout) as sock: + output_flag = kwargs.get("output_flag", True) + timeout_msg = " with timeout %ss" % str(timeout / 1000) + message = "{} execute command: {} shell {}{}".format(convert_serial(device.device_sn), + HdcHelper.CONNECTOR_NAME, + command, timeout_msg) + if output_flag: + LOG.info(message) + else: + LOG.debug(message) + from xdevice import Scheduler + HdcHelper.handle_shake(sock, device.device_sn) + request = HdcHelper.form_hdc_request("shell {}".format(command)) + HdcHelper.write(sock, request) + resp = HdcResponse() + resp.okay = True + while True: + len_buf = sock.recv(DATA_UNIT_LENGTH) + if len_buf: + length = struct.unpack("!I", len_buf)[0] + else: + break + data = sock.recv(length) + ret = HdcHelper.reply_to_string(data) + if ret: + if receiver: + receiver.__read__(ret) + else: + LOG.debug(ret) + if not Scheduler.is_execute: + raise ExecuteTerminate() + return resp + except socket.timeout as error: + device.log.error("ShellCommandUnresponsiveException: {} shell {} timeout[{}S]".format( + convert_serial(device.device_sn), command, str(timeout / 1000))) + raise ShellCommandUnresponsiveException() from error + finally: + if receiver: + receiver.__done__() + + @staticmethod + def set_device(device, sock): + """ + Tells hdc to talk to a specific device + if the device is not -1, then we first tell hdc we're looking to talk + to a specific device + """ + msg = "host:transport:%s" % device.device_sn + device_query = HdcHelper.form_hdc_request(msg) + HdcHelper.write(sock, device_query) + resp = HdcHelper.read_hdc_response(sock) + if not resp.okay: + raise HdcCommandRejectedException(resp.message) + + @staticmethod + def form_hdc_request(req): + """ + Create an ASCII string preceded by four hex digits. + """ + try: + if not req.endswith('\0'): + req = "%s\0" % req + req = req.encode("utf-8") + fmt = "!I%ss" % len(req) + result = struct.pack(fmt, len(req), req) + except UnicodeEncodeError as ex: + LOG.error(ex) + raise ex + return result + + @staticmethod + def read_hdc_response(sock, read_diag_string=False): + """ + Reads the response from HDC after a command. + + Args: + ------------ + read_diag_string : + If true, we're expecting an OKAY response to be followed by a + diagnostic string. Otherwise, we only expect the diagnostic string + to follow a FAIL. + """ + resp = HdcResponse() + reply = HdcHelper.read(sock, DATA_UNIT_LENGTH) + if HdcHelper.is_okay(reply): + resp.okay = True + else: + read_diag_string = True + resp.okay = False + + while read_diag_string: + len_buf = HdcHelper.read(sock, DATA_UNIT_LENGTH) + len_str = HdcHelper.reply_to_string(len_buf) + msg = HdcHelper.read(sock, int(len_str, HEXADECIMAL_NUMBER)) + resp.message = HdcHelper.reply_to_string(msg) + break + + return resp + + @staticmethod + def write(sock, req, timeout=10): + if isinstance(req, str): + req = req.encode(DEFAULT_ENCODING) + elif isinstance(req, list): + req = bytes(req) + + start_time = time.time() + while req: + if time.time() - start_time > timeout: + LOG.debug("Socket write timeout, timeout:%ss" % timeout) + break + + size = sock.send(req) + if size < 0: + raise DeviceError("channel EOF") + + req = req[size:] + time.sleep(5 / 1000) + + @staticmethod + def read(sock, length, timeout=10): + data = b'' + recv_len = 0 + start_time = time.time() + exc_num = 3 + while length - recv_len > 0: + if time.time() - start_time > timeout: + LOG.debug("Socket read timeout, timout:%ss" % timeout) + break + try: + recv = sock.recv(length - recv_len) + if len(recv) > 0: + time.sleep(5 / 1000) + else: + break + except ConnectionResetError as error: + if exc_num <= 0: + raise error + exc_num = exc_num - 1 + recv = b'' + time.sleep(1) + LOG.debug("ConnectionResetError occurs") + + data += recv + recv_len += len(recv) + + return data + + @staticmethod + def is_okay(reply): + """ + Checks to see if the first four bytes in "reply" are OKAY. + """ + return reply[0:4] == ID_OKAY + + @staticmethod + def reply_to_string(reply): + """ + Converts an HDC reply to a string. + """ + try: + return str(reply, encoding=DEFAULT_ENCODING) + except (ValueError, TypeError) as _: + return "" + + @staticmethod + def socket(host=None, port=None, timeout=None): + end = time.time() + 10 * 60 + sock = None + hdc_connection = HdcMonitor.MONITOR_MAP.get(host, "127.0.0.1") + while host not in HdcMonitor.MONITOR_MAP or \ + hdc_connection.main_hdc_connection is None: + LOG.debug("Host: %s, port: %s, HdcMonitor map is %s" % ( + host, port, HdcMonitor.MONITOR_MAP)) + if host in HdcMonitor.MONITOR_MAP: + LOG.debug("Monitor main hdc connection is %s" % + hdc_connection.main_hdc_connection) + if time.time() > end: + raise HdcError("Cannot detect HDC monitor!") + time.sleep(2) + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, int(port))) + except socket.error as exception: + LOG.exception("Connect hdc server error: %s" % str(exception), + exc_info=False) + raise exception + + if sock is None: + raise HdcError("Cannot connect hdc server!") + + if timeout is not None: + sock.setblocking(False) + sock.settimeout(timeout / 1000) + + return sock + + @staticmethod + def handle_shake(connection, connect_key=""): + reply = HdcHelper.read(connection, 48) + struct.unpack(">I12s32s", reply) + banner_str = b'OHOS HDC' + connect_key = connect_key.encode("utf-8") + size = struct.calcsize('12s256s') + fmt = "!I12s256s" + pack_cmd = struct.pack(fmt, size, banner_str, connect_key) + HdcHelper.write(connection, pack_cmd) + return True + + @staticmethod + def _operator_file(command, device, local, remote, timeout): + sock = HdcHelper.socket(host=device.host, port=device.port, + timeout=timeout) + HdcHelper.handle_shake(sock, device.device_sn) + request = HdcHelper.form_hdc_request( + "%s %s %s" % (command, local, remote)) + HdcHelper.write(sock, request) + reply = HdcHelper.read(sock, DATA_UNIT_LENGTH) + length = struct.unpack("!I", reply)[0] + data_buf = HdcHelper.read(sock, length) + HdcHelper.reply_to_string(data_buf) + LOG.debug("result %s" % data_buf) + + @staticmethod + def is_hdc_std(): + return HDC_STD_NAME in HdcHelper.CONNECTOR_NAME + + +class DeviceConnector(object): + __instance = None + __init_flag = False + + def __init__(self, host=None, port=None, usb_type=None): + if DeviceConnector.__init_flag: + return + self.device_listeners = [] + self.device_monitor = None + self.monitor_lock = threading.Condition() + self.host = host if host else "127.0.0.1" + self.usb_type = usb_type + connector_name = HdcMonitor.peek_hdc() + HdcHelper.CONNECTOR_NAME = connector_name + if port: + self.port = int(port) + else: + self.port = int(os.getenv("OHOS_HDC_SERVER_PORT", DEFAULT_STD_PORT)) + + def start(self): + self.device_monitor = HdcMonitor.get_instance( + self.host, self.port, device_connector=self) + self.device_monitor.start() + + def terminate(self): + if self.device_monitor: + self.device_monitor.stop() + self.device_monitor = None + + def add_device_change_listener(self, device_change_listener): + self.device_listeners.append(device_change_listener) + + def remove_device_change_listener(self, device_change_listener): + if device_change_listener in self.device_listeners: + self.device_listeners.remove(device_change_listener) + + def device_connected(self, device): + LOG.debug("DeviceConnector device connected:host %s, port %s, " + "device sn %s " % (self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_connected(device) + + def device_disconnected(self, device): + LOG.debug("DeviceConnector device disconnected:host %s, port %s, " + "device sn %s" % (self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_disconnected(device) + + def device_changed(self, device): + LOG.debug("DeviceConnector device changed:host %s, port %s, " + "device sn %s" % (self.host, self.port, device.device_sn)) + if device.host != self.host or device.port != self.port: + LOG.debug("DeviceConnector device error") + for listener in self.device_listeners: + listener.device_changed(device) + + +class CollectingOutputReceiver(IShellReceiver): + def __init__(self): + self.output = "" + + def __read__(self, output): + self.output = "%s%s" % (self.output, output) + + def __error__(self, message): + pass + + def __done__(self, result_code="", message=""): + pass + + +class DisplayOutputReceiver(IShellReceiver): + def __init__(self): + self.output = "" + self.unfinished_line = "" + + def _process_output(self, output, end_mark="\n"): + content = output + if self.unfinished_line: + content = "".join((self.unfinished_line, content)) + self.unfinished_line = "" + lines = content.split(end_mark) + if content.endswith(end_mark): + # get rid of the tail element of this list contains empty str + return lines[:-1] + else: + self.unfinished_line = lines[-1] + # not return the tail element of this list contains unfinished str, + # so we set position -1 + return lines[:-1] + + def __read__(self, output): + self.output = "%s%s" % (self.output, output) + lines = self._process_output(output) + for line in lines: + line = line.strip() + if line: + LOG.info(line) + + def __error__(self, message): + pass + + def __done__(self, result_code="", message=""): + pass + + +def process_command_ret(ret, receiver): + try: + if ret != "" and receiver: + receiver.__read__(ret) + receiver.__done__() + except Exception as error: + LOG.exception("Error generating log report.", exc_info=False) + raise ReportException() from error + + if ret != "" and not receiver: + lines = ret.split("\n") + for line in lines: + line = line.strip() + if line: + LOG.debug(line) diff --git a/xdevice/plugins/ohos/src/ohos/environment/dmlib_lite.py b/xdevice/plugins/ohos/src/ohos/environment/dmlib_lite.py new file mode 100644 index 0000000..006d159 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/environment/dmlib_lite.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# +import json +import time +import re + +from xdevice import DeviceTestType +from xdevice import ExecuteTerminate +from xdevice import platform_logger +from ohos.exception import LiteDeviceTimeout +from ohos.exception import LiteDeviceConnectError +from ohos.exception import LiteDeviceExecuteCommandError + + +__all__ = ["generate_report", "LiteHelper"] + +CPP_TEST_STANDARD_SIGN = "[==========]" +CPP_TEST_END_SIGN = "Gtest xml output finished" +CPP_SYS_STANDARD_SIGN = "OHOS #" +CPP_ERR_MESSAGE = "[ERR]No such file or directory: " +CTEST_STANDARD_SIGN = "Start to run test suite" +AT_CMD_ENDS = "OK" +CTEST_END_SIGN = "All the test suites finished" +CPP_TEST_STOP_SIGN = "Test Stop" +CPP_TEST_MOUNT_SIGN = "not mount properly" + +_START_JSUNIT_RUN_MARKER = "[start] start run suites" +_END_JSUNIT_RUN_MARKER = "[end] run suites end" +INSTALL_END_MARKER = "resultMessage is install success !" + +PATTERN = re.compile(r'\x1B(\[([0-9]{1,2}(;[0-9]{1,2})*)?m)*') +TIMEOUT = 90 +STATUS_OK_CODE = 200 +LOG = platform_logger("DmlibLite") + + +def check_open_source_test(result_output): + if result_output.find(CPP_TEST_STANDARD_SIGN) == -1 and \ + ("test pass" in result_output.lower() or + "test fail" in result_output.lower() or + "tests pass" in result_output.lower() or + "tests fail" in result_output.lower()): + return True + return False + + +def check_read_test_end(result=None, input_command=None): + temp_result = result.replace("\n", "") + index = result.find(input_command) + len(input_command) + result_output = result[index:] + if input_command.startswith("./"): + if result_output.find(CPP_TEST_STANDARD_SIGN) != -1: + if result_output.count(CPP_TEST_STANDARD_SIGN) == 2 or \ + result_output.find(CPP_TEST_END_SIGN) != -1: + return True + if check_open_source_test(result_output): + return True + if result_output.find(_START_JSUNIT_RUN_MARKER) >= 1 and \ + result_output.find(_END_JSUNIT_RUN_MARKER) >= 1: + return True + + if result_output.find(INSTALL_END_MARKER) != -1: + return True + if (result_output.find(CPP_TEST_MOUNT_SIGN) != -1 + and result_output.find(CPP_TEST_STOP_SIGN) != -1): + LOG.info("Find test stop") + return True + if "%s%s" % (CPP_ERR_MESSAGE, input_command[2:]) in result_output: + LOG.error("Execute file not exist, result is %s" % result_output, + error_no="00402") + raise LiteDeviceExecuteCommandError("execute file not exist", + error_no="00402") + elif input_command.startswith("zcat"): + return False + else: + if "OHOS #" in result_output or "# " in result_output: + if input_command == "reboot" or input_command == "reset": + return False + if input_command.startswith("mount"): + if "Mount nfs finished." not in result_output: + return False + return True + return False + + +def generate_report(receiver, result): + if result and receiver: + if result: + receiver.__read__(result) + receiver.__done__() + + +def get_current_time(): + current_time = time.time() + local_time = time.localtime(current_time) + data_head = time.strftime("%Y-%m-%d %H:%M:%S", local_time) + millisecond = (current_time - int(current_time)) * 1000 + return "%s.%03d" % (data_head, millisecond) + + +class LiteHelper: + @staticmethod + def execute_remote_cmd_with_timeout(telnet, command="", timeout=TIMEOUT, + receiver=None): + """ + Executes command on the device. + + Parameters: + telnet: + command: the command to execute + timeout: timeout for read result + receiver: parser handler + """ + from xdevice import Scheduler + time.sleep(2) + start_time = time.time() + status = True + error_message = "" + result = "" + if not telnet: + raise LiteDeviceConnectError("remote device is not connected.", + error_no="00402") + + telnet.write(command.encode('ascii') + b"\n") + while time.time() - start_time < timeout: + data = telnet.read_until(bytes(command, encoding="utf8"), + timeout=1) + data = PATTERN.sub('', data.decode('gbk', 'ignore')).replace( + "\r", "") + result = "{}{}".format(result, data) + if command in result: + break + + expect_result = [bytes(CPP_TEST_STANDARD_SIGN, encoding="utf8"), + bytes(CPP_SYS_STANDARD_SIGN, encoding="utf8"), + bytes(CPP_TEST_END_SIGN, encoding="utf8"), + bytes(CPP_TEST_STOP_SIGN, encoding="utf8")] + while time.time() - start_time < timeout: + if not Scheduler.is_execute: + raise ExecuteTerminate("Execute terminate", error_no="00300") + _, _, data = telnet.expect(expect_result, timeout=1) + data = PATTERN.sub('', data.decode('gbk', 'ignore')).replace( + "\r", "") + result = "{}{}".format(result, data) + if receiver and data: + receiver.__read__(data) + if check_read_test_end(result, command): + break + else: + error_message = "execute %s timed out %s " % (command, timeout) + status = False + + if receiver: + receiver.__done__() + + if not status and command.startswith("uname"): + raise LiteDeviceTimeout("Execute command time out:%s" % command) + + return result, status, error_message + + @staticmethod + def read_local_output_test(com=None, command=None, timeout=TIMEOUT, + receiver=None): + input_command = command + linux_end_command = "" + if "--gtest_output=" in command: + linux_end_command = input_command.split(":")[1].split( + "reports")[0].rstrip("/") + " #" + error_message = "" + start_time = time.time() + result = "" + status = True + from xdevice import Scheduler + while time.time() - start_time < timeout: + if not Scheduler.is_execute: + raise ExecuteTerminate("Execute terminate", error_no="00300") + if com.in_waiting == 0: + continue + data = com.read(com.in_waiting).decode('gbk', errors='ignore') + data = PATTERN.sub('', data).replace("\r", "") + result = "{}{}".format(result, data) + if receiver and data: + receiver.__read__(data) + if check_read_test_end(result, input_command): + break + else: + error_message = "execute %s timed out %s " % (command, timeout) + status = False + + if receiver: + receiver.__done__() + + if not status and command.startswith("uname"): + raise LiteDeviceTimeout("Execute command time out:%s" % command) + + return result, status, error_message + + @staticmethod + def read_local_output_ctest(com=None, command=None, timeout=TIMEOUT, + receiver=None): + result = "" + input_command = command + + start = time.time() + from xdevice import Scheduler + while True: + if not Scheduler.is_execute: + raise ExecuteTerminate("Execute terminate", error_no="00300") + data = com.readline().decode('gbk', errors='ignore') + data = PATTERN.sub('', data) + if isinstance(input_command, list): + if len(data.strip()) > 0: + data = "{} {}".format(get_current_time(), data) + if data and receiver: + receiver.__read__(data.replace("\r", "")) + result = "{}{}".format(result, data.replace("\r", "")) + if re.search(r"\d+\s+Tests\s+\d+\s+Failures\s+\d+\s+" + r"Ignored", data): + start = time.time() + if CTEST_END_SIGN in data: + break + if (int(time.time()) - int(start)) > timeout: + break + else: + result = "{}{}".format( + result, data.replace("\r", "").replace("\n", "").strip()) + if AT_CMD_ENDS in data: + return result, True, "" + if (int(time.time()) - int(start)) > timeout: + return result, False, "" + + if receiver: + receiver.__done__() + LOG.info('Info: execute command success') + return result, True, "" + + @staticmethod + def read_local_output(com=None, command=None, case_type="", + timeout=TIMEOUT, receiver=None): + if case_type == DeviceTestType.ctest_lite: + return LiteHelper.read_local_output_ctest(com, command, + timeout, receiver) + else: + return LiteHelper.read_local_output_test(com, command, + timeout, receiver) + + @staticmethod + def execute_local_cmd_with_timeout(com, **kwargs): + """ + Execute command on the serial and read all the output from the serial. + """ + args = kwargs + command = args.get("command", None) + input_command = command + case_type = args.get("case_type", "") + timeout = args.get("timeout", TIMEOUT) + receiver = args.get("receiver", None) + if not com: + raise LiteDeviceConnectError("local device is not connected.", + error_no="00402") + + LOG.info("local_%s execute command shell %s with timeout %ss" % + (com.port, command, str(timeout))) + + if isinstance(command, str): + command = command.encode("utf-8") + if command[-2:] != b"\r\n": + command = command.rstrip() + b'\r\n' + com.write(command) + else: + com.write(command) + return LiteHelper.read_local_output( + com, command=input_command, case_type=case_type, timeout=timeout, + receiver=receiver) + + @staticmethod + def execute_local_command(com, command): + """ + Execute command on the serial and read all the output from the serial. + """ + if not com: + raise LiteDeviceConnectError("local device is not connected.", + error_no="00402") + + LOG.info( + "local_%s execute command shell %s" % (com.port, command)) + command = command.encode("utf-8") + if command[-2:] != b"\r\n": + command = command.rstrip() + b'\r\n' + com.write(command) diff --git a/xdevice/plugins/ohos/src/ohos/environment/emulator.py b/xdevice/plugins/ohos/src/ohos/environment/emulator.py new file mode 100644 index 0000000..e7a8bc0 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/environment/emulator.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +from xdevice import IDevice +from xdevice import platform_logger +from xdevice import DeviceAllocationState +from xdevice import TestDeviceState + +LOG = platform_logger("Emulator") + + +class Emulator(IDevice): + """ + Class representing an emulator. + + Each object of this class represents one emulator in xDevice. + + Attributes: + device_sn: A string that's the serial number of the emulator. + """ + + def __get_serial__(self): + pass + + def __set_serial__(self, device_sn=""): + pass + + def __init__(self, device_sn=""): + self.device_sn = device_sn + self.is_timeout = False + self.device_log_proc = None + self.test_device_state = TestDeviceState.ONLINE + self.device_allocation_state = DeviceAllocationState.available + + def __serial__(self): + return self.device_sn + + def get_device_sn(self): + """ + Returns the serial number of the device. + """ + return self.device_sn diff --git a/xdevice/plugins/ohos/src/ohos/exception.py b/xdevice/plugins/ohos/src/ohos/exception.py new file mode 100644 index 0000000..6192f93 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/exception.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# +from xdevice import DeviceError + +__all__ = ["LiteDeviceConnectError", "LiteDeviceTimeout", "LiteParamError", + "LiteDeviceError", "LiteDeviceExecuteCommandError", + "LiteDeviceMountError", "LiteDeviceReadOutputError"] + + +class LiteDeviceError(Exception): + def __init__(self, error_msg, error_no=""): + super(LiteDeviceError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class LiteDeviceConnectError(LiteDeviceError): + def __init__(self, error_msg, error_no=""): + super(LiteDeviceConnectError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class LiteDeviceTimeout(LiteDeviceError): + def __init__(self, error_msg, error_no=""): + super(LiteDeviceTimeout, self).__init__( + error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class LiteParamError(LiteDeviceError): + def __init__(self, error_msg, error_no=""): + super(LiteParamError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class LiteDeviceExecuteCommandError(LiteDeviceError): + def __init__(self, error_msg, error_no=""): + super(LiteDeviceExecuteCommandError, self).__init__( + error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class LiteDeviceMountError(LiteDeviceError): + def __init__(self, error_msg, error_no=""): + super(LiteDeviceMountError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class LiteDeviceReadOutputError(LiteDeviceError): + def __init__(self, error_msg, error_no=""): + super(LiteDeviceReadOutputError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + diff --git a/xdevice/plugins/ohos/src/ohos/executor/__init__.py b/xdevice/plugins/ohos/src/ohos/executor/__init__.py new file mode 100644 index 0000000..160d3a7 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/executor/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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/xdevice/plugins/ohos/src/ohos/executor/listener.py b/xdevice/plugins/ohos/src/ohos/executor/listener.py new file mode 100644 index 0000000..7b47677 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/executor/listener.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from xdevice import Plugin +from xdevice import IListener +from xdevice import LifeCycle +from xdevice import platform_logger +from xdevice import ListenerType +from xdevice import TestDescription +from xdevice import ResultCode + +__all__ = ["CollectingLiteGTestListener", "CollectingPassListener"] + +LOG = platform_logger("Listener") + + +@Plugin(type=Plugin.LISTENER, id=ListenerType.collect_lite) +class CollectingLiteGTestListener(IListener): + """ + Listener test status information to the console + """ + + def __init__(self): + self.tests = [] + + def __started__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestCase: + if not test_result.test_class or not test_result.test_name: + return + test = TestDescription(test_result.test_class, + test_result.test_name) + if test not in self.tests: + self.tests.append(test) + + def __ended__(self, lifecycle, test_result=None, **kwargs): + pass + + def __skipped__(self, lifecycle, test_result): + pass + + def __failed__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestCase: + if not test_result.test_class or not test_result.test_name: + return + test = TestDescription(test_result.test_class, + test_result.test_name) + if test not in self.tests: + self.tests.append(test) + + def get_current_run_results(self): + return self.tests + + +@Plugin(type=Plugin.LISTENER, id=ListenerType.collect_pass) +class CollectingPassListener(IListener): + """ + listener test status information to the console + """ + + def __init__(self): + self.tests = [] + + def __started__(self, lifecycle, test_result): + pass + + def __ended__(self, lifecycle, test_result=None, **kwargs): + if lifecycle == LifeCycle.TestCase: + if not test_result.test_class or not test_result.test_name: + return + if test_result.code != ResultCode.PASSED.value: + return + test = TestDescription(test_result.test_class, + test_result.test_name) + if test not in self.tests: + self.tests.append(test) + else: + LOG.warning("Duplicate testcase: %s#%s" % ( + test_result.test_class, test_result.test_name)) + + def __skipped__(self, lifecycle, test_result): + pass + + def __failed__(self, lifecycle, test_result): + pass + + def get_current_run_results(self): + return self.tests \ No newline at end of file diff --git a/xdevice/plugins/ohos/src/ohos/managers/__init__.py b/xdevice/plugins/ohos/src/ohos/managers/__init__.py new file mode 100644 index 0000000..160d3a7 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/managers/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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/xdevice/plugins/ohos/src/ohos/managers/manager_device.py b/xdevice/plugins/ohos/src/ohos/managers/manager_device.py new file mode 100644 index 0000000..9996343 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/managers/manager_device.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import sys +import threading + +from xdevice import UserConfigManager +from xdevice import ManagerType +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import IDeviceManager +from xdevice import IFilter +from xdevice import platform_logger +from xdevice import ParamError +from xdevice import ConfigConst +from xdevice import HdcCommandRejectedException +from xdevice import DeviceConnectorType +from xdevice import DeviceEvent +from xdevice import TestDeviceState +from xdevice import DeviceState +from xdevice import handle_allocation_event +from xdevice import DeviceAllocationState +from xdevice import DeviceStateMonitor +from xdevice import convert_serial +from xdevice import DeviceNode +from xdevice import DeviceSelector + +from ohos.environment.dmlib import DeviceConnector +from ohos.environment.dmlib import HDC_NAME +from ohos.environment.dmlib import HDC_STD_NAME + +__all__ = ["ManagerDevice"] + +LOG = platform_logger("ManagerDevice") + + +@Plugin(type=Plugin.MANAGER, id=ManagerType.device) +class ManagerDevice(IDeviceManager, IFilter): + """ + Class representing device manager + managing the set of available devices for testing + """ + + def __init__(self): + self.devices_list = [] + self.global_device_filter = None + self.lock_con = threading.Condition() + self.list_con = threading.Condition() + self.device_connector = None + self.managed_device_listener = None + self.support_labels = ["phone", "watch", "car", "tv", "tablet", "ivi"] + self.support_types = ["device"] + self.wait_times = 0 + + def init_environment(self, environment="", user_config_file=""): + self._start_device_monitor(environment, user_config_file) + + def env_stop(self): + self._stop_device_monitor() + + def _start_device_monitor(self, environment="", user_config_file=""): + self.managed_device_listener = ManagedDeviceListener(self) + device = UserConfigManager( + config_file=user_config_file, env=environment).get_device( + "environment/device") + if device: + try: + self.device_connector = DeviceConnector(device.get("ip"), + device.get("port"), + device.get("usb_type")) + self.global_device_filter = UserConfigManager( + config_file=user_config_file, env=environment).get_sn_list( + device.get("sn")) + self.device_connector.add_device_change_listener( + self.managed_device_listener) + self.device_connector.start() + except (ParamError, FileNotFoundError) as error: + self.env_stop() + LOG.debug("Start %s error: %s" % ( + device.get("usb_type"), error)) + self.device_connector = DeviceConnector( + device.get("ip"), device.get("port"), + DeviceConnectorType.hdc) + self.device_connector.add_device_change_listener( + self.managed_device_listener) + self.device_connector.start() + else: + raise ParamError("Manager device is not supported, please " + "check config user_config.xml", error_no="00108") + + def _stop_device_monitor(self): + self.device_connector.remove_device_change_listener( + self.managed_device_listener) + self.device_connector.terminate() + + def find(self, idevice): + LOG.debug("Find: apply list con lock") + self.list_con.acquire() + try: + for device in self.devices_list: + if device.device_sn == idevice.device_sn and \ + device.device_os_type == idevice.device_os_type: + return device + finally: + LOG.debug("Find: release list con lock") + self.list_con.release() + + def apply_device(self, device_option, timeout=3): + + LOG.debug("Apply device: apply lock con lock") + self.lock_con.acquire() + try: + device = self.allocate_device_option(device_option) + if device: + return device + LOG.debug("Wait for available device founded") + self.wait_times += 3 + if self.wait_times > timeout: + self.lock_con.wait(timeout) + else: + self.lock_con.wait(self.wait_times) + LOG.debug("Wait for available device founded") + return self.allocate_device_option(device_option) + finally: + LOG.debug("Apply device: release lock con lock") + self.lock_con.release() + + def allocate_device_option(self, device_option): + """ + Request a device for testing that meets certain criteria. + """ + + LOG.debug("Allocate device option: apply list con lock") + if not self.list_con.acquire(timeout=5): + LOG.debug("Allocate device option: list con wait timeout") + return None + try: + allocated_device = None + LOG.debug("Require device label is: %s" % device_option.label) + for device in self.devices_list: + if device_option.matches(device): + self.handle_device_event(device, + DeviceEvent.ALLOCATE_REQUEST) + LOG.debug("Allocate device sn: %s, type: %s" % ( + device.__get_serial__(), device.__class__)) + return device + return allocated_device + + finally: + LOG.debug("Allocate device option: release list con lock") + self.list_con.release() + + def release_device(self, device): + LOG.debug("Release device: apply list con lock") + self.list_con.acquire() + try: + if device.test_device_state == TestDeviceState.ONLINE: + self.handle_device_event(device, DeviceEvent.FREE_AVAILABLE) + else: + self.handle_device_event(device, DeviceEvent.FREE_UNAVAILABLE) + + device.device_id = None + + LOG.debug("Free device sn: %s, type: %s" % ( + device.__get_serial__(), device.__class__.__name__)) + + finally: + LOG.debug("Release_device: release list con lock") + self.list_con.release() + + def reset_device(self, device): + if device and hasattr(device, "reset"): + device.reset() + + def find_device(self, device_sn, device_os_type): + for device in self.devices_list: + if device.device_sn == device_sn and \ + device.device_os_type == device_os_type: + return device + + def append_device_by_sort(self, device_instance): + if (not self.global_device_filter or + not self.devices_list or + device_instance.device_sn not in self.global_device_filter): + self.devices_list.append(device_instance) + else: + device_dict = dict(zip( + self.global_device_filter, + list(range(1, len(self.global_device_filter) + 1)))) + for index in range(len(self.devices_list)): + if self.devices_list[index].device_sn not in \ + self.global_device_filter: + self.devices_list.insert(index, device_instance) + break + if device_dict[device_instance.device_sn] < \ + device_dict[self.devices_list[index].device_sn]: + self.devices_list.insert(index, device_instance) + break + else: + self.devices_list.append(device_instance) + + def find_or_create(self, idevice): + LOG.debug("Find or create: apply list con lock") + self.list_con.acquire() + try: + device = self.find_device(idevice.device_sn, + idevice.device_os_type) + if device is None: + device = get_plugin( + plugin_type=Plugin.DEVICE, + plugin_id=idevice.device_os_type)[0] + device_instance = device.__class__() + device_instance.__set_serial__(idevice.device_sn) + device_instance.host = idevice.host + device_instance.port = idevice.port + device_instance.usb_type = self.device_connector.usb_type + LOG.debug("Create device(%s) host is %s, " + "port is %s, device sn is %s, usb type is %s" % + (device_instance, device_instance.host, + device_instance.port, device_instance.device_sn, + device_instance.usb_type)) + device_instance.device_state = idevice.device_state + device_instance.test_device_state = \ + TestDeviceState.get_test_device_state( + device_instance.device_state) + device_instance.device_state_monitor = \ + DeviceStateMonitor(device_instance) + if idevice.device_state == DeviceState.ONLINE or \ + idevice.device_state == DeviceState.CONNECTED: + device_instance.get_device_type() + self.append_device_by_sort(device_instance) + device = device_instance + else: + LOG.debug("Find device(%s), host is %s, " + "port is %s, device sn is %s, usb type is %s" % + (device, device.host, device.port, device.device_sn, + device.usb_type)) + return device + except HdcCommandRejectedException as hcr_error: + LOG.debug("%s occurs error. Reason:%s" % + (idevice.device_sn, hcr_error)) + finally: + LOG.debug("Find or create: release list con lock") + self.list_con.release() + + def remove(self, idevice): + LOG.debug("Remove: apply list con lock") + self.list_con.acquire() + try: + self.devices_list.remove(idevice) + finally: + LOG.debug("Remove: release list con lock") + self.list_con.release() + + def handle_device_event(self, device, event): + state_changed = None + old_state = device.device_allocation_state + new_state = handle_allocation_event(old_state, event) + + if new_state == DeviceAllocationState.checking_availability: + if self.global_device_filter and \ + device.device_sn not in self.global_device_filter: + event = DeviceEvent.AVAILABLE_CHECK_IGNORED + else: + event = DeviceEvent.AVAILABLE_CHECK_PASSED + new_state = handle_allocation_event(new_state, event) + + if old_state != new_state: + state_changed = True + device.device_allocation_state = new_state + + if state_changed is True and \ + new_state == DeviceAllocationState.available: + # notify_device_state_change + LOG.debug("Handle device event apply lock con") + self.lock_con.acquire() + LOG.debug("Find available device") + self.lock_con.notify_all() + LOG.debug("Handle device event release lock con") + self.lock_con.release() + + if device.device_allocation_state == \ + DeviceAllocationState.unknown: + self.remove(device) + return + + def launch_emulator(self): + pass + + def kill_emulator(self): + pass + + def list_devices(self): + self.device_connector.monitor_lock.acquire(1) + print("devices:") + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}".format( + "Serial", "OsType", "State", "Allocation", "Product", "host", + "port")) + for device in self.devices_list: + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}".format( + convert_serial(device.device_sn), device.device_os_type, + device.test_device_state.value, + device.device_allocation_state, + device.label if device.label else 'None', + device.host, device.port)) + self.device_connector.monitor_lock.release() + + def __filter_selector__(self, selector): + if isinstance(selector, DeviceSelector): + return True + return False + + def __filter_xml_node__(self, node): + if isinstance(node, DeviceNode): + if HDC_NAME in node.get_connectors() or\ + HDC_STD_NAME in node.get_connectors(): + return True + return False + + +class ManagedDeviceListener(object): + """ + A class to listen for and act on device presence updates from ddmlib + """ + + def __init__(self, manager): + self.manager = manager + + def device_changed(self, idevice): + test_device = self.manager.find_or_create(idevice) + if test_device is None: + return + new_state = TestDeviceState.get_test_device_state(idevice.device_state) + test_device.test_device_state = new_state + if new_state == TestDeviceState.ONLINE: + self.manager.handle_device_event(test_device, + DeviceEvent.STATE_CHANGE_ONLINE) + elif new_state == TestDeviceState.NOT_AVAILABLE: + self.manager.handle_device_event(test_device, + DeviceEvent.STATE_CHANGE_OFFLINE) + test_device.device_state_monitor.set_state( + test_device.test_device_state) + LOG.debug("Device changed to %s: %s %s %s %s" % ( + new_state, convert_serial(idevice.device_sn), + idevice.device_os_type, idevice.host, idevice.port)) + + def device_connected(self, idevice): + test_device = self.manager.find_or_create(idevice) + if test_device is None: + return + + new_state = TestDeviceState.get_test_device_state(idevice.device_state) + test_device.test_device_state = new_state + if test_device.test_device_state == TestDeviceState.ONLINE: + self.manager.handle_device_event(test_device, + DeviceEvent.CONNECTED_ONLINE) + elif new_state == TestDeviceState.NOT_AVAILABLE: + self.manager.handle_device_event(test_device, + DeviceEvent.CONNECTED_OFFLINE) + test_device.device_state_monitor.set_state( + test_device.test_device_state) + LOG.debug("Device connected: {} {} {} {}, state: {}".format( + convert_serial(idevice.device_sn), idevice.device_os_type, + idevice.host, idevice.port, test_device.test_device_state)) + LOG.debug("Set device %s %s to true" % ( + convert_serial(idevice.device_sn), ConfigConst.recover_state)) + test_device.set_recover_state(True) + + def device_disconnected(self, disconnected_device): + test_device = self.manager.find(disconnected_device) + if test_device is not None: + test_device.test_device_state = TestDeviceState.NOT_AVAILABLE + self.manager.handle_device_event(test_device, + DeviceEvent.DISCONNECTED) + test_device.device_state_monitor.set_state( + TestDeviceState.NOT_AVAILABLE) + LOG.debug("Device disconnected: %s %s %s %s" % ( + convert_serial(disconnected_device.device_sn), + disconnected_device.device_os_type, + disconnected_device.host, disconnected_device.port)) diff --git a/xdevice/plugins/ohos/src/ohos/managers/manager_lite.py b/xdevice/plugins/ohos/src/ohos/managers/manager_lite.py new file mode 100644 index 0000000..9af2ad8 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/managers/manager_lite.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import time +import threading + +from xdevice import UserConfigManager +from xdevice import DeviceOsType +from xdevice import ManagerType +from xdevice import DeviceAllocationState +from xdevice import Plugin +from xdevice import get_plugin +from xdevice import IDeviceManager +from xdevice import platform_logger +from xdevice import convert_ip +from xdevice import convert_port +from xdevice import convert_serial + +from ohos.exception import LiteDeviceError + +__all__ = ["ManagerLite"] + +LOG = platform_logger("ManagerLite") + + +@Plugin(type=Plugin.MANAGER, id=ManagerType.lite_device) +class ManagerLite(IDeviceManager): + """ + Class representing device manager that + managing the set of available devices for testing + """ + + def __init__(self): + self.devices_list = [] + self.list_con = threading.Condition() + self.support_labels = ["ipcamera", "wifiiot", "watchGT"] + self.support_types = ["device"] + + def init_environment(self, environment="", user_config_file=""): + device_lite = get_plugin(plugin_type=Plugin.DEVICE, + plugin_id=DeviceOsType.lite)[0] + + devices = UserConfigManager( + config_file=user_config_file, env=environment).get_com_device( + "environment/device") + + for device in devices: + try: + device_lite_instance = device_lite.__class__() + device_lite_instance.__init_device__(device) + device_lite_instance.device_allocation_state = \ + DeviceAllocationState.available + except LiteDeviceError as exception: + LOG.warning(exception) + continue + + self.devices_list.append(device_lite_instance) + + def env_stop(self): + pass + + def apply_device(self, device_option, timeout=10): + """ + Request a device for testing that meets certain criteria. + """ + del timeout + LOG.debug("Lite apply device: apply lock") + self.list_con.acquire() + try: + allocated_device = None + for device in self.devices_list: + if device_option.matches(device): + device.device_allocation_state = \ + DeviceAllocationState.allocated + LOG.debug("Allocate device sn: %s, type: %s" % ( + convert_serial(device.__get_serial__()), + device.__class__)) + return device + time.sleep(10) + return allocated_device + finally: + LOG.debug("Lite apply device: release lock") + self.list_con.release() + + def release_device(self, device): + LOG.debug("Lite release device: apply lock") + self.list_con.acquire() + try: + if device.device_allocation_state == \ + DeviceAllocationState.allocated: + device.device_allocation_state = \ + DeviceAllocationState.available + LOG.debug("Free device sn: %s, type: %s" % ( + device.__get_serial__(), device.__class__)) + finally: + LOG.debug("Lite release device: release lock") + self.list_con.release() + + def reset_device(self, device): + pass + + def list_devices(self): + print("Lite devices:") + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}". + format("SerialPort/IP", "Baudrate/Port", "OsType", "Allocation", + "Product", "ConnectType", "ComType")) + for device in self.devices_list: + if device.device_connect_type == "remote" or \ + device.device_connect_type == "agent": + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}".format( + convert_ip(device.device.host), + convert_port(device.device.port), + device.device_os_type, + device.device_allocation_state, + device.label, + device.device_connect_type)) + else: + for com_controller in device.device.com_dict: + print("{0:<20}{1:<16}{2:<16}{3:<16}{4:<16}{5:<16}{6:<16}". + format(convert_port(device.device.com_dict[ + com_controller].serial_port), + device.device.com_dict[ + com_controller].baud_rate, + device.device_os_type, + device.device_allocation_state, + device.label, + device.device_connect_type, + com_controller)) diff --git a/xdevice/plugins/ohos/src/ohos/parser/__init__.py b/xdevice/plugins/ohos/src/ohos/parser/__init__.py new file mode 100644 index 0000000..160d3a7 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/parser/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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/xdevice/plugins/ohos/src/ohos/parser/parser.py b/xdevice/plugins/ohos/src/ohos/parser/parser.py new file mode 100644 index 0000000..7ef74e9 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/parser/parser.py @@ -0,0 +1,1599 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import copy +import re +import threading +import time +import json +from enum import Enum + +from xdevice import LifeCycle +from xdevice import IParser +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import check_pub_key_exist +from xdevice import StateRecorder +from xdevice import TestDescription +from xdevice import ResultCode +from xdevice import CommonParserType +from xdevice import get_cst_time +from xdevice import get_delta_time_ms + +__all__ = ["CppTestParser", "CppTestListParser", "JunitParser", "JSUnitParser", + "OHKernelTestParser", "OHJSUnitTestParser", + "OHJSUnitTestListParser", "_ACE_LOG_MARKER", "OHRustTestParser"] + +_INFORMATIONAL_MARKER = "[----------]" +_START_TEST_RUN_MARKER = "[==========] Running" +_TEST_RUN_MARKER = "[==========]" +_GTEST_DRYRUN_MARKER = "Running main() " +_START_TEST_MARKER = "[ RUN ]" +_OK_TEST_MARKER = "[ OK ]" +_SKIPPED_TEST_MARKER = "[ SKIPPED ]" +_FAILED_TEST_MARKER = "[ FAILED ]" +_ALT_OK_MARKER = "[ OK ]" +_TIMEOUT_MARKER = "[ TIMEOUT ]" + +_START_JSUNIT_RUN_MARKER = "[start] start run suites" +_START_JSUNIT_SUITE_RUN_MARKER = "[suite start]" +_START_JSUNIT_SUITE_END_MARKER = "[suite end]" +_END_JSUNIT_RUN_MARKER = "[end] run suites end" +_PASS_JSUNIT_MARKER = "[pass]" +_FAIL_JSUNIT_MARKER = "[fail]" +_ERROR_JSUNIT_MARKER = "[error]" +_ACE_LOG_MARKER = "jsapp" + +""" +OpenHarmony Kernel Test +""" +RUNTEST_TEST = "runtest test" +START_TO_TEST = "Start to test" +FINISHED_TO_TEST = "Finished to test" +TIMEOUT_TESTCASES = "Timeout testcases" +FAIL_DOT = "FAIL." +PASS_DOT = "PASS." +ERROR_EXCLAMATION = "ERROR!!!" +TIMEOUT_EXCLAMATION = "TIMEOUT!" + + +LOG = platform_logger("Parser") + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.cpptest) +class CppTestParser(IParser): + def __init__(self): + self.state_machine = StateRecorder() + self.suite_name = "" + self.listeners = [] + self.product_info = {} + self.is_params = False + self.start_time = get_cst_time() + self.suite_start_time = get_cst_time() + + def get_suite_name(self): + return self.suite_name + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(lines) + for line in lines: + LOG.debug(line) + self.parse(line) + + def __done__(self): + suite_result = self.state_machine.get_suites() + if not suite_result.suites_name: + return + for listener in self.get_listeners(): + suites = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name, + product_info=suites.product_info) + self.state_machine.current_suites = None + + def parse(self, line): + + if self.state_machine.suites_is_started() or line.startswith( + _TEST_RUN_MARKER): + if line.startswith(_START_TEST_RUN_MARKER): + message = line[len(_TEST_RUN_MARKER):].strip() + self.handle_suites_started_tag(message) + elif line.startswith(_INFORMATIONAL_MARKER): + pattern = r"(.*) (\(\d+ ms total\))" + message = line[len(_INFORMATIONAL_MARKER):].strip() + if re.match(pattern, line.strip()): + self.handle_suite_ended_tag(message) + elif re.match(r'(\d+) test[s]? from (.*)', message): + self.handle_suite_started_tag(message) + elif line.startswith(_TEST_RUN_MARKER): + if not self.state_machine.suites_is_running(): + return + message = line[len(_TEST_RUN_MARKER):].strip() + self.handle_suites_ended_tag(message) + elif line.startswith(_START_TEST_MARKER): + # Individual test started + message = line[len(_START_TEST_MARKER):].strip() + self.handle_test_started_tag(message) + else: + self.process_test(line) + + def process_test(self, line): + if _SKIPPED_TEST_MARKER in line: + message = line[line.index(_SKIPPED_TEST_MARKER) + len( + _SKIPPED_TEST_MARKER):].strip() + if not self.state_machine.test_is_running(): + LOG.error( + "Found {} without {} before, wrong GTest log format". + format(line, _START_TEST_MARKER)) + return + self.handle_test_ended_tag(message, ResultCode.SKIPPED) + elif _OK_TEST_MARKER in line: + message = line[line.index(_OK_TEST_MARKER) + len( + _OK_TEST_MARKER):].strip() + if not self.state_machine.test_is_running(): + LOG.error( + "Found {} without {} before, wrong GTest log format". + format(line, _START_TEST_MARKER)) + return + self.handle_test_ended_tag(message, ResultCode.PASSED) + elif _ALT_OK_MARKER in line: + message = line[line.index(_ALT_OK_MARKER) + len( + _ALT_OK_MARKER):].strip() + self.fake_run_marker(message) + self.handle_test_ended_tag(message, ResultCode.PASSED) + elif _FAILED_TEST_MARKER in line: + message = line[line.index(_FAILED_TEST_MARKER) + len( + _FAILED_TEST_MARKER):].strip() + if not self.state_machine.suite_is_running(): + return + if not self.state_machine.test_is_running(): + self.fake_run_marker(message) + self.handle_test_ended_tag(message, ResultCode.FAILED) + elif _TIMEOUT_MARKER in line: + message = line[line.index(_TIMEOUT_MARKER) + len( + _TIMEOUT_MARKER):].strip() + self.fake_run_marker(message) + self.handle_test_ended_tag(message, ResultCode.FAILED) + elif self.state_machine.test_is_running(): + self.append_test_output(line) + + def handle_test_suite_failed(self, error_msg): + error_msg = "Unknown error" if error_msg is None else error_msg + LOG.info("Test run failed: {}".format(error_msg)) + if self.state_machine.test_is_running(): + self.state_machine.test().is_completed = True + for listener in self.get_listeners(): + test_result = copy.copy(self.currentTestResult) + listener.__failed__(LifeCycle.TestCase, test_result) + listener.__ended__(LifeCycle.TestCase, test_result) + self.state_machine.suite().stacktrace = error_msg + self.state_machine.suite().is_completed = True + for listener in self.get_listeners(): + suite_result = copy.copy(self.currentSuiteResult) + listener.__failed__(LifeCycle.TestSuite, suite_result) + listener.__ended__(LifeCycle.TestSuite, suite_result) + + def handle_test_started_tag(self, message): + test_class, test_name, _ = self.parse_test_description( + message) + test_result = self.state_machine.test(reset=True) + test_result.test_class = test_class + test_result.test_name = test_name + self.start_time = get_cst_time() + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + @classmethod + def parse_test_description(cls, message): + run_time = 0 + matcher = re.match(r'(.*) \((\d+) ms\)', message) + if matcher: + test_class, test_name = matcher.group(1).rsplit(".", 1) + run_time = int(matcher.group(2)) + else: + test_class, test_name = message.rsplit(".", 1) + return test_class, test_name, run_time + + def handle_test_ended_tag(self, message, test_status): + test_class, test_name, run_time = self.parse_test_description( + message) + test_result = self.state_machine.test() + test_result.run_time = get_delta_time_ms(self.start_time) + if test_result.run_time == 0 or test_result.run_time < run_time: + test_result.run_time = run_time + test_result.code = test_status.value + test_result.current = self.state_machine.running_test_index + 1 + if not test_result.is_running(): + LOG.error( + "Test has no start tag when trying to end test: %s", message) + return + found_unexpected_test = False + if test_result.test_class != test_class: + LOG.error( + "Expected class: {} but got:{} ".format(test_result.test_class, + test_class)) + found_unexpected_test = True + if test_result.test_name != test_name: + LOG.error( + "Expected test: {} but got: {}".format(test_result.test_name, + test_name)) + found_unexpected_test = True + + if found_unexpected_test or ResultCode.FAILED == test_status: + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + elif ResultCode.SKIPPED == test_status: + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__skipped__(LifeCycle.TestCase, result) + + self.state_machine.test().is_completed = True + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + + def fake_run_marker(self, message): + fake_marker = re.compile(" +").split(message) + self.handle_test_started_tag(fake_marker) + + def handle_suites_started_tag(self, message): + self.state_machine.get_suites(reset=True) + matcher = re.match(r'Running (\d+) test[s]? from .*', message) + expected_test_num = int(matcher.group(1)) if matcher else -1 + if expected_test_num >= 0: + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.get_suite_name() + test_suites.test_num = expected_test_num + test_suites.product_info = self.product_info + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suite_started_tag(self, message): + self.state_machine.suite(reset=True) + matcher = re.match(r'(\d+) test[s]? from (.*)', message) + expected_test_num = int(matcher.group(1)) if matcher else -1 + if expected_test_num >= 0: + test_suite = self.state_machine.suite() + test_suite.suite_name = matcher.group(2) + test_suite.test_num = expected_test_num + self.suite_start_time = get_cst_time() + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self, message): + self.state_machine.running_test_index = 0 + suite_result = self.state_machine.suite() + suite_result.run_time = get_delta_time_ms(self.suite_start_time) + matcher = re.match(r'.*\((\d+) ms total\)', message) + if matcher and suite_result.run_time == 0: + suite_result.run_time = int(matcher.group(1)) + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + + def handle_suites_ended_tag(self, message): + suites = self.state_machine.get_suites() + matcher = re.match(r'.*\((\d+) ms total\)', message) + if matcher: + suites.run_time = int(matcher.group(1)) + suites.is_completed = True + for listener in self.get_listeners(): + copy_suites = copy.copy(suites) + listener.__ended__(LifeCycle.TestSuites, test_result=copy_suites, + suites_name=suites.suites_name, + product_info=suites.product_info, + suite_report=True) + + def append_test_output(self, message): + if self.state_machine.test().stacktrace: + self.state_machine.test().stacktrace += "\r\n" + self.state_machine.test().stacktrace += message + + @staticmethod + def handle_test_run_failed(error_msg): + if not error_msg: + error_msg = "Unknown error" + if not check_pub_key_exist(): + LOG.debug("Error msg:%s" % error_msg) + + def mark_test_as_blocked(self, test): + if not self.state_machine.current_suite and not test.class_name: + return + suites_result = self.state_machine.get_suites(reset=True) + suites_result.suites_name = self.get_suite_name() + suite_name = self.state_machine.current_suite.suite_name if \ + self.state_machine.current_suite else None + suite_result = self.state_machine.suite(reset=True) + test_result = self.state_machine.test(reset=True) + suite_result.suite_name = suite_name or test.class_name + suite_result.suite_num = 1 + test_result.test_class = test.class_name + test_result.test_name = test.test_name + test_result.stacktrace = "error_msg: run crashed" + test_result.num_tests = 1 + test_result.run_time = 0 + test_result.code = ResultCode.BLOCKED.value + for listener in self.get_listeners(): + suite_report = copy.copy(suites_result) + listener.__started__(LifeCycle.TestSuites, suite_report) + for listener in self.get_listeners(): + suite_report = copy.copy(suite_result) + listener.__started__(LifeCycle.TestSuite, suite_report) + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, test_result) + for listener in self.get_listeners(): + suite_report = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite_report, + is_clear=True) + self.__done__() + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.cpptest_list) +class CppTestListParser(IParser): + def __init__(self): + self.last_test_class_name = None + self.tests = [] + self.result_data = "" + self.suites = dict() + + def __process__(self, lines): + for line in lines: + self.result_data = "{}{}\n".format(self.result_data, line) + self.parse(line) + + def __done__(self): + LOG.debug("CppTestListParser data:") + LOG.debug(self.result_data) + self.result_data = "" + + def parse(self, line): + class_matcher = re.match('^([a-zA-Z]+.*)\\.$', line) + method_matcher = re.match('\\s+([a-zA-Z_]+[\\S]*)(.*)?(\\s+.*)?$', + line) + if class_matcher: + self.last_test_class_name = class_matcher.group(1) + if self.last_test_class_name not in self.suites: + self.suites.setdefault(self.last_test_class_name, []) + elif method_matcher: + if not self.last_test_class_name: + LOG.error("Parsed new test case name %s but no test class name" + " has been set" % line) + else: + test_name = method_matcher.group(1) + if test_name not in self.suites.get(self.last_test_class_name, []): + test = TestDescription(self.last_test_class_name, + test_name) + self.tests.append(test) + self.suites.get(self.last_test_class_name, []).append(test_name) + else: + LOG.debug("[{}.{}] has already collect it, skip it.".format( + self.last_test_class_name, test_name)) + else: + if not check_pub_key_exist(): + LOG.debug("Line ignored: %s" % line) + + +class StatusCodes(Enum): + FAILURE = -2 + START = 1 + ERROR = -1 + SUCCESS = 0 + IN_PROGRESS = 2 + IGNORE = -3 + BLOCKED = 3 + + +class Prefixes(Enum): + STATUS = "INSTRUMENTATION_STATUS: " + STATUS_CODE = "INSTRUMENTATION_STATUS_CODE: " + STATUS_FAILED = "INSTRUMENTATION_FAILED: " + CODE = "INSTRUMENTATION_CODE: " + RESULT = "INSTRUMENTATION_RESULT: " + TIME_REPORT = "Time: " + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.junit) +class JunitParser(IParser): + def __init__(self): + self.state_machine = StateRecorder() + self.suite_name = "" + self.listeners = [] + self.current_key = None + self.current_value = None + self.start_time = get_cst_time() + self.test_time = 0 + self.test_run_finished = False + + def get_suite_name(self): + return self.suite_name + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + for line in lines: + if not check_pub_key_exist(): + LOG.debug(line) + self.parse(line) + + def __done__(self): + suite_result = self.state_machine.suite() + suite_result.run_time = self.test_time + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, + suite_report=True) + self.state_machine.current_suite = None + + def parse(self, line): + if line.startswith(Prefixes.STATUS_CODE.value): + self.submit_current_key_value() + self.parse_status_code(line) + elif line.startswith(Prefixes.STATUS.value): + self.submit_current_key_value() + self.parse_key(line, len(Prefixes.STATUS.value)) + elif line.startswith(Prefixes.RESULT.value): + self.test_run_finished = True + elif line.startswith(Prefixes.STATUS_FAILED.value) or \ + line.startswith(Prefixes.CODE.value): + self.submit_current_key_value() + self.test_run_finished = True + elif line.startswith(Prefixes.TIME_REPORT.value): + self.parse_time(line) + else: + if self.current_key == "stack" and self.current_value: + self.current_value = self.current_value + r"\r\n" + self.current_value = self.current_value + line + elif line: + pass + + def parse_key(self, line, key_start_pos): + key_value = line[key_start_pos:].split("=", 1) + if len(key_value) == 2: + self.current_key = key_value[0] + self.current_value = key_value[1] + + def parse_time(self, line): + message = line[len(Prefixes.TIME_REPORT.value):] + self.test_time = float(message.replace(",", "")) * 1000 + + @staticmethod + def check_legality(name): + if not name or name == "null": + return False + return True + + def parse_status_code(self, line): + value = line[len(Prefixes.STATUS_CODE.value):] + test_info = self.state_machine.test() + test_info.code = int(value) + if test_info.code != StatusCodes.IN_PROGRESS: + if self.check_legality(test_info.test_class) and \ + self.check_legality(test_info.test_name): + self.report_result(test_info) + self.clear_current_test_info() + + def clear_current_test_info(self): + self.state_machine.current_test = None + + def submit_current_key_value(self): + if self.current_key and self.current_value: + status_value = self.current_value + test_info = self.state_machine.test() + if self.current_key == "class": + test_info.test_class = status_value + elif self.current_key == "test": + test_info.test_name = status_value + elif self.current_key == "numtests": + test_info.num_tests = int(status_value) + elif self.current_key == "Error": + self.handle_test_run_failed(status_value) + elif self.current_key == "stack": + test_info.stacktrace = status_value + elif self.current_key == "stream": + pass + self.current_key = None + self.current_value = None + + def report_result(self, test_info): + if not test_info.test_name or not test_info.test_class: + LOG.info("Invalid instrumentation status bundle") + return + test_info.is_completed = True + self.report_test_run_started(test_info) + if test_info.code == StatusCodes.START.value: + self.start_time = get_cst_time() + for listener in self.get_listeners(): + result = copy.copy(test_info) + listener.__started__(LifeCycle.TestCase, result) + elif test_info.code == StatusCodes.FAILURE.value: + self.state_machine.running_test_index += 1 + test_info.current = self.state_machine.running_test_index + end_time = get_cst_time() + run_time = (end_time - self.start_time).total_seconds() + test_info.run_time = int(run_time * 1000) + for listener in self.get_listeners(): + result = copy.copy(test_info) + result.code = ResultCode.FAILED.value + listener.__ended__(LifeCycle.TestCase, result) + elif test_info.code == StatusCodes.ERROR.value: + self.state_machine.running_test_index += 1 + test_info.current = self.state_machine.running_test_index + end_time = get_cst_time() + run_time = (end_time - self.start_time).total_seconds() + test_info.run_time = int(run_time * 1000) + for listener in self.get_listeners(): + result = copy.copy(test_info) + result.code = ResultCode.FAILED.value + listener.__ended__(LifeCycle.TestCase, result) + elif test_info.code == StatusCodes.SUCCESS.value: + self.state_machine.running_test_index += 1 + test_info.current = self.state_machine.running_test_index + end_time = get_cst_time() + run_time = (end_time - self.start_time).total_seconds() + test_info.run_time = int(run_time * 1000) + for listener in self.get_listeners(): + result = copy.copy(test_info) + result.code = ResultCode.PASSED.value + listener.__ended__(LifeCycle.TestCase, result) + elif test_info.code == StatusCodes.IGNORE.value: + end_time = get_cst_time() + run_time = (end_time - self.start_time).total_seconds() + test_info.run_time = int(run_time * 1000) + for listener in self.get_listeners(): + result = copy.copy(test_info) + result.code = ResultCode.SKIPPED.value + listener.__skipped__(LifeCycle.TestCase, result) + elif test_info.code == StatusCodes.BLOCKED.value: + test_info.current = self.state_machine.running_test_index + end_time = get_cst_time() + run_time = (end_time - self.start_time).total_seconds() + test_info.run_time = int(run_time * 1000) + for listener in self.get_listeners(): + result = copy.copy(test_info) + result.code = ResultCode.BLOCKED.value + listener.__ended__(LifeCycle.TestCase, result) + + self.output_stack_trace(test_info) + + @classmethod + def output_stack_trace(cls, test_info): + if check_pub_key_exist(): + return + if test_info.stacktrace: + stack_lines = test_info.stacktrace.split(r"\r\n") + LOG.error("Stacktrace information is:") + for line in stack_lines: + line.strip() + if line: + LOG.error(line) + + def report_test_run_started(self, test_result): + test_suite = self.state_machine.suite() + if not self.state_machine.suite().is_started: + if not test_suite.test_num or not test_suite.suite_name: + test_suite.suite_name = self.get_suite_name() + test_suite.test_num = test_result.num_tests + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + @staticmethod + def handle_test_run_failed(error_msg): + if not error_msg: + error_msg = "Unknown error" + if not check_pub_key_exist(): + LOG.debug("Error msg:%s" % error_msg) + + def mark_test_as_failed(self, test): + test_info = self.state_machine.test() + if test_info: + test_info.test_class = test.class_name + test_info.test_name = test.test_name + test_info.code = StatusCodes.START.value + self.report_result(test_info) + test_info.code = StatusCodes.FAILURE.value + self.report_result(test_info) + self.__done__() + + def mark_test_as_blocked(self, test): + test_info = self.state_machine.test() + if test_info: + test_info.test_class = test.class_name + test_info.test_name = test.test_name + test_info.num_tests = 1 + test_info.run_time = 0 + test_info.code = StatusCodes.START.value + self.report_result(test_info) + test_info.code = StatusCodes.BLOCKED.value + self.report_result(test_info) + self.__done__() + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.jsunit) +class JSUnitParser(IParser): + last_line = "" + pattern = r"(\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}:\d{1,2}\.\d{3}) " + + def __init__(self): + self.state_machine = StateRecorder() + self.suites_name = "" + self.listeners = [] + self.expect_tests_dict = dict() + self.marked_suite_set = set() + self.exclude_list = list() + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(lines) + for line in lines: + self.parse(line) + + def __done__(self): + pass + + def parse(self, line): + if (self.state_machine.suites_is_started() or line.find( + _START_JSUNIT_RUN_MARKER) != -1) and \ + line.lower().find(_ACE_LOG_MARKER) != -1: + if line.find(_START_JSUNIT_RUN_MARKER) != -1: + self.handle_suites_started_tag() + elif line.endswith(_END_JSUNIT_RUN_MARKER): + self.handle_suites_ended_tag() + elif line.find(_START_JSUNIT_SUITE_RUN_MARKER) != -1: + self.handle_suite_started_tag(line.strip()) + elif line.endswith(_START_JSUNIT_SUITE_END_MARKER): + self.handle_suite_ended_tag() + elif _PASS_JSUNIT_MARKER in line or _FAIL_JSUNIT_MARKER \ + in line or _ERROR_JSUNIT_MARKER in line: + self.handle_one_test_tag(line.strip()) + self.last_line = line + + def parse_test_description(self, message): + pattern = r".*\[(pass|fail|error)\]" + year = time.strftime("%Y") + match_list = ["app Log:", "JSApp:", "JsApp:", "JSAPP:"] + filter_message = "" + for keyword in match_list: + if keyword in message: + filter_message = \ + message.split(r"{0}".format(keyword))[1].strip() + break + end_time = "%s-%s" % \ + (year, re.match(self.pattern, message).group().strip()) + start_time = "%s-%s" % \ + (year, re.match(self.pattern, + self.last_line.strip()).group().strip()) + start_timestamp = int(time.mktime( + time.strptime(start_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + start_time.split(".")[-1]) + end_timestamp = int(time.mktime( + time.strptime(end_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + end_time.split(".")[-1]) + run_time = end_timestamp - start_timestamp + match = re.match(pattern, filter_message) + _, status_end_index = match.span() + if " ;" in filter_message: + test_name = filter_message[status_end_index: + str(filter_message).find(" ;")] + else: + test_name = filter_message[status_end_index:] + status_dict = {"pass": ResultCode.PASSED, "fail": ResultCode.FAILED, + "ignore": ResultCode.SKIPPED, + "error": ResultCode.FAILED} + status = status_dict.get(match.group(1)) + return test_name.strip(), status, run_time + + def handle_suites_started_tag(self): + self.state_machine.get_suites(reset=True) + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.suites_name + test_suites.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suites_ended_tag(self): + self._mark_all_test_case() + suites = self.state_machine.get_suites() + suites.is_completed = True + + for listener in self.get_listeners(): + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name) + + def handle_one_test_tag(self, message): + test_name, status, run_time = \ + self.parse_test_description(message) + test_suite = self.state_machine.suite() + if self.exclude_list: + qualified_name = "{}#{}".format(test_suite.suite_name, test_name) + if qualified_name in self.exclude_list: + LOG.debug("{} will be discard!".format(qualified_name)) + test_suite.test_num -= 1 + return + test_result = self.state_machine.test(reset=True) + test_result.test_class = test_suite.suite_name + test_result.test_name = test_name + test_result.run_time = run_time + test_result.code = status.value + test_result.current = self.state_machine.running_test_index + 1 + self.state_machine.suite().run_time += run_time + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + test_suites = self.state_machine.get_suites() + found_unexpected_test = False + + if found_unexpected_test or ResultCode.FAILED == status: + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + elif ResultCode.SKIPPED == status: + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__skipped__(LifeCycle.TestCase, result) + + self.state_machine.test().is_completed = True + if not hasattr(test_suite, "total_cases"): + test_suite.test_num += 1 + test_suites.test_num += 1 + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + + def fake_run_marker(self, message): + fake_marker = re.compile(" +").split(message) + self.processTestStartedTag(fake_marker) + + def handle_suite_started_tag(self, message): + self.state_machine.suite(reset=True) + self.state_machine.running_test_index = 0 + test_suite = self.state_machine.suite() + if "total cases:" in message: + m_result = re.match(r".*\[suite start](.+), total cases: (\d+)", + message) + if m_result: + expect_test_num = m_result.group(2) + test_suite.suite_name = m_result.group(1) + test_suite.test_num = int(expect_test_num) + setattr(test_suite, "total_cases", True) + + else: + if re.match(r".*\[suite start].*", message): + _, index = re.match(r".*\[suite start]", message).span() + if message[index:]: + test_suite.suite_name = message[index:] + else: + test_suite.suite_name = self.suite_name + test_suite.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self): + suite_result = self.state_machine.suite() + suites = self.state_machine.get_suites() + suite_result.run_time = suite_result.run_time + suites.run_time += suite_result.run_time + suite_result.is_completed = True + self._mark_test_case(suite_result, self.get_listeners()) + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + + def append_test_output(self, message): + if self.state_machine.test().stacktrace: + self.state_machine.test().stacktrace = \ + "%s\r\n" % self.state_machine.test().stacktrace + self.state_machine.test().stacktrace = \ + ''.join((self.state_machine.test().stacktrace, message)) + + def _mark_test_case(self, suite, listeners): + if not self.expect_tests_dict: + return + tests_list = [] + for listener in listeners: + if listener.__class__.__name__ == "ReportListener": + tests_list.extend(listener.tests.values()) + break + test_name_list = [] + for item_test in tests_list: + test_name_list.append(item_test.test_name) + self.marked_suite_set.add(suite.suite_name) + test_in_cur = self.expect_tests_dict.get(suite.suite_name, []) + for test in test_in_cur: + if "{}#{}".format(suite.suite_name, test.test_name) \ + in self.exclude_list: + suite.test_num -= 1 + continue + if test.test_name not in test_name_list: + self._mock_test_case_life_cycle(listeners, test) + + def _mock_test_case_life_cycle(self, listeners, test): + test_result = self.state_machine.test(reset=True) + test_result.test_class = test.class_name + test_result.test_name = test.test_name + test_result.stacktrace = "error_msg: mark blocked" + test_result.num_tests = 1 + test_result.run_time = 0 + test_result.current = self.state_machine.running_test_index + 1 + test_result.code = ResultCode.BLOCKED.value + test_result = copy.copy(test_result) + for listener in listeners: + listener.__started__(LifeCycle.TestCase, test_result) + test_result = copy.copy(test_result) + for listener in listeners: + listener.__ended__(LifeCycle.TestCase, test_result) + self.state_machine.running_test_index += 1 + + def _mark_all_test_case(self): + if not self.expect_tests_dict: + return + all_suite_set = set(self.expect_tests_dict.keys()) + un_suite_set = all_suite_set.difference(self.marked_suite_set) + for un_suite_name in un_suite_set: + test_list = self.expect_tests_dict.get(un_suite_name, []) + + self.state_machine.suite(reset=True) + self.state_machine.running_test_index = 0 + test_suite = self.state_machine.suite() + test_suite.suite_name = un_suite_name + test_suite.test_num = len(test_list) + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + for test in test_list: + if "{}#{}".format(test_suite.suite_name, test.test_name) \ + in self.exclude_list: + test_suite.test_num -= 1 + continue + self._mock_test_case_life_cycle(self.get_listeners(), test) + + test_suite.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(test_suite) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.oh_kernel_test) +class OHKernelTestParser(IParser): + + def __init__(self): + self.state_machine = StateRecorder() + self.suites_name = "" + self.listeners = [] + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(lines) + for line in lines: + self.parse(line) + + def __done__(self): + pass + + def parse(self, line): + line = re.sub('\x1b.*?m', '', line) + if self.state_machine.suites_is_started() or RUNTEST_TEST in line: + if RUNTEST_TEST in line: + self.handle_suites_started_tag(line) + elif START_TO_TEST in line: + self.handle_suite_start_tag(line) + elif FINISHED_TO_TEST in line: + self.handle_suite_end_tag(line) + elif line.endswith(PASS_DOT) or line.endswith(FAIL_DOT): + self.handle_one_test_case_tag(line) + elif line.endswith(ERROR_EXCLAMATION) \ + or line.endswith(TIMEOUT_EXCLAMATION): + self.handle_test_case_error(line) + elif TIMEOUT_TESTCASES in line: + self.handle_suites_ended_tag(line) + + def handle_suites_started_tag(self, line): + self.state_machine.get_suites(reset=True) + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.suites_name + test_suites.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suites_ended_tag(self, line): + suites = self.state_machine.get_suites() + suites.is_completed = True + + for listener in self.get_listeners(): + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name) + + def handle_suite_start_tag(self, line): + pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}" \ + " Start to test (.+)$" + matcher = re.match(pattern, line) + if matcher and matcher.group(1): + self.state_machine.suite(reset=True) + test_suite = self.state_machine.suite() + test_suite.suite_name = matcher.group(1) + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_end_tag(self, line): + pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}" \ + " Finished to test (.+)$" + matcher = re.match(pattern, line) + if matcher and matcher.group(1): + suite_result = self.state_machine.suite() + suites = self.state_machine.get_suites() + suite_result.run_time = suite_result.run_time + suites.run_time += suite_result.run_time + suite_result.is_completed = True + + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + + def handle_one_test_case_tag(self, line): + pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} (.+) " \ + "(PASS)\\.$" + matcher = re.match(pattern, line) + if not (matcher and matcher.group(1) and matcher.group(2)): + return + test_result = self.state_machine.test(reset=True) + test_suite = self.state_machine.suite() + test_result.test_class = test_suite.suite_name + test_result.test_name = matcher.group(1) + test_result.current = self.state_machine.running_test_index + 1 + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + test_suites = self.state_machine.get_suites() + if PASS_DOT in line: + test_result.code = ResultCode.PASSED.value + elif FAIL_DOT in line: + test_result.code = ResultCode.FAILED.value + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + self.state_machine.test().is_completed = True + test_suite.test_num += 1 + test_suites.test_num += 1 + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + + def handle_test_case_error(self, line): + pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} (.+) " \ + "(ERROR!!!|TIMEOUT!)$" + matcher = re.match(pattern, line) + if not (matcher and matcher.group(1) and matcher.group(2)): + return + test_result = self.state_machine.test(reset=True) + test_suite = self.state_machine.suite() + test_result.test_class = test_suite.suite_name + test_result.test_name = matcher.group(1) + test_result.current = self.state_machine.running_test_index + 1 + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + test_suites = self.state_machine.get_suites() + if ERROR_EXCLAMATION in line: + test_result.code = ResultCode.FAILED.value + elif TIMEOUT_EXCLAMATION in line: + test_result.code = ResultCode.BLOCKED.value + + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + self.state_machine.test().is_completed = True + test_suite.test_num += 1 + test_suites.test_num += 1 + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + + +class OHJSUnitPrefixes(Enum): + SUM = "OHOS_REPORT_SUM: " + STATUS = "OHOS_REPORT_STATUS: " + STATUS_CODE = "OHOS_REPORT_STATUS_CODE: " + RESULT = "OHOS_REPORT_RESULT: " + CODE = "OHOS_REPORT_CODE: " + TEST_FINISHED_RESULT_MSG = "TestFinished-ResultMsg: " + + +class OHJSUnitItemConstants(Enum): + CLASS = "class" + TEST = "test" + NUM_TESTS = "numtests" + STACK = "stack" + SUITE_CONSUMING = "suiteconsuming" + CONSUMING = "consuming" + APP_DIED = "App died" + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.oh_jsunit) +class OHJSUnitTestParser(IParser): + + def __init__(self): + self.state_machine = StateRecorder() + self.suites_name = "" + self.listeners = [] + self.current_key = None + self.current_value = None + self.start_time = get_cst_time() + self.suite_start_time = get_cst_time() + self.test_time = 0 + self.test_run_finished = False + self.cur_sum = -1 + self.runner = None + + def get_suite_name(self): + return self.suites_name + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + for line in lines: + LOG.debug(line) + self.parse(line) + + def parse(self, line): + if not str(line).strip(): + return + if line.startswith(OHJSUnitPrefixes.SUM.value): + self.handle_sum_line(line) + elif line.startswith(OHJSUnitPrefixes.STATUS.value): + self.handle_status_line(line) + elif line.startswith(OHJSUnitPrefixes.STATUS_CODE.value): + self.submit_current_key_value() + self.parse_status_code(line) + elif line.startswith(OHJSUnitPrefixes.TEST_FINISHED_RESULT_MSG.value): + self._handle_result_msg(line) + + def handle_sum_line(self, line): + value = line[len(OHJSUnitPrefixes.SUM.value):].split("=", 1)[0] + self.cur_sum = int(value) + + def handle_status_line(self, line): + self.parse_key(line, len(OHJSUnitPrefixes.STATUS.value)) + if self.cur_sum > 0 and \ + self.current_key == OHJSUnitItemConstants.CLASS.value: + if self.current_value not in self.runner.suite_recorder.keys(): + current_suite = self.state_machine.suite(reset=True) + current_suite.test_num = self.cur_sum + current_suite.suite_name = self.current_value + self.runner.suite_recorder.update({ + self.current_value: + [len(self.runner.suite_recorder.keys()), + current_suite]}) + else: + current_suite = self.runner.suite_recorder.get( + self.current_value)[1] + self.state_machine.current_suite = current_suite + self.cur_sum = -1 + self.current_key = None + self.current_value = None + self.state_machine.running_test_index = 0 + self.suite_start_time = get_cst_time() + for listener in self.get_listeners(): + suite = copy.copy(current_suite) + listener.__started__(LifeCycle.TestSuite, suite) + + else: + if self.current_key == OHJSUnitItemConstants.SUITE_CONSUMING.value: + self.test_time = int(self.current_value) + self.handle_suite_end() + elif self.current_key == OHJSUnitItemConstants.CONSUMING.value: + self.test_time = int(self.current_value) + self.handle_case_end() + else: + self.submit_current_key_value() + self.parse_key(line, len(OHJSUnitPrefixes.STATUS.value)) + + def submit_current_key_value(self): + if self.current_key and self.current_value: + status_value = self.current_value + test_info = self.state_machine.test() + if self.current_key == OHJSUnitItemConstants.CLASS.value: + test_info.test_class = status_value + elif self.current_key == OHJSUnitItemConstants.TEST.value: + test_info.test_name = status_value + elif self.current_key == OHJSUnitItemConstants.NUM_TESTS.value: + test_info.num_tests = int(status_value) + elif self.current_key == OHJSUnitItemConstants.STACK.value: + test_info.stacktrace = status_value + self.current_key = None + self.current_value = None + + def parse_key(self, line, key_start_pos): + key_value = line[key_start_pos:].split("=", 1) + if len(key_value) == 2: + self.current_key = key_value[0] + self.current_value = key_value[1] + + def parse_status_code(self, line): + value = line[len(OHJSUnitPrefixes.STATUS_CODE.value):] + test_info = self.state_machine.test() + test_info.code = int(value) + if test_info.code != StatusCodes.IN_PROGRESS: + if self.check_legality(test_info.test_class) and \ + self.check_legality(test_info.test_name): + self.report_result(test_info) + + def clear_current_test_info(self): + self.state_machine.current_test = None + + def report_result(self, test_info): + if not test_info.test_name or not test_info.test_class: + LOG.info("Invalid instrumentation status bundle") + return + if test_info.code == StatusCodes.START.value: + self.start_time = get_cst_time() + for listener in self.get_listeners(): + result = copy.copy(test_info) + listener.__started__(LifeCycle.TestCase, result) + return + if test_info.code == StatusCodes.FAILURE.value: + self.state_machine.running_test_index += 1 + test_info.current = self.state_machine.running_test_index + test_info.code = ResultCode.FAILED.value + test_info.run_time = get_delta_time_ms(self.start_time) + elif test_info.code == StatusCodes.ERROR.value: + self.state_machine.running_test_index += 1 + test_info.current = self.state_machine.running_test_index + test_info.code = ResultCode.FAILED.value + test_info.run_time = get_delta_time_ms(self.start_time) + elif test_info.code == StatusCodes.SUCCESS.value: + self.state_machine.running_test_index += 1 + test_info.current = self.state_machine.running_test_index + test_info.code = ResultCode.PASSED.value + test_info.run_time = get_delta_time_ms(self.start_time) + + @classmethod + def output_stack_trace(cls, test_info): + if check_pub_key_exist(): + return + if test_info.stacktrace: + stack_lines = test_info.stacktrace.split(r"\r\n") + LOG.error("Stacktrace information is:") + for line in stack_lines: + line.strip() + if line: + LOG.error(line) + + @staticmethod + def check_legality(name): + if not name or name == "null": + return False + return True + + def __done__(self): + pass + + def handle_case_end(self): + test_info = self.state_machine.test() + if test_info.run_time == 0 or test_info.run_time < self.test_time: + test_info.run_time = self.test_time + for listener in self.get_listeners(): + result = copy.copy(test_info) + result.code = test_info.code + listener.__ended__(LifeCycle.TestCase, result) + if listener.__class__.__name__ == "ReportListener" \ + and self.runner.retry_times > 1: + index = list(listener.tests.keys())[-1] + listener.tests.pop(index) + test_info.is_completed = True + self.clear_current_test_info() + + def handle_suite_end(self): + suite_result = self.state_machine.suite() + suite_result.run_time = get_delta_time_ms(self.suite_start_time) + if suite_result.run_time == 0: + suite_result.run_time = self.test_time + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + + def handle_suites_end(self): + suite_result = self.state_machine.suite() + if not suite_result.is_completed: + self.handle_suite_end() + for listener in self.get_listeners(): + if listener.__class__.__name__ == "ReportListener": + self._cal_result(listener) + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuites, suite, + suites_name=self.suites_name) + self.state_machine.current_suite = None + + def _cal_result(self, report_listener): + result_len = len(report_listener.result) + suites_len = len(report_listener.suites) + if result_len > suites_len: + diff_result_tuple_list = report_listener.result[suites_len:] + report_listener.result = report_listener.result[:suites_len] + for diff_result_tuple in diff_result_tuple_list: + suite, case_result_list = diff_result_tuple + pos = self.runner.suite_recorder.get(suite.suite_name)[0] + report_listener.result[pos][1].extend(case_result_list) + self._handle_lacking_one_testcase(report_listener) + self._handle_lacking_whole_suite(report_listener) + + def _handle_lacking_one_testcase(self, report_listener): + for suite in report_listener.suites.values(): + test_des_list = self.runner.expect_tests_dict.get( + suite.suite_name, []) + pos = self.runner.suite_recorder.get(suite.suite_name)[0] + if len(test_des_list) == len(report_listener.result[pos][1]): + continue + interval = len(test_des_list) - len(report_listener.result[pos][1]) + if len(test_des_list) > 0: + LOG.info("{} tests in {} had missed.".format( + interval, suite.suite_name)) + else: + LOG.info("The count of tests in '{}' is incorrect! {} test " + "form dry run and {} tests have run." + "".format(suite.suite_name, len(test_des_list), + len(report_listener.result[pos][1]))) + for test_des in test_des_list: + is_contain = False + for case in report_listener.result[pos][1]: + if case.test_name == test_des.test_name: + is_contain = True + break + if not is_contain: + test_result = self.state_machine.test(reset=True) + test_result.test_class = test_des.class_name + test_result.test_name = test_des.test_name + test_result.stacktrace = "error_msg:mark blocked" + test_result.num_tests = 1 + test_result.run_time = 0 + test_result.current = \ + self.state_machine.running_test_index + 1 + test_result.code = ResultCode.BLOCKED.value + report_listener.result[pos][1].append(test_result) + LOG.debug("Add {}#{}".format(test_des.class_name, + test_des.test_name)) + + def _handle_lacking_whole_suite(self, report_listener): + all_suite_set = set(self.runner.expect_tests_dict.keys()) + un_suite_set = set() + if len(all_suite_set) > len(report_listener.suites): + suite_name_set = set() + for suite in report_listener.suites.values(): + suite_name_set.add(suite.suite_name) + un_suite_set = all_suite_set.difference(suite_name_set) + if un_suite_set: + LOG.info("{} suites have missed.".format(len(un_suite_set))) + for name in un_suite_set: + self.state_machine.running_test_index = 0 + test_des_list = self.runner.expect_tests_dict.get( + name, []) + current_suite = self.state_machine.suite(reset=True) + current_suite.test_num = len(test_des_list) + current_suite.suite_name = name + for listener in self.get_listeners(): + suite = copy.copy(current_suite) + listener.__started__(LifeCycle.TestSuite, suite) + + for test in test_des_list: + test_result = self.state_machine.test(reset=True) + test_result.test_class = test.class_name + test_result.test_name = test.test_name + test_result.stacktrace = "error_msg:mark blocked" + test_result.num_tests = 1 + test_result.run_time = 0 + test_result.current = self.state_machine.running_test_index + 1 + test_result.code = ResultCode.BLOCKED.value + test_result = copy.copy(test_result) + for listener in self.get_listeners(): + listener.__started__(LifeCycle.TestCase, test_result) + test_result = copy.copy(test_result) + for listener in self.get_listeners(): + listener.__ended__(LifeCycle.TestCase, test_result) + self.state_machine.running_test_index += 1 + current_suite.run_time = self.test_time + current_suite.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(current_suite) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + + def notify_task_finished(self): + self.handle_suites_end() + + def _handle_result_msg(self, line): + if OHJSUnitItemConstants.APP_DIED.value in line: + test_result = self.state_machine.test() + suite = self.state_machine.suite() + if not test_result.is_completed: + if self.check_legality(test_result.test_class) and \ + self.check_legality(test_result.test_name): + self.report_result(test_result) + self.clear_current_test_info() + if not suite.is_completed: + self.handle_suite_end() + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.oh_jsunit_list) +class OHJSUnitTestListParser(IParser): + + def __init__(self): + self.tests = [] + self.json_str = "" + self.tests_dict = dict() + self.result_data = "" + + def __process__(self, lines): + for line in lines: + self.result_data = "{}{}".format(self.result_data, line) + self.parse(line) + + def __done__(self): + LOG.debug("OHJSUnitTestListParser data:") + LOG.debug(self.result_data) + self.result_data = "" + + def parse(self, line): + if "{" in line or "}" in line: + self.json_str = "%s%s" % (self.json_str, line) + return + if "dry run finished" in line: + suite_dict_list = json.loads(self.json_str).get("suites", []) + for suite_dict in suite_dict_list: + for class_name, test_name_dict_list in suite_dict.items(): + self.tests_dict.update({class_name.strip(): []}) + for test_name_dict in test_name_dict_list: + for test_name in test_name_dict.values(): + test = TestDescription(class_name.strip(), + test_name.strip()) + self.tests_dict.get( + class_name.strip()).append(test) + self.tests.append(test) + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.oh_rust) +class OHRustTestParser(IParser): + + def __init__(self): + self.test_pattern = "test (?:tests::)?(.+) ... (ok|FAILED)" + self.stout_pattern = "---- tests::(.+) stdout ----" + self.running_pattern = "running (\\d+) test|tests" + self.test_result_pattern = "test result: (ok|FAILED)\\..+finished in (.+)s" + self.suite_name = "" + self.result_list = list() + self.stdout_list = list() + self.failures_stdout = list() + self.cur_fail_case = "" + self.state_machine = StateRecorder() + self.listeners = [] + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + for line in lines: + LOG.debug(line) + self.parse(line) + + def __done__(self): + self.handle_suite_end() + + def parse(self, line): + if line.startswith("running"): + matcher = re.match(self.running_pattern, line) + if not (matcher and matcher.group(1)): + return + self.handle_suite_start(matcher) + elif line.startswith("test result:"): + matcher = re.match(self.test_result_pattern, line) + if not (matcher and matcher.group(2)): + return + self.handle_case_lifecycle(matcher) + + elif "..." in line: + matcher = re.match(self.test_pattern, line) + if not (matcher and matcher.group(1) and matcher.group(2)): + return + self.collect_case(matcher) + elif line.startswith("---- tests::"): + matcher = re.match(self.stout_pattern, line) + if not (matcher and matcher.group(1)): + return + self.cur_fail_case = matcher.group(1) + else: + if self.cur_fail_case: + self.handle_stdout(line) + + def handle_case_lifecycle(self, matcher): + cost_time = matcher.group(2) + for test_result in self.result_list: + if test_result.code == ResultCode.FAILED.value: + if self.stdout_list and \ + self.stdout_list[0][0] == test_result.test_name: + test_result.stacktrace = self.stdout_list[0][1] + self.stdout_list.pop(0) + test_result.current = self.state_machine.running_test_index + 1 + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + test_suite = self.state_machine.suite() + test_suite.run_time = float(cost_time) * 1000 + + def handle_stdout(self, line): + if line.strip(): + self.failures_stdout.append(line.strip()) + else: + self.stdout_list.append((self.cur_fail_case, + " ".join(self.failures_stdout))) + self.cur_fail_case = "" + self.failures_stdout.clear() + + def collect_case(self, matcher): + test_result = self.state_machine.test(reset=True) + test_result.test_class = self.suite_name + test_result.test_name = matcher.group(1) + test_result.code = ResultCode.PASSED.value if \ + matcher.group(2) == "ok" else ResultCode.FAILED.value + self.result_list.append(test_result) + + def handle_suite_start(self, matcher): + self.state_machine.suite(reset=True) + test_suite = self.state_machine.suite() + test_suite.suite_name = self.suite_name + test_suite.test_num = int(matcher.group(1)) + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_end(self): + suite_result = self.state_machine.suite() + suite_result.run_time += suite_result.run_time + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, suite_report=True) + + +@Plugin(type=Plugin.PARSER, id=CommonParserType.oh_yara) +class OHYaraTestParser(IParser): + last_line = "" + pattern = r"(\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}:\d{1,2}\.\d{3}) " + + def __init__(self): + self.state_machine = StateRecorder() + self.suites_name = "" + self.vul_items = None + self.listeners = [] + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + self.parse(lines) + + def __done__(self): + pass + + def parse(self, lines): + for line in lines: + if line: + self.handle_suites_started_tag() + self.handle_suite_started_tag() + self.handle_one_test_tag(line) + self.handle_suite_ended_tag() + self.handle_suites_ended_tag() + + def handle_suites_started_tag(self): + self.state_machine.get_suites(reset=True) + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.suites_name + test_suites.test_num = len(self.vul_items) + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suites_ended_tag(self): + suites = self.state_machine.get_suites() + suites.is_completed = True + for listener in self.get_listeners(): + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name) + + def handle_one_test_tag(self, message): + status_dict = {"pass": ResultCode.PASSED, "fail": ResultCode.FAILED, + "block": ResultCode.BLOCKED} + message = message.strip().split("|") + test_name = message[0] + status = status_dict.get(message[3]) + trace = message[6] if message[3] else "" + run_time = 0 + test_suite = self.state_machine.suite() + test_result = self.state_machine.test(reset=True) + test_result.test_class = test_suite.suite_name + test_result.test_name = test_name + test_result.run_time = run_time + test_result.code = status.value + test_result.stacktrace = trace + test_result.current = self.state_machine.running_test_index + 1 + self.state_machine.suite().run_time += run_time + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + test_suites = self.state_machine.get_suites() + self.state_machine.test().is_completed = True + test_suites.test_num += 1 + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + + def handle_suite_started_tag(self): + self.state_machine.suite(reset=True) + self.state_machine.running_test_index = 0 + test_suite = self.state_machine.suite() + test_suite.suite_name = self.suites_name + test_suite.test_num = 1 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self): + suite_result = self.state_machine.suite() + suites = self.state_machine.get_suites() + suite_result.run_time = suite_result.run_time + suites.run_time += suite_result.run_time + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) \ No newline at end of file diff --git a/xdevice/plugins/ohos/src/ohos/parser/parser_lite.py b/xdevice/plugins/ohos/src/ohos/parser/parser_lite.py new file mode 100644 index 0000000..c1e68a2 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/parser/parser_lite.py @@ -0,0 +1,1092 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import copy +import re +import time +from queue import Queue + +from xdevice import IParser +from xdevice import Plugin +from xdevice import StateRecorder +from xdevice import TestDescription +from xdevice import LifeCycle +from xdevice import ResultCode +from xdevice import platform_logger +from xdevice import check_pub_key_exist +from xdevice import get_cst_time + +from ohos.constants import ParserType + +__all__ = ["CppTestListParserLite", "CTestParser", "OpenSourceParser", + "CppTestParserLite"] + +_INFORMATIONAL_START = "[----------]" +_TEST_START_RUN_TAG = "[==========] Running" +_TEST_RUN_TAG = "[==========]" +_CPP_TEST_DRYRUN_TAG = "Running main() " +_TEST_START_TAG = "[ RUN ]" +_TEST_OK_TAG = "[ OK ]" +_TEST_SKIPPED_TAG = "[ SKIPPED ]" +_TEST_FAILED_TAG = "[ FAILED ]" +_ALT_OK_TAG = "[ OK ]" +_TIMEOUT_TAG = "[ TIMEOUT ]" + +_CTEST_START_TEST_RUN_TAG = "Framework inited." +_CTEST_END_TEST_RUN_TAG = "Framework finished." +_CTEST_SUITE_TEST_RUN_TAG = "Start to run test suite:" +_CTEST_SUITE_TIME_RUN_TAG = "Run test suite " +_CTEST_SETUP_TAG = "setup" +_CTEST_RUN_TAG = "-----------------------" + +_TEST_PASSED_LOWER = "pass" + +_COMPILE_PASSED = "compile PASSED" +_COMPILE_PARA = r"(.* compile .*)" + +_PRODUCT_PARA = r"(.*The .* is .*)" +_PRODUCT_PARA_START = r"To Obtain Product Params Start" +_PRODUCT_PARA_END = r"To Obtain Product Params End" + +_START_JSUNIT_RUN_MARKER = "[start] start run suites" +_START_JSUNIT_SUITE_RUN_MARKER = "[suite start]" +_START_JSUNIT_SUITE_END_MARKER = "[suite end]" +_END_JSUNIT_RUN_MARKER = "[end] run suites end" +_PASS_JSUNIT_MARKER = "[%s]" % "pass" +_FAIL_JSUNIT_MARKER = "[fail]" +_ACE_LOG_MARKER = "[Console Info]" + +LOG = platform_logger("ParserLite") + + +@Plugin(type=Plugin.PARSER, id=ParserType.cpp_test_lite) +class CppTestParserLite(IParser): + def __init__(self): + self.state_machine = StateRecorder() + self.suite_name = "" + self.listeners = [] + self.product_info = {} + self.is_params = False + + def get_suite_name(self): + return self.suite_name + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(lines) + for line in lines: + if not check_pub_key_exist(): + LOG.debug(line) + self.parse(line) + + def __done__(self): + suite_result = self.state_machine.suite() + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + self.state_machine.running_test_index = 0 + + suites_result = self.state_machine.get_suites() + if not suites_result.suites_name: + return + for listener in self.get_listeners(): + suites = copy.copy(suites_result) + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name, + product_info=suites.product_info) + self.state_machine.current_suites = None + + @staticmethod + def _is_test_run(line): + return True if _TEST_RUN_TAG in line else False + + @staticmethod + def _is_test_start_run(line): + return True if _TEST_START_RUN_TAG in line else False + + @staticmethod + def _is_informational_start(line): + return True if _INFORMATIONAL_START in line else False + + @staticmethod + def _is_test_start(line): + return True if _TEST_START_TAG in line else False + + def _process_informational_line(self, line): + pattern = r"(.*) (\(\d+ ms total\))" + message = line[len(_INFORMATIONAL_START):].strip() + if re.match(pattern, line.strip()): + self.handle_suite_ended_tag(message) + elif re.match(r'(\d+) test[s]? from (.*)', message): + self.handle_suite_started_tag(message) + + def _process_test_run_line(self, line): + if not self.state_machine.suites_is_running(): + return + message = line[len(_TEST_RUN_TAG):].strip() + self.handle_suites_ended_tag(message) + + def parse(self, line): + if _PRODUCT_PARA_START in line: + self.is_params = True + elif _PRODUCT_PARA_END in line: + self.is_params = False + if re.match(_PRODUCT_PARA, line) and self.is_params: + handle_product_info(line, self.product_info) + + if self.state_machine.suites_is_started() or self._is_test_run(line): + if self._is_test_start_run(line): + self.handle_suites_started_tag(line) + elif self._is_informational_start(line): + self._process_informational_line(line) + elif self._is_test_run(line): + self._process_test_run_line(line) + elif self._is_test_start(line): + message = line[line.index(_TEST_START_TAG) + + len(_TEST_START_TAG):].strip() + self.handle_test_started_tag(message) + else: + self.process_test(line) + + def process_test(self, line): + if _TEST_SKIPPED_TAG in line: + message = line[line.index(_TEST_SKIPPED_TAG) + len( + _TEST_SKIPPED_TAG):].strip() + if not self.state_machine.test_is_running(): + LOG.error( + "Found {} without {} before, wrong GTest log format". + format(line, _TEST_START_TAG), error_no="00405") + return + self.handle_test_ended_tag(message, ResultCode.SKIPPED) + elif _TEST_OK_TAG in line: + message = line[line.index(_TEST_OK_TAG) + len( + _TEST_OK_TAG):].strip() + if not self.state_machine.test_is_running(): + LOG.error( + "Found {} without {} before, wrong GTest log format". + format(line, _TEST_START_TAG), error_no="00405") + return + self.handle_test_ended_tag(message, ResultCode.PASSED) + elif _ALT_OK_TAG in line: + message = line[line.index(_ALT_OK_TAG) + len( + _ALT_OK_TAG):].strip() + self.fake_run_marker(message) + self.handle_test_ended_tag(message, ResultCode.PASSED) + elif _TEST_FAILED_TAG in line: + message = line[line.index(_TEST_FAILED_TAG) + len( + _TEST_FAILED_TAG):].strip() + if not self.state_machine.suite_is_running(): + return + if not self.state_machine.test_is_running(): + self.fake_run_marker(message) + self.handle_test_ended_tag(message, ResultCode.FAILED) + elif _TIMEOUT_TAG in line: + message = line[line.index(_TIMEOUT_TAG) + len( + _TIMEOUT_TAG):].strip() + self.fake_run_marker(message) + self.handle_test_ended_tag(message, ResultCode.FAILED) + elif self.state_machine.test_is_running(): + self.append_test_output(line) + + def handle_test_started_tag(self, message): + test_class, test_name, _ = self.parse_test_description(message) + test_result = self.state_machine.test(reset=True) + test_result.test_class = test_class + test_result.test_name = test_name + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + @classmethod + def parse_test_description(cls, message): + run_time = 0 + matcher = re.match(r'(.*) \((\d+) ms\)(.*)', message) + if matcher: + test_class, test_name = matcher.group(1).rsplit(".", 1) + run_time = int(matcher.group(2)) + else: + test_class, test_name = message.rsplit(".", 1) + return test_class.split(" ")[-1], test_name.split(" ")[0], run_time + + def handle_test_ended_tag(self, message, test_status): + test_class, test_name, run_time = self.parse_test_description( + message) + test_result = self.state_machine.test() + test_result.run_time = int(run_time) + test_result.code = test_status.value + if not test_result.is_running(): + LOG.error( + "Test has no start tag when trying to end test: %s", message, + error_no="00405") + return + found_unexpected_test = False + if test_result.test_class != test_class: + LOG.error( + "Expected class: {} but got:{} ".format(test_result.test_class, + test_class), + error_no="00405") + found_unexpected_test = True + if test_result.test_name != test_name: + LOG.error( + "Expected test: {} but got: {}".format(test_result.test_name, + test_name), + error_no="00405") + found_unexpected_test = True + test_result.current = self.state_machine.running_test_index + 1 + self.state_machine.test().is_completed = True + if found_unexpected_test: + test_result.code = ResultCode.FAILED.value + + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + + def fake_run_marker(self, message): + fake_marker = re.compile(" +").split(message) + self.handle_test_started_tag(fake_marker) + + def handle_suites_started_tag(self, message): + self.state_machine.get_suites(reset=True) + matcher = re.match(r'.* Running (\d+) test[s]? from .*', message) + expected_test_num = int(matcher.group(1)) if matcher else -1 + if expected_test_num >= 0: + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.get_suite_name() + test_suites.test_num = expected_test_num + test_suites.product_info = self.product_info + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suite_started_tag(self, message): + self.state_machine.suite(reset=True) + matcher = re.match(r'(\d+) test[s]? from (.*)', message) + expected_test_num = int(matcher.group(1)) if matcher else -1 + if expected_test_num >= 0: + test_suite = self.state_machine.suite() + test_suite.suite_name = matcher.group(2) + test_suite.test_num = expected_test_num + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self, message): + suite_result = self.state_machine.suite() + matcher = re.match(r'.*\((\d+) ms total\)', message) + if matcher: + suite_result.run_time = int(matcher.group(1)) + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + self.state_machine.running_test_index = 0 + + def handle_suites_ended_tag(self, message): + suites = self.state_machine.get_suites() + matcher = re.match(r'.*\((\d+) ms total\)', message) + if matcher: + suites.run_time = int(matcher.group(1)) + suites.is_completed = True + for listener in self.get_listeners(): + copy_suites = copy.copy(suites) + listener.__ended__(LifeCycle.TestSuites, test_result=copy_suites, + suites_name=suites.suites_name, + product_info=suites.product_info) + + def append_test_output(self, message): + if self.state_machine.test().stacktrace: + self.state_machine.test().stacktrace = "{}\r\n".format( + self.state_machine.test().stacktrace) + self.state_machine.test().stacktrace = "{}{}".format( + self.state_machine.test().stacktrace, message) + + @staticmethod + def handle_test_run_failed(error_msg): + if not error_msg: + error_msg = "Unknown error" + if not check_pub_key_exist(): + LOG.debug("Error msg:%s" % error_msg) + + def mark_test_as_failed(self, test): + if not self.state_machine.current_suite and not test.class_name: + return + suites_result = self.state_machine.get_suites(reset=True) + suites_result.suites_name = self.get_suite_name() + + suite_name = self.state_machine.current_suite.suite_name if \ + self.state_machine.current_suite else None + suite_result = self.state_machine.suite(reset=True) + test_result = self.state_machine.test(reset=True) + suite_result.suite_name = suite_name or test.class_name + suite_result.suite_num = 1 + test_result.test_class = test.class_name + test_result.test_name = test.test_name + test_result.stacktrace = "error_msg: Unknown error" + test_result.num_tests = 1 + test_result.run_time = 0 + test_result.code = ResultCode.FAILED.value + for listener in self.get_listeners(): + suite_report = copy.copy(suites_result) + listener.__started__(LifeCycle.TestSuites, suite_report) + for listener in self.get_listeners(): + suite_report = copy.copy(suite_result) + listener.__started__(LifeCycle.TestSuite, suite_report) + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, test_result) + for listener in self.get_listeners(): + suite_report = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite_report, + is_clear=True) + self.__done__() + + +@Plugin(type=Plugin.PARSER, id=ParserType.cpp_test_list_lite) +class CppTestListParserLite(IParser): + def __init__(self): + self.last_test_class_name = None + self.state_machine = StateRecorder() + self.listeners = [] + self.tests = [] + self.suites_name = "" + self.class_result = None + self.method_result = None + + def __process__(self, lines): + for line in lines: + if not check_pub_key_exist(): + LOG.debug(line) + self.parse(line) + + def get_suite_name(self): + return self.suites_name + + def get_listeners(self): + return self.listeners + + def __done__(self): + if self.state_machine.is_started(): + self.handle_suite_ended_tag() + suites_result = self.state_machine.get_suites() + if not suites_result.suites_name: + return + for listener in self.get_listeners(): + suites = copy.copy(suites_result) + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name) + self.state_machine.current_suites = None + + def _is_class(self, line): + self.class_result = re.compile('^([a-zA-Z]+.*)\\.$').match(line) + return self.class_result + + def _is_method(self, line): + self.method_result = re.compile( + '\\s+([a-zA-Z_]+[\\S]*)(.*)?(\\s+.*)?$').match(line) + return self.method_result + + def _process_class_line(self, line): + del line + if not self.state_machine.suites_is_started(): + self.handle_suites_started_tag() + self.last_test_class_name = self.class_result.group(1) + if self.state_machine.is_started(): + self.handle_suite_ended_tag() + self.handle_suite_started_tag(self.class_result.group(1)) + + def _process_method_line(self, line): + if not self.last_test_class_name: + LOG.error( + "Parsed new test case name %s but no test class" + " name has been set" % line, error_no="00405") + else: + test = TestDescription(self.last_test_class_name, + self.method_result.group(1)) + self.tests.append(test) + self.handle_test_tag(self.last_test_class_name, + self.method_result.group(1)) + + @staticmethod + def _is_cpp_test_dryrun(line): + return True if line.find(_CPP_TEST_DRYRUN_TAG) != -1 else False + + def parse(self, line): + if self.state_machine.suites_is_started() or self._is_cpp_test_dryrun( + line): + if self._is_cpp_test_dryrun(line): + self.handle_suites_started_tag() + elif self._is_class(line): + self._process_class_line(line) + elif self._is_method(line): + self._process_method_line(line) + else: + if not check_pub_key_exist(): + LOG.debug("Line ignored: %s" % line) + + def handle_test_tag(self, test_class, test_name): + test_result = self.state_machine.test(reset=True) + test_result.test_class = test_class + test_result.test_name = test_name + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + self.state_machine.test().is_completed = True + test_result.code = ResultCode.SKIPPED.value + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + test_suites = self.state_machine.get_suites() + test_suite = self.state_machine.suite() + test_suites.test_num += 1 + test_suite.test_num += 1 + + def handle_suites_started_tag(self): + self.state_machine.get_suites(reset=True) + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.get_suite_name() + test_suites.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suite_started_tag(self, class_name): + self.state_machine.suite(reset=True) + test_suite = self.state_machine.suite() + test_suite.suite_name = class_name + test_suite.test_num = 0 + for listener in self.get_listeners(): + test_suite_copy = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, test_suite_copy) + + def handle_suite_ended_tag(self): + suite_result = self.state_machine.suite() + suite_result.is_completed = True + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite) + + def handle_suites_ended_tag(self): + suites = self.state_machine.get_suites() + suites.is_completed = True + for listener in self.get_listeners(): + copy_suites = copy.copy(suites) + listener.__ended__(LifeCycle.TestSuites, test_result=copy_suites, + suites_name=suites.suites_name) + + def mark_test_as_failed(self, test): + if not self.state_machine.current_suite and not test.class_name: + return + suite_name = self.state_machine.current_suite.suite_name if \ + self.state_machine.current_suite else None + suite_result = self.state_machine.suite(reset=True) + test_result = self.state_machine.test(reset=True) + suite_result.suite_name = suite_name or test.class_name + suite_result.suite_num = 1 + test_result.test_class = test.class_name + test_result.test_name = test.test_name + test_result.stacktrace = "error_msg: Unknown error" + test_result.num_tests = 1 + test_result.run_time = 0 + test_result.code = ResultCode.FAILED.value + for listener in self.get_listeners(): + suite_report = copy.copy(suite_result) + listener.__started__(LifeCycle.TestSuite, suite_report) + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, test_result) + self.__done__() + + +@Plugin(type=Plugin.PARSER, id=ParserType.ctest_lite) +class CTestParser(IParser): + last_line = "" + pattern = r"(\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}:\d{1,2}\.\d{3}) " + + def __init__(self): + self.state_machine = StateRecorder() + self.suites_name = "" + self.listeners = [] + self.product_info = {} + self.is_params = False + self.result_lines = [] + + def get_suite_name(self): + return self.suites_name + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(lines) + for line in lines: + self.parse(line) + + def __done__(self): + suites = self.state_machine.get_suites() + suites.is_completed = True + + for listener in self.get_listeners(): + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name, + product_info=suites.product_info) + self.state_machine.current_suites = None + + @staticmethod + def _is_ctest_start_test_run(line): + return True if line.endswith(_CTEST_START_TEST_RUN_TAG) else False + + @staticmethod + def _is_ctest_end_test_run(line): + return True if line.endswith(_CTEST_END_TEST_RUN_TAG) else False + + @staticmethod + def _is_ctest_run(line): + return re.match(r"[\s\S]*(Tests)[\s\S]*(Failures)[\s\S]*(Ignored)[\s\S]*", line) + + def _is_ctest_suite_test_run(self, line): + return re.match("{}{}".format(self.pattern, _CTEST_SUITE_TEST_RUN_TAG), + line) + + def is_ctest_suite_time_run(self, line): + return re.match("{}{}".format(self.pattern, _CTEST_SUITE_TIME_RUN_TAG), + line) + + def _process_ctest_suite_test_run_line(self, line): + _, message_index = re.match( + "{}{}".format(self.pattern, _CTEST_SUITE_TEST_RUN_TAG), + line).span() + self.handle_suite_started_tag(line[message_index:].strip()) + + @staticmethod + def _is_execute_result_line(line): + return re.match( + r"(.*" + "\\.c:" + "\\d+:.*:(PASS|FAIL|OK|IGNORE"")\\.*)", + line.strip()) + + @staticmethod + def _is_result_line(line): + return line.find("PASS") != -1 or line.find("FAIL") != -1 or line.find( + "IGNORE") != -1 + + def parse(self, line): + self._parse_product_info(line) + + if self.state_machine.suites_is_started() or \ + self._is_ctest_start_test_run(line): + try: + test_matcher = re.match(r".*(\d+ Tests).+", line) + failed_matcher = \ + re.match(r".*(Failures).*", line) + ignore_matcher = \ + re.match(r".*(Ignored).*", line) + if (test_matcher or failed_matcher or ignore_matcher) and \ + not self._is_ctest_run(line): + if test_matcher: + self.result_lines.append(test_matcher.group(1)) + if failed_matcher: + self.result_lines.append(failed_matcher.group(1)) + if ignore_matcher: + self.result_lines.append(ignore_matcher.group(1)) + line = " ".join(self.result_lines) + self.result_lines.clear() + if self._is_ctest_start_test_run(line): + self.handle_suites_started_tag() + elif self._is_ctest_end_test_run(line): + self.process_suites_ended_tag() + elif self._is_ctest_run(line): + self.handle_suite_ended_tag(line) + elif self._is_ctest_suite_test_run(line) and \ + not self.state_machine.suite_is_running(): + self._process_ctest_suite_test_run_line(line) + elif self.is_ctest_suite_time_run(line) and \ + not self.state_machine.suite_is_running(): + self.handle_suite_started_tag(line) + elif self._is_result_line(line) and \ + self.state_machine.suite_is_running(): + if line.find(":") != -1 and line.count( + ":") >= 3 and self._is_execute_result_line(line): + self.handle_one_test_tag(line.strip(), False) + else: + self.handle_one_test_tag(line.strip(), True) + except AttributeError as _: + LOG.error("Parsing log: %s failed" % (line.strip()), + error_no="00405") + self.last_line = line + + def _parse_product_info(self, line): + if _PRODUCT_PARA_START in line: + self.is_params = True + elif _PRODUCT_PARA_END in line: + self.is_params = False + if self.is_params and re.match(_PRODUCT_PARA, line): + handle_product_info(line, self.product_info) + + def parse_error_test_description(self, message): + end_time = re.match(self.pattern, message).group().strip() + start_time = re.match(self.pattern, + self.last_line.strip()).group().strip() + start_timestamp = int(time.mktime( + time.strptime(start_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + start_time.split(".")[-1]) + end_timestamp = int(time.mktime( + time.strptime(end_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + end_time.split(".")[-1]) + run_time = end_timestamp - start_timestamp + status_dict = {"PASS": ResultCode.PASSED, "FAIL": ResultCode.FAILED, + "IGNORE": ResultCode.SKIPPED} + status = "" + if message.find("PASS") != -1: + status = "PASS" + elif message.find("FAIL") != -1: + status = "FAIL" + elif message.find("IGNORE") != -1: + status = "IGNORE" + status = status_dict.get(status) + return "", "", status, run_time + + def parse_test_description(self, message): + + test_class = message.split(".c:")[0].split(" ")[-1].split("/")[-1] + message_index = message.index(".c:") + end_time = re.match(self.pattern, message).group().strip() + start_time = re.match(self.pattern, + self.last_line.strip()).group().strip() + start_timestamp = int(time.mktime( + time.strptime(start_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + start_time.split(".")[-1]) + end_timestamp = int(time.mktime( + time.strptime(end_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + end_time.split(".")[-1]) + run_time = end_timestamp - start_timestamp + message_list = message[message_index + 3:].split(":") + test_name, status = message_list[1].strip(), message_list[2].strip() + status_dict = {"PASS": ResultCode.PASSED, "FAIL": ResultCode.FAILED, + "IGNORE": ResultCode.SKIPPED} + status = status_dict.get(status) + return test_class, test_name, status, run_time + + def handle_one_test_tag(self, message, is_error): + if is_error: + test_class, test_name, status, run_time = \ + self.parse_error_test_description(message) + else: + test_class, test_name, status, run_time = \ + self.parse_test_description(message) + test_result = self.state_machine.test(reset=True) + test_result.test_class = self.state_machine.suite().suite_name + test_result.test_name = test_name + test_result.run_time = run_time + self.state_machine.running_test_index += 1 + test_result.current = self.state_machine.running_test_index + test_result.code = status.value + self.state_machine.suite().run_time += run_time + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + test_suite = self.state_machine.suite() + test_suites = self.state_machine.get_suites() + + found_unexpected_test = False + + if found_unexpected_test or ResultCode.FAILED == status: + if "FAIL:" in message and not message.endswith("FAIL:"): + test_result.stacktrace = message[ + message.rindex("FAIL:") + len( + "FAIL:"):] + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + elif ResultCode.SKIPPED == status: + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + + self.state_machine.test().is_completed = True + test_suite.test_num += 1 + test_suites.test_num += 1 + + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + + def handle_suites_started_tag(self): + self.state_machine.get_suites(reset=True) + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.suites_name + test_suites.product_info = self.product_info + test_suites.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suite_started_tag(self, message): + if re.match("{}{}".format(self.pattern, _CTEST_SUITE_TIME_RUN_TAG), + message.strip()): + message = self.state_machine.suite().suite_name + self.state_machine.suite(reset=True) + test_suite = self.state_machine.suite() + test_suite.suite_name = message + test_suite.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self, line): + suite_result = self.state_machine.suite() + suites = self.state_machine.get_suites() + suite_result.run_time = suite_result.run_time + suites.run_time += suite_result.run_time + suite_result.is_completed = True + + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + self.state_machine.running_test_index = 0 + + def process_suites_ended_tag(self): + suites = self.state_machine.get_suites() + suites.is_completed = True + + for listener in self.get_listeners(): + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name, + product_info=suites.product_info) + + def append_test_output(self, message): + if self.state_machine.test().stacktrace: + self.state_machine.test().stacktrace = "{}\r\n".format( + self.state_machine.test().stacktrace) + self.state_machine.test().stacktrace = "{}{}".format( + self.state_machine.test().stacktrace, message) + + +@Plugin(type=Plugin.PARSER, id=ParserType.open_source_test) +class OpenSourceParser(IParser): + def __init__(self): + self.state_machine = StateRecorder() + self.suite_name = "" + self.test_name = "" + self.test_num = 1 + self.listeners = [] + self.output = "" + self.lines = [] + self.start_time = None + + def get_suite_name(self): + return self.suite_name + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.start_time: + self.start_time = get_cst_time() + self.lines.extend(lines) + + def __done__(self): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(self.lines) + self.handle_suite_started_tag(self.test_num) + + test_result = self.state_machine.test(reset=True, + test_index=self.test_name) + test_result.run_time = 0 + test_result.test_class = self.suite_name + test_result.test_name = self.test_name + test_result.test_num = 1 + test_result.current = 1 + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, result) + for line in self.lines: + self.output = "{}{}".format(self.output, line) + if _TEST_PASSED_LOWER in line.lower(): + test_result.code = ResultCode.PASSED.value + if self.start_time: + end_time = get_cst_time() + run_time = (end_time - self.start_time).total_seconds() + test_result.run_time = int(run_time * 1000) + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + break + else: + test_result.code = ResultCode.FAILED.value + test_result.stacktrace = "\\n".join(self.lines) + if self.start_time: + end_time = get_cst_time() + run_time = (end_time - self.start_time).total_seconds() + test_result.run_time = int(run_time * 1000) + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + + self.state_machine.test().is_completed = True + self.handle_suite_ended_tag() + + def handle_suite_started_tag(self, test_num): + test_suite = self.state_machine.suite() + if test_num >= 0: + test_suite.suite_name = self.suite_name + test_suite.test_num = test_num + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self): + suite_result = self.state_machine.suite() + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, + suite_report=True) + + +@Plugin(type=Plugin.PARSER, id=ParserType.build_only_test) +class BuildOnlyParser(IParser): + def __init__(self): + self.state_machine = StateRecorder() + self.suite_name = "" + self.test_name = "" + self.test_num = 0 + self.listeners = [] + self.output = "" + + def get_suite_name(self): + return self.suite_name + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(lines) + self.handle_suite_started_tag(self.test_num) + + self.state_machine.running_test_index = \ + self.state_machine.running_test_index + 1 + + for line in lines: + if re.match(_COMPILE_PARA, line): + self.test_name = str(line).split('compile')[0].strip() + test_result = self.state_machine.test(reset=True) + test_result.run_time = 0 + test_result.test_class = self.suite_name + test_result.test_name = self.test_name + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, result) + if _COMPILE_PASSED in line: + test_result.code = ResultCode.PASSED.value + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + else: + test_result.code = ResultCode.FAILED.value + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + self.state_machine.test().is_completed = True + + def __done__(self): + self.handle_suite_ended_tag() + + def handle_suite_started_tag(self, test_num): + test_suite = self.state_machine.suite() + if test_num >= 0: + test_suite.suite_name = self.suite_name + test_suite.test_num = test_num + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self): + suite_result = self.state_machine.suite() + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, + suite_report=True) + + +@Plugin(type=Plugin.PARSER, id=ParserType.jsuit_test_lite) +class JSUnitParserLite(IParser): + last_line = "" + pattern = r"(\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}:\d{1,2}\.\d{3}) " + + def __init__(self): + self.state_machine = StateRecorder() + self.suites_name = "" + self.listeners = [] + + def get_listeners(self): + return self.listeners + + def __process__(self, lines): + if not self.state_machine.suites_is_started(): + self.state_machine.trace_logs.extend(lines) + for line in lines: + self.parse(line) + + def __done__(self): + pass + + def parse(self, line): + if (self.state_machine.suites_is_started() or + line.find(_START_JSUNIT_RUN_MARKER) != -1) and \ + line.find(_ACE_LOG_MARKER) != -1: + if line.find(_START_JSUNIT_RUN_MARKER) != -1: + self.handle_suites_started_tag() + elif line.endswith(_END_JSUNIT_RUN_MARKER): + self.handle_suites_ended_tag() + elif line.find(_START_JSUNIT_SUITE_RUN_MARKER) != -1: + self.handle_suite_started_tag(line.strip()) + elif line.endswith(_START_JSUNIT_SUITE_END_MARKER): + self.handle_suite_ended_tag() + elif _PASS_JSUNIT_MARKER in line or _FAIL_JSUNIT_MARKER \ + in line: + self.handle_one_test_tag(line.strip()) + self.last_line = line + + def parse_test_description(self, message): + pattern = r"\[(pass|fail)\]" + year = time.strftime("%Y") + filter_message = message.split("[Console Info]")[1].strip() + end_time = "%s-%s" % \ + (year, re.match(self.pattern, message).group().strip()) + start_time = "%s-%s" % \ + (year, re.match(self.pattern, + self.last_line.strip()).group().strip()) + start_timestamp = int(time.mktime( + time.strptime(start_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + start_time.split(".")[-1]) + end_timestamp = int(time.mktime( + time.strptime(end_time, "%Y-%m-%d %H:%M:%S.%f"))) * 1000 + int( + end_time.split(".")[-1]) + run_time = end_timestamp - start_timestamp + _, status_end_index = re.match(pattern, filter_message).span() + status = filter_message[:status_end_index] + test_name = filter_message[status_end_index:] + status_dict = {"pass": ResultCode.PASSED, "fail": ResultCode.FAILED, + "ignore": ResultCode.SKIPPED} + status = status_dict.get(status[1:-1]) + return test_name, status, run_time + + def handle_suites_started_tag(self): + self.state_machine.get_suites(reset=True) + test_suites = self.state_machine.get_suites() + test_suites.suites_name = self.suites_name + test_suites.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suites) + listener.__started__(LifeCycle.TestSuites, suite_report) + + def handle_suites_ended_tag(self): + suites = self.state_machine.get_suites() + suites.is_completed = True + + for listener in self.get_listeners(): + listener.__ended__(LifeCycle.TestSuites, test_result=suites, + suites_name=suites.suites_name) + + def handle_one_test_tag(self, message): + test_name, status, run_time = \ + self.parse_test_description(message) + test_result = self.state_machine.test(reset=True) + test_suite = self.state_machine.suite() + test_result.test_class = test_suite.suite_name + test_result.test_name = test_name + test_result.run_time = run_time + test_result.code = status.value + test_result.current = self.state_machine.running_test_index + 1 + self.state_machine.suite().run_time += run_time + for listener in self.get_listeners(): + test_result = copy.copy(test_result) + listener.__started__(LifeCycle.TestCase, test_result) + + test_suites = self.state_machine.get_suites() + found_unexpected_test = False + + if found_unexpected_test or ResultCode.FAILED == status: + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__failed__(LifeCycle.TestCase, result) + elif ResultCode.SKIPPED == status: + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__skipped__(LifeCycle.TestCase, result) + + self.state_machine.test().is_completed = True + test_suite.test_num += 1 + test_suites.test_num += 1 + for listener in self.get_listeners(): + result = copy.copy(test_result) + listener.__ended__(LifeCycle.TestCase, result) + self.state_machine.running_test_index += 1 + + def fake_run_marker(self, message): + fake_marker = re.compile(" +").split(message) + self.processTestStartedTag(fake_marker) + + def handle_suite_started_tag(self, message): + self.state_machine.suite(reset=True) + test_suite = self.state_machine.suite() + if re.match(r".*\[suite start\].*", message): + _, index = re.match(r".*\[suite start\]", message).span() + test_suite.suite_name = message[index:] + test_suite.test_num = 0 + for listener in self.get_listeners(): + suite_report = copy.copy(test_suite) + listener.__started__(LifeCycle.TestSuite, suite_report) + + def handle_suite_ended_tag(self): + suite_result = self.state_machine.suite() + suites = self.state_machine.get_suites() + suite_result.run_time = suite_result.run_time + suites.run_time += suite_result.run_time + suite_result.is_completed = True + + for listener in self.get_listeners(): + suite = copy.copy(suite_result) + listener.__ended__(LifeCycle.TestSuite, suite, is_clear=True) + + def append_test_output(self, message): + if self.state_machine.test().stacktrace: + self.state_machine.test().stacktrace = \ + "%s\r\n" % self.state_machine.test().stacktrace + self.state_machine.test().stacktrace = \ + ''.join((self.state_machine.test().stacktrace, message)) + + +def handle_product_info(message, product_info): + message = message[message.index("The"):] + items = message[len("The "):].split(" is ") + product_info.setdefault(items[0].strip(), + items[1].strip().strip("[").strip("]")) diff --git a/xdevice/plugins/ohos/src/ohos/testkit/__init__.py b/xdevice/plugins/ohos/src/ohos/testkit/__init__.py new file mode 100644 index 0000000..160d3a7 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/testkit/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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/xdevice/plugins/ohos/src/ohos/testkit/kit.py b/xdevice/plugins/ohos/src/ohos/testkit/kit.py new file mode 100644 index 0000000..ce94335 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/testkit/kit.py @@ -0,0 +1,1147 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import re +import subprocess +import zipfile +import stat +import time +import json +from dataclasses import dataclass +from tempfile import TemporaryDirectory +from tempfile import NamedTemporaryFile +from multiprocessing import Process +from multiprocessing import Queue + +from xdevice import ITestKit +from xdevice import platform_logger +from xdevice import Plugin +from xdevice import ParamError +from xdevice import get_file_absolute_path +from xdevice import get_config_value +from xdevice import exec_cmd +from xdevice import ConfigConst +from xdevice import AppInstallError +from xdevice import convert_serial +from xdevice import check_path_legal +from xdevice import modify_props +from xdevice import get_app_name_by_tool +from xdevice import remount +from xdevice import disable_keyguard +from xdevice import get_class +from xdevice import get_cst_time + +from ohos.constants import CKit +from ohos.environment.dmlib import HdcHelper +from ohos.environment.dmlib import CollectingOutputReceiver + +__all__ = ["STSKit", "CommandKit", "PushKit", "PropertyCheckKit", "ShellKit", "WifiKit", + "ConfigKit", "AppInstallKit", "ComponentKit", "PermissionKit", + "junit_dex_para_parse", "SmartPerfKit"] + +MAX_WAIT_COUNT = 4 +TARGET_SDK_VERSION = 22 + +LOG = platform_logger("Kit") + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.command) +class CommandKit(ITestKit): + + def __init__(self): + self.run_command = [] + self.teardown_command = [] + self.paths = "" + + def __check_config__(self, config): + self.paths = get_config_value('paths', config) + self.teardown_command = get_config_value('teardown', config) + self.run_command = get_config_value('shell', config) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("CommandKit setup, device:{}, params:{}". + format(device, self.get_plugin_config().__dict__)) + if len(self.run_command) == 0: + LOG.info("No setup_command to run, skipping!") + return + for command in self.run_command: + self._run_command(command, device) + + def __teardown__(self, device): + LOG.debug("CommandKit teardown: device:{}, params:{}".format + (device, self.get_plugin_config().__dict__)) + if len(self.teardown_command) == 0: + LOG.info("No teardown_command to run, skipping!") + return + for command in self.teardown_command: + self._run_command(command, device) + + def _run_command(self, command, device): + + command_type = command.get("name").strip() + command_value = command.get("value") + + if command_type == "reboot": + device.reboot() + elif command_type == "install": + LOG.debug("Trying to install package {}".format(command_value)) + package = get_file_absolute_path(command_value, self.paths) + if not package or not os.path.exists(package): + LOG.error( + "The package {} to be installed does not exist".format( + package)) + + result = device.install_package(package) + if not result.startswith("Success") and "successfully" not in result: + raise AppInstallError( + "Failed to install %s on %s. Reason:%s" % + (package, device.__get_serial__(), result)) + LOG.debug("Installed package finished {}".format(package)) + elif command_type == "uninstall": + LOG.debug("Trying to uninstall package {}".format(command_value)) + package = get_file_absolute_path(command_value, self.paths) + app_name = get_app_name_by_tool(package, self.paths) + if app_name: + result = device.uninstall_package(app_name) + if not result.startswith("Success"): + LOG.error("error uninstalling package %s %s" % + (device.__get_serial__(), result)) + LOG.debug("uninstall package finished {}".format(app_name)) + elif command_type == "pull": + files = command_value.split("->") + remote = files[0].strip() + local = files[1].strip() + device.pull_file(remote, local) + elif command_type == "push": + files = command_value.split("->") + src = files[0].strip() + dst = files[1].strip() if files[1].strip().startswith("/") else \ + files[1].strip() + Props.dest_root + LOG.debug( + "Trying to push the file local {} to remote{}".format( + src, dst)) + real_src_path = get_file_absolute_path(src, self.paths) + if not real_src_path or not os.path.exists(real_src_path): + LOG.error( + "The src file {} to be pushed does not exist".format(src)) + device.push_file(real_src_path, dst) + LOG.debug("Push file finished from {} to {}".format(src, dst)) + elif command_type == "shell": + device.execute_shell_command(command_value) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.sts) +class STSKit(ITestKit): + def __init__(self): + self.sts_version = "" + self.throw_error = "" + + def __check_config__(self, config): + self.sts_version = get_config_value('sts-version', config) + self.throw_error = get_config_value('throw-error', config) + if len(self.sts_version) < 1: + raise TypeError( + "The sts_version: {} is invalid".format(self.sts_version)) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("STSKit setup, device:{}, params:{}". + format(device, self.get_plugin_config().__dict__)) + device_spl = device.get_property(Props.security_patch) + if device_spl is None or device_spl == "": + LOG.error("The device security {} is invalid".format(device_spl)) + raise ParamError( + "The device security patch version {} is invalid".format( + device_spl)) + rex = '^[a-zA-Z\\d\\.]+_([\\d]+-[\\d]+)$' + match = re.match(rex, self.sts_version) + if match is None: + LOG.error("The sts version {} does match the rule".format( + self.sts_version)) + raise ParamError("The sts version {} does match the rule".format( + self.sts_version)) + sts_version_date_user = match.group(1).join("-01") + sts_version_date_kernel = match.group(1).join("-05") + if device_spl in [sts_version_date_user, sts_version_date_kernel]: + LOG.info( + "The device SPL version {} match the sts version {}".format( + device_spl, self.sts_version)) + else: + err_msg = "The device SPL version {} does not match the sts " \ + "version {}".format(device_spl, self.sts_version) + LOG.error(err_msg) + raise ParamError(err_msg) + + def __teardown__(self, device): + LOG.debug("STSKit teardown: device:{}, params:{}".format + (device, self.get_plugin_config().__dict__)) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.push) +class PushKit(ITestKit): + def __init__(self): + self.pre_push = "" + self.push_list = "" + self.post_push = "" + self.is_uninstall = "" + self.paths = "" + self.pushed_file = [] + self.abort_on_push_failure = True + self.teardown_push = "" + + def __check_config__(self, config): + self.pre_push = get_config_value('pre-push', config) + self.push_list = get_config_value('push', config) + self.post_push = get_config_value('post-push', config) + self.teardown_push = get_config_value('teardown-push', config) + self.is_uninstall = get_config_value('uninstall', config, + is_list=False, default=True) + self.abort_on_push_failure = get_config_value( + 'abort-on-push-failure', config, is_list=False, default=True) + if isinstance(self.abort_on_push_failure, str): + self.abort_on_push_failure = False if \ + self.abort_on_push_failure.lower() == "false" else True + + self.paths = get_config_value('paths', config) + self.pushed_file = [] + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("PushKit setup, device:{}".format(device.device_sn)) + for command in self.pre_push: + run_command(device, command) + dst = None + remount(device) + for push_info in self.push_list: + files = re.split('->|=>', push_info) + if len(files) != 2: + LOG.error("The push spec is invalid: {}".format(push_info)) + continue + src = files[0].strip() + dst = files[1].strip() if files[1].strip().startswith("/") else \ + files[1].strip() + Props.dest_root + LOG.debug( + "Trying to push the file local {} to remote {}".format(src, + dst)) + + try: + real_src_path = get_file_absolute_path(src, self.paths) + except ParamError as error: + if self.abort_on_push_failure: + raise error + else: + LOG.warning(error, error_no=error.error_no) + continue + # hdc don't support push directory now + if os.path.isdir(real_src_path): + device.connector_command("shell mkdir {}".format(dst)) + for root, _, files in os.walk(real_src_path): + for file in files: + device.push_file("{}".format(os.path.join(root, file)), + "{}".format(dst)) + LOG.debug( + "Push file finished from {} to {}".format( + os.path.join(root, file), dst)) + self.pushed_file.append(os.path.join(dst, file)) + else: + if device.is_directory(dst): + dst = os.path.join(dst, os.path.basename(real_src_path)) + if dst.find("\\") > -1: + dst_paths = dst.split("\\") + dst = "/".join(dst_paths) + device.push_file("{}".format(real_src_path), + "{}".format(dst)) + LOG.debug("Push file finished from {} to {}".format(src, dst)) + self.pushed_file.append(dst) + for command in self.post_push: + run_command(device, command) + return self.pushed_file, dst + + def add_pushed_dir(self, src, dst): + for root, _, files in os.walk(src): + for file_path in files: + self.pushed_file.append( + os.path.join(root, file_path).replace(src, dst)) + + def __teardown__(self, device): + LOG.debug("PushKit teardown: device:{}".format(device.device_sn)) + for command in self.teardown_push: + run_command(device, command) + if self.is_uninstall: + remount(device) + for file_name in self.pushed_file: + LOG.debug("Trying to remove file {}".format(file_name)) + file_name = file_name.replace("\\", "/") + + for _ in range( + Props.trying_remove_maximum_times): + collect_receiver = CollectingOutputReceiver() + file_name = check_path_legal(file_name) + device.execute_shell_command("rm -rf {}".format( + file_name), receiver=collect_receiver, + output_flag=False) + if not collect_receiver.output: + LOG.debug( + "Removed file {} successfully".format(file_name)) + break + else: + LOG.error("Removed file {} successfully". + format(collect_receiver.output)) + else: + LOG.error("Failed to remove file {}".format(file_name)) + + def __add_pushed_file__(self, device, src, dst): + if device.is_directory(dst): + dst = dst + os.path.basename(src) if dst.endswith( + "/") else dst + "/" + os.path.basename(src) + self.pushed_file.append(dst) + + def __add_dir_pushed_files__(self, device, src, dst): + if device.file_exist(device, dst): + for _, dirs, files in os.walk(src): + for file_path in files: + if dst.endswith("/"): + dst = "%s%s" % (dst, os.path.basename(file_path)) + else: + dst = "%s/%s" % (dst, os.path.basename(file_path)) + self.pushed_file.append(dst) + for dir_name in dirs: + self.__add_dir_pushed_files__(device, dir_name, dst) + else: + self.pushed_file.append(dst) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.propertycheck) +class PropertyCheckKit(ITestKit): + def __init__(self): + self.prop_name = "" + self.expected_value = "" + self.throw_error = "" + + def __check_config__(self, config): + self.prop_name = get_config_value('property-name', config, + is_list=False) + self.expected_value = get_config_value('expected-value', config, + is_list=False) + self.throw_error = get_config_value('throw-error', config, + is_list=False) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("PropertyCheckKit setup, device:{}".format(device.device_sn)) + if not self.prop_name: + LOG.warning("The option of property-name not setting") + return + prop_value = device.get_property(self.prop_name) + if not prop_value: + LOG.warning( + "The property {} not found on device, cannot check the value". + format(self.prop_name)) + return + + if prop_value != self.expected_value: + msg = "The value found for property {} is {}, not same with the " \ + "expected {}".format(self.prop_name, prop_value, + self.expected_value) + LOG.warning(msg) + if self.throw_error and self.throw_error.lower() == 'true': + raise Exception(msg) + + @classmethod + def __teardown__(cls, device): + LOG.debug("PropertyCheckKit teardown: device:{}".format( + device.device_sn)) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.shell) +class ShellKit(ITestKit): + def __init__(self): + self.command_list = [] + self.tear_down_command = [] + self.paths = None + + def __check_config__(self, config): + self.command_list = get_config_value('run-command', config) + self.tear_down_command = get_config_value('teardown-command', config) + self.tear_down_local_command = get_config_value('teardown-localcommand', config) + self.paths = get_config_value('paths', config) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("ShellKit setup, device:{}".format(device.device_sn)) + if len(self.command_list) == 0: + LOG.info("No setup_command to run, skipping!") + return + for command in self.command_list: + run_command(device, command) + + def __teardown__(self, device): + LOG.debug("ShellKit teardown: device:{}".format(device.device_sn)) + if len(self.tear_down_command) == 0: + LOG.info("No teardown_command to run, skipping!") + else: + for command in self.tear_down_command: + run_command(device, command) + if len(self.tear_down_local_command) == 0: + LOG.info("No teardown-localcommand to run, skipping!") + else: + for command in self.tear_down_local_command: + subprocess.run(command) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.wifi) +class WifiKit(ITestKit): + def __init__(self): + self.certfilename = "" + self.certpassword = "" + self.wifiname = "" + self.paths = "" + + def __check_config__(self, config): + self.certfilename = get_config_value( + 'certfilename', config, False, + default=None) + self.certpassword = get_config_value( + 'certpassword', config, False, + default=None) + self.wifiname = get_config_value( + 'wifiname', config, False, + default=None) + self.paths = get_config_value('paths', config) + + def __setup__(self, device, **kwargs): + request = kwargs.get("request", None) + if not request: + LOG.error("WifiKit need input request") + return + testargs = request.get("testargs", {}) + self.certfilename = \ + testargs.pop("certfilename", [self.certfilename])[0] + self.wifiname = \ + testargs.pop("wifiname", [self.wifiname])[0] + self.certpassword = \ + testargs.pop("certpassword", [self.certpassword])[0] + del kwargs + LOG.debug("WifiKit setup, device:{}".format(device.device_sn)) + + try: + wifi_app_path = get_file_absolute_path( + Props.Paths.service_wifi_app_path, self.paths) + except ParamError as _: + wifi_app_path = None + + if wifi_app_path is None: + LOG.error("The resource wifi app file does not exist!") + return + + try: + pfx_path = get_file_absolute_path( + "tools/wifi/%s" % self.certfilename + ) if self.certfilename else None + except ParamError as _: + pfx_path = None + + if pfx_path is None: + LOG.error("The resource wifi pfx file does not exist!") + return + pfx_dest_path = \ + "/storage/emulated/0/%s" % self.certfilename + if self.wifiname is None: + LOG.error("The wifi name is not given!") + return + if self.certpassword is None: + LOG.error("The wifi password is not given!") + return + + device.install_package(wifi_app_path, command="-r") + device.push_file(pfx_path, pfx_dest_path) + device.execute_shell_command("svc wifi enable") + for _ in range(Props.maximum_connect_wifi_times): + connect_wifi_cmd = Props.connect_wifi_cmd % ( + pfx_dest_path, + self.certpassword, + self.wifiname + ) + if device.execute_shell_command(connect_wifi_cmd): + LOG.info("Connect wifi successfully") + break + else: + LOG.error("Connect wifi failed") + + @classmethod + def __teardown__(cls, device): + LOG.debug("WifiKit teardown: device:{}".format(device.device_sn)) + LOG.info("Disconnect wifi") + device.execute_shell_command("svc wifi disable") + + +@dataclass +class Props: + @dataclass + class Paths: + system_build_prop_path = "/%s/%s" % ("system", "build.prop") + service_wifi_app_path = "tools/wifi/%s" % "Service-wifi.app" + + dest_root = "/%s/%s/" % ("data", "data") + mnt_external_storage = "EXTERNAL_STORAGE" + trying_remove_maximum_times = 3 + maximum_connect_wifi_times = 3 + connect_wifi_cmd = "am instrument -e request \"{module:Wifi, " \ + "method:connectWifiByCertificate, params:{'certPath':" \ + "'%s'," \ + "'certPassword':'%s'," \ + "'wifiName':'%s'}}\" " \ + "-w com.xdeviceservice.service/.MainInstrumentation" + security_patch = "ro.build.version.security_patch" + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.config) +class ConfigKit(ITestKit): + def __init__(self): + self.is_connect_wifi = "" + self.is_disconnect_wifi = "" + self.wifi_kit = WifiKit() + self.min_external_store_space = "" + self.is_disable_dialing = "" + self.is_test_harness = "" + self.is_audio_silent = "" + self.is_disable_dalvik_verifier = "" + self.build_prop_list = "" + self.is_enable_hook = "" + self.cust_prop_file = "" + self.is_prop_changed = False + self.local_system_prop_file = "" + self.cust_props = "" + self.is_reboot_delay = "" + self.is_remount = "" + self.local_cust_prop_file = {} + + def __check_config__(self, config): + self.is_connect_wifi = get_config_value('connect-wifi', config, + is_list=False, default=False) + self.is_disconnect_wifi = get_config_value( + 'disconnect-wifi-after-test', config, is_list=False, default=True) + self.wifi_kit = WifiKit() + self.min_external_store_space = get_config_value( + 'min-external-store-space', config) + self.is_disable_dialing = get_config_value('disable-dialing', config) + self.is_test_harness = get_config_value('set-test-harness', config) + self.is_audio_silent = get_config_value('audio-silent', config) + self.is_disable_dalvik_verifier = get_config_value( + 'disable-dalvik-verifier', config) + self.build_prop_list = get_config_value('build-prop', config) + self.cust_prop_file = get_config_value('cust-prop-file', config) + self.cust_props = get_config_value('cust-prop', config) + self.is_enable_hook = get_config_value('enable-hook', config) + self.is_reboot_delay = get_config_value('reboot-delay', config) + self.is_remount = get_config_value('remount', config, default=True) + self.local_system_prop_file = NamedTemporaryFile(prefix='build', + suffix='.prop', + delete=False).name + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("ConfigKit setup, device:{}".format(device.device_sn)) + if self.is_remount: + remount(device) + self.is_prop_changed = self.modify_system_prop(device) + self.is_prop_changed = self.modify_cust_prop( + device) or self.is_prop_changed + + keep_screen_on(device) + if self.is_enable_hook: + pass + if self.is_prop_changed: + device.reboot() + + def __teardown__(self, device): + LOG.debug("ConfigKit teardown: device:{}".format(device.device_sn)) + if self.is_remount: + remount(device) + if self.is_connect_wifi and self.is_disconnect_wifi: + self.wifi_kit.__teardown__(device) + if self.is_prop_changed: + device.push_file(self.local_system_prop_file, + Props.Paths.system_build_prop_path) + device.execute_shell_command( + " ".join(["chmod 644", Props.Paths.system_build_prop_path])) + os.remove(self.local_system_prop_file) + + for target_file, temp_file in self.local_cust_prop_file.items(): + device.push_file(temp_file, target_file) + device.execute_shell_command( + " ".join(["chmod 644", target_file])) + os.remove(temp_file) + + def modify_system_prop(self, device): + prop_changed = False + new_props = {} + if self.is_disable_dialing: + new_props['ro.telephony.disable-call'] = 'true' + if self.is_test_harness: + new_props['ro.monkey'] = '1' + new_props['ro.test_harness'] = '1' + if self.is_audio_silent: + new_props['ro.audio.silent'] = '1' + if self.is_disable_dalvik_verifier: + new_props['dalvik.vm.dexopt-flags'] = 'v=n' + for prop in self.build_prop_list: + if prop is None or prop.find("=") < 0 or len(prop.split("=")) != 2: + LOG.warning("The build prop:{} not match the format " + "'key=value'".format(prop)) + continue + new_props[prop.split("=")[0]] = prop.split("=")[1] + if new_props: + prop_changed = modify_props(device, self.local_system_prop_file, + Props.Paths.system_build_prop_path, + new_props) + return prop_changed + + def modify_cust_prop(self, device): + prop_changed = False + cust_files = {} + new_props = {} + for cust_prop_file in self.cust_prop_file: + # the correct format should be "CustName:/cust/prop/absolutepath" + if len(cust_prop_file.split(":")) != 2: + LOG.error( + "The value %s of option cust-prop-file is incorrect" % + cust_prop_file) + continue + cust_files[cust_prop_file.split(":")[0]] = \ + cust_prop_file.split(":")[1] + for prop in self.cust_props: + # the correct format should be "CustName:key=value" + prop_infos = re.split(r'[:|=]', prop) + if len(prop_infos) != 3: + LOG.error( + "The value {} of option cust-prop is incorrect".format( + prop)) + continue + file_name, key, value = prop_infos + if file_name not in cust_files: + LOG.error( + "The custName {} must be in cust-prop-file option".format( + file_name)) + continue + props = new_props.setdefault(file_name, {}) + props[key] = value + + for name in new_props.keys(): + cust_file = cust_files.get(name) + temp_cust_file = NamedTemporaryFile(prefix='cust', suffix='.prop', + delete=False).name + self.local_cust_prop_file[cust_file] = temp_cust_file + try: + prop_changed = modify_props(device, temp_cust_file, cust_file, + new_props[name]) or prop_changed + except KeyError: + LOG.error("Get props error.") + continue + + return prop_changed + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.app_install) +class AppInstallKit(ITestKit): + def __init__(self): + self.app_list = "" + self.app_list_name = "" + self.is_clean = "" + self.alt_dir = "" + self.ex_args = "" + self.installed_app = set() + self.paths = "" + self.is_pri_app = "" + self.pushed_hap_file = set() + self.env_index_list = None + + def __check_config__(self, options): + self.app_list = get_config_value('test-file-name', options) + self.app_list_name = get_config_value('test-file-packName', options) + self.is_clean = get_config_value('cleanup-apps', options, False) + self.alt_dir = get_config_value('alt-dir', options, False) + if self.alt_dir and self.alt_dir.startswith("resource/"): + self.alt_dir = self.alt_dir[len("resource/"):] + self.ex_args = get_config_value('install-arg', options, False) + self.installed_app = set() + self.paths = get_config_value('paths', options) + self.is_pri_app = get_config_value('install-as-privapp', options, + False, default=False) + self.env_index_list = get_config_value('env-index', options) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("AppInstallKit setup, device:{}".format(device.device_sn)) + if len(self.app_list) == 0: + LOG.info("No app to install, skipping!") + return + for app in self.app_list: + if self.alt_dir: + app_file = get_file_absolute_path(app, self.paths, + self.alt_dir) + else: + app_file = get_file_absolute_path(app, self.paths) + if app_file is None: + LOG.error("The app file {} does not exist".format(app)) + continue + device.connector_command("install \"{}\"".format(app_file)) + self.installed_app.add(app_file) + + def __teardown__(self, device): + LOG.debug("AppInstallKit teardown: device:{}".format(device.device_sn)) + if self.is_clean and str(self.is_clean).lower() == "true": + if self.app_list_name and len(self.app_list_name) > 0: + for app_name in self.app_list_name: + result = device.uninstall_package(app_name) + if result and (result.startswith("Success") or "successfully" in result): + LOG.debug("uninstalling package Success. result is %s" % + result) + else: + LOG.warning("Error uninstalling package %s %s" % + (device.__get_serial__(), result)) + else: + for app in self.installed_app: + app_name = get_app_name(app) + if app_name: + result = device.uninstall_package(app_name) + if result and (result.startswith("Success") or "successfully" in result): + LOG.debug("uninstalling package Success. result is %s" % + result) + else: + LOG.warning("Error uninstalling package %s %s" % + (device.__get_serial__(), result)) + else: + LOG.warning("Can't find app name for %s" % app) + if self.is_pri_app: + remount(device) + for pushed_file in self.pushed_hap_file: + device.execute_shell_command("rm -r %s" % pushed_file) + + def install_hap(self, device, hap_file): + if self.is_pri_app: + LOG.info("Install hap as privileged app {}".format(hap_file)) + hap_name = os.path.basename(hap_file).replace(".hap", "") + try: + with TemporaryDirectory(prefix=hap_name) as temp_dir: + zif_file = zipfile.ZipFile(hap_file) + zif_file.extractall(path=temp_dir) + entry_app = os.path.join(temp_dir, "Entry.app") + push_dest_dir = os.path.join("/system/priv-app/", hap_name) + device.execute_shell_command("rm -rf " + push_dest_dir, + output_flag=False) + device.push_file(entry_app, os.path.join( + push_dest_dir + os.path.basename(entry_app))) + device.push_file(hap_file, os.path.join( + push_dest_dir + os.path.basename(hap_file))) + self.pushed_hap_file.add(os.path.join( + push_dest_dir + os.path.basename(hap_file))) + device.reboot() + except RuntimeError as exception: + msg = "Install hap app failed withe error {}".format(exception) + LOG.error(msg) + raise Exception(msg) + except Exception as exception: + msg = "Install hap app failed withe exception {}".format( + exception) + LOG.error(msg) + raise Exception(msg) + finally: + zif_file.close() + else: + push_dest = "/%s" % "sdcard" + push_dest = "%s/%s" % (push_dest, os.path.basename(hap_file)) + device.push_file(hap_file, push_dest) + self.pushed_hap_file.add(push_dest) + output = device.execute_shell_command("bm install -p " + push_dest) + if not output.startswith("Success") and not "successfully" in output: + output = output.strip() + if "[ERROR_GET_BUNDLE_INSTALLER_FAILED]" not in output.upper(): + raise AppInstallError( + "Failed to install %s on %s. Reason:%s" % + (push_dest, device.__get_serial__(), output)) + else: + LOG.info("'[ERROR_GET_BUNDLE_INSTALLER_FAILED]' occurs, " + "retry install hap") + exec_out = self.retry_install_hap( + device, "bm install -p " + push_dest) + if not exec_out.startswith("Success") and not "successfully" in output: + raise AppInstallError( + "Retry failed,Can't install %s on %s. Reason:%s" % + (push_dest, device.__get_serial__(), exec_out)) + else: + LOG.debug("Install %s success" % push_dest) + + @classmethod + def retry_install_hap(cls, device, command): + real_command = [HdcHelper.CONNECTOR_NAME, "-t", str(device.device_sn), "-s", + "tcp:%s:%s" % (str(device.host), str(device.port)), + "shell", command] + message = "%s execute command: %s" % \ + (convert_serial(device.device_sn), " ".join(real_command)) + LOG.info(message) + exec_out = "" + for wait_count in range(1, MAX_WAIT_COUNT): + LOG.debug("Retry times:%s, wait %ss" % + (wait_count, (wait_count * 10))) + time.sleep(wait_count * 10) + exec_out = exec_cmd(real_command) + if exec_out and exec_out.startswith("Success"): + break + if not exec_out: + exec_out = "System is not in %s" % ["Windows", "Linux", "Darwin"] + LOG.info("Retry install hap result is: [%s]" % exec_out.strip()) + return exec_out + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.component) +class ComponentKit(ITestKit): + + def __init__(self): + self._white_list_file = "" + self._white_list = "" + self._cap_file = "" + self.paths = "" + self.cache_subsystem = set() + self.cache_part = set() + + def __check_config__(self, config): + self._white_list_file =\ + get_config_value('white-list', config, is_list=False) + self._cap_file = get_config_value('cap-file', config, is_list=False) + self.paths = get_config_value('paths', config) + + def __setup__(self, device, **kwargs): + if hasattr(device, ConfigConst.support_component): + return + if device.label in ["phone", "watch", "car", "tv", "tablet", "ivi"]: + command = "cat %s" % self._cap_file + result = device.execute_shell_command(command) + part_set = set() + subsystem_set = set() + if "{" in result: + for item in json.loads(result).get("components", []): + part_set.add(item.get("component", "")) + subsystems, parts = self.get_white_list() + part_set.update(parts) + subsystem_set.update(subsystems) + setattr(device, ConfigConst.support_component, + (subsystem_set, part_set)) + self.cache_subsystem.update(subsystem_set) + self.cache_part.update(part_set) + + def get_cache(self): + return self.cache_subsystem, self.cache_part + + def get_white_list(self): + if not self._white_list and self._white_list_file: + self._white_list = self._parse_white_list() + return self._white_list + + def _parse_white_list(self): + subsystem = set() + part = set() + white_json_file = os.path.normpath(self._white_list_file) + if not os.path.isabs(white_json_file): + white_json_file = \ + get_file_absolute_path(white_json_file, self.paths) + if os.path.isfile(white_json_file): + subsystem_list = list() + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(white_json_file, flags, modes), + "r") as file_content: + json_result = json.load(file_content) + if "subsystems" in json_result.keys(): + subsystem_list.extend(json_result["subsystems"]) + for subsystem_item_list in subsystem_list: + for key, value in subsystem_item_list.items(): + if key == "subsystem": + subsystem.add(value) + elif key == "components": + for component_item in value: + if "component" in component_item.keys(): + part.add( + component_item["component"]) + + return subsystem, part + + def __teardown__(self, device): + if hasattr(device, ConfigConst.support_component): + setattr(device, ConfigConst.support_component, None) + self._white_list_file = "" + self._white_list = "" + self._cap_file = "" + self.cache_subsystem.clear() + self.cache_part.clear() + self.cache_device.clear() + + +@Plugin(type=Plugin.TEST_KIT , id=CKit.permission) +class PermissionKit(ITestKit): + def __init__(self): + self.package_name_list = None + self.permission_list = None + + def __check_config__(self, config): + self.package_name_list = \ + get_config_value('package-names', config, True, []) + self.permission_list = \ + get_config_value('permissions', config, True, []) + + def __setup__(self, device, **kwargs): + if not self.package_name_list or not self.permission_list: + LOG.warning("Please check parameters of permission kit in json") + return + for index in range(len(self.package_name_list)): + cur_name = self.package_name_list[index] + token_id = self._get_token_id(device, cur_name) + if not token_id: + LOG.warning("Not found accessTokenId of '{}'".format(cur_name)) + continue + for permission in self.permission_list[index]: + command = "atm perm -g -i {} -p {}".format(token_id, + permission) + out = device.execute_shell_command(command) + LOG.debug("Set permission result: {}".format(out)) + + def __teardown__(self, device): + pass + + def _get_token_id(self, device, pkg_name): + # shell bm dump -n + dump_command = "bm dump -n {}".format(pkg_name) + content = device.execute_shell_command(dump_command) + if not content or not str(content).startswith(pkg_name): + return "" + content = content[len(pkg_name) + len(":\n"):] + dump_dict = json.loads(content) + if "userInfo" not in dump_dict.keys(): + return "" + user_info_dict = dump_dict["userInfo"][0] + if "accessTokenId" not in user_info_dict.keys(): + return "" + else: + return user_info_dict["accessTokenId"] + + +def keep_screen_on(device): + device.execute_shell_command("svc power stayon true") + + +def run_command(device, command): + LOG.debug("The command:{} is running".format(command)) + stdout = None + if command.strip() == "remount": + remount(device) + elif command.strip() == "reboot": + device.reboot() + elif command.strip() == "reboot-delay": + pass + elif command.strip().endswith("&"): + device.execute_shell_in_daemon(command.strip()) + else: + stdout = device.execute_shell_command(command) + LOG.debug("Run command result: %s" % (stdout if stdout else "")) + return stdout + + +def junit_dex_para_parse(device, junit_paras, prefix_char="--"): + """To parse the para of junit + Args: + device: the device running + junit_paras: the para dict of junit + prefix_char: the prefix char of parsed cmd + Returns: + the new para using in a command like -e testFile xxx + -e coverage true... + """ + ret_str = [] + path = "/%s/%s/%s" % ("data", "local", "ajur") + include_file = "%s/%s" % (path, "includes.txt") + exclude_file = "%s/%s" % (path, "excludes.txt") + + if not isinstance(junit_paras, dict): + LOG.warning("The para of junit is not the dict format as required") + return "" + # Disable screen keyguard + disable_key_guard = junit_paras.get('disable-keyguard') + if not disable_key_guard or disable_key_guard[0].lower() != 'false': + disable_keyguard(device) + + for para_name in junit_paras.keys(): + path = "/%s/%s/%s/" % ("data", "local", "ajur") + if para_name.strip() == 'test-file-include-filter': + for file_name in junit_paras[para_name]: + device.push_file(file_name, include_file) + device.execute_shell_command( + 'chown -R shell:shell %s' % path) + ret_str.append(prefix_char + " ".join(['testFile', include_file])) + elif para_name.strip() == "test-file-exclude-filter": + for file_name in junit_paras[para_name]: + device.push_file(file_name, include_file) + device.execute_shell_command( + 'chown -R shell:shell %s' % path) + ret_str.append(prefix_char + " ".join(['notTestFile', + exclude_file])) + elif para_name.strip() == "test" or para_name.strip() == "class": + result = get_class(junit_paras, prefix_char, para_name.strip()) + ret_str.append(result) + elif para_name.strip() == "include-annotation": + ret_str.append(prefix_char + " ".join( + ['annotation', ",".join(junit_paras[para_name])])) + elif para_name.strip() == "exclude-annotation": + ret_str.append(prefix_char + " ".join( + ['notAnnotation', ",".join(junit_paras[para_name])])) + else: + ret_str.append(prefix_char + " ".join( + [para_name, ",".join(junit_paras[para_name])])) + + return " ".join(ret_str) + + +def get_app_name(hap_app): + hap_name = os.path.basename(hap_app).replace(".hap", "") + app_name = "" + hap_file_info = None + config_json_file = "" + try: + hap_file_info = zipfile.ZipFile(hap_app) + name_list = ["module.json", "config.json"] + for _, name in enumerate(hap_file_info.namelist()): + if name in name_list: + config_json_file = name + break + config_info = hap_file_info.read(config_json_file).decode('utf-8') + attrs = json.loads(config_info) + if "app" in attrs.keys() and \ + "bundleName" in attrs.get("app", dict()).keys(): + app_name = attrs["app"]["bundleName"] + LOG.info("Obtain the app name {} from json " + "successfully".format(app_name)) + else: + LOG.debug("Tip: 'app' or 'bundleName' not " + "in %s.hap/config.json" % hap_name) + except Exception as e: + LOG.error("get app name from hap error: {}".format(e)) + finally: + if hap_file_info: + hap_file_info.close() + return app_name + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.smartperf) +class SmartPerfKit(ITestKit): + def __init__(self): + self._run_command = ["SP_daemon", "-PKG"] + self._process = None + self._pattern = "order:\\d+ (.+)=(.+)" + self._param_key = ["cpu", "gpu", "ddr", "fps", "pnow", "ram", "temp"] + self.target_name = "" + self._msg_queue = None + + def __check_config__(self, config): + self._run_command.append(self.target_name) + if isinstance(config, str): + key_value_pairs = str(config).strip(";") + for key_value_pair in key_value_pairs: + key, value = key_value_pair.split(":", 1) + if key == "num": + self._run_command.append("-N") + self._run_command.append(value) + else: + if key in self._param_key and value == "true": + self._run_command.append("-" + key[:1]) + + def _execute(self, msg_queue, cmd_list, xls_file, proc_name): + data = [] + while msg_queue.empty(): + process = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False) + rev = process.stdout.read() + data.append((get_cst_time().strftime("%Y-%m-%d-%H-%M-%S"), rev)) + self.write_to_file(data, proc_name, xls_file) + + def write_to_file(self, data, proc_name, xls_file): + from openpyxl import Workbook + from openpyxl import styles + book = Workbook() + sheet = book.active + sheet.row_dimensions[1].height = 30 + sheet.column_dimensions["A"].width = 30 + sheet.sheet_properties.tabColor = "1072BA" + alignment = styles.Alignment(horizontal='center', vertical='center') + font = styles.Font(size=15, color="000000", bold=True, + italic=False, strike=None, underline=None) + names = ["time", "PKG"] + start = True + for _time, content in data: + cur = [_time, proc_name] + rev_list = str(content, "utf-8").split("\n") + if start: + start = False + for rev in rev_list: + result = re.match(self._pattern, rev) + if result and result.group(1): + names.append(result.group(1)) + cur.append(result.group(2)) + sheet.append(names) + sheet.append(cur) + for pos in range(1, len(names) + 1): + cur_cell = sheet.cell(1, pos) + sheet.column_dimensions[cur_cell.colum_letter].width = 20 + cur_cell.alignment = alignment + cur_cell.font = font + else: + for rev in rev_list: + result = re.match(self._pattern, rev) + if result and result.group(1): + cur.append(result.group(2)) + sheet.append(cur) + book.save(xls_file) + + def __setup__(self, device, **kwargs): + request = kwargs.get("request") + folder = os.path.join(request.get_config().report_path, "smart_perf") + if not os.path.exists(folder): + os.mkdir(folder) + file = os.path.join(folder, "{}.xlsx".format(request.get_module_name())) + if device.host != "127.0.0.1": + cmd_list = [HdcHelper.CONNECTOR_NAME, "-s", "{}:{}".format( + device.host, device.port), "-t", device.device_sn, "shell"] + else: + cmd_list = [HdcHelper.CONNECTOR_NAME, "-t", device.device_sn, "shell"] + + cmd_list.extend(self._run_command) + LOG.debug("Smart perf command:{}".format(" ".join(cmd_list))) + self._msg_queue = Queue() + self._process = Process(target=self._execute, args=( + self._msg_queue, cmd_list, file, self.target_name)) + self._process.start() + + def __teardown__(self, device): + if self._process: + if self._msg_queue: + self._msg_queue.put("") + self._msg_queue = None + else: + self._process.terminate() + self._process = None diff --git a/xdevice/plugins/ohos/src/ohos/testkit/kit_lite.py b/xdevice/plugins/ohos/src/ohos/testkit/kit_lite.py new file mode 100644 index 0000000..cc3da80 --- /dev/null +++ b/xdevice/plugins/ohos/src/ohos/testkit/kit_lite.py @@ -0,0 +1,755 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import random +import re +import string +import subprocess +import shutil +import platform +import glob +import time +import sys +from xdevice import Plugin +from xdevice import platform_logger +from xdevice import DeviceAllocationState +from xdevice import ParamError +from xdevice import ITestKit +from xdevice import get_config_value +from xdevice import get_file_absolute_path +from xdevice import UserConfigManager +from xdevice import ConfigConst +from xdevice import get_test_component_version +from xdevice import get_local_ip +from xdevice import FilePermission +from xdevice import DeviceTestType +from xdevice import DeviceLabelType +from ohos.exception import LiteDeviceConnectError +from ohos.exception import LiteDeviceError +from ohos.exception import LiteDeviceMountError +from ohos.constants import ComType +from ohos.constants import CKit +from ohos.constants import DeviceLiteKernel + + +__all__ = ["DeployKit", "MountKit", "RootFsKit", "QueryKit", "LiteShellKit", + "LiteAppInstallKit", "DeployToolKit"] +LOG = platform_logger("KitLite") +RESET_CMD = "0xEF, 0xBE, 0xAD, 0xDE, 0x0C, 0x00, 0x87, 0x78, 0x00, 0x00, " \ + "0x61, 0x94" + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.deploy) +class DeployKit(ITestKit): + def __init__(self): + self.burn_file = "" + self.burn_command = "" + self.timeout = "" + self.paths = "" + + def __check_config__(self, config): + self.timeout = str(int(get_config_value( + 'timeout', config, is_list=False, default=0)) // 1000) + self.burn_file = get_config_value('burn_file', config, is_list=False) + burn_command = get_config_value('burn_command', config, is_list=False, + default=RESET_CMD) + self.burn_command = burn_command.replace(" ", "").split(",") + self.paths = get_config_value('paths', config) + if self.timeout == "0" or not self.burn_file: + msg = "The config for deploy kit is invalid with timeout:{}, " \ + "burn_file:{}".format(self.timeout, self.burn_file) + raise ParamError(msg, error_no="00108") + + def _reset(self, device): + cmd_com = device.device.com_dict.get(ComType.cmd_com) + try: + cmd_com.connect() + cmd_com.execute_command( + command='AT+RST={}'.format(self.timeout)) + cmd_com.close() + except (LiteDeviceConnectError, IOError) as error: + device.device_allocation_state = DeviceAllocationState.unusable + LOG.error( + "The exception {} happened in deploy kit running".format( + error), error_no=getattr(error, "error_no", + "00000")) + raise LiteDeviceError("%s port set_up wifiiot failed" % + cmd_com.serial_port, + error_no=getattr(error, "error_no", + "00000")) + finally: + if cmd_com: + cmd_com.close() + + def _send_file(self, device): + burn_tool_name = "HiBurn.exe" if os.name == "nt" else "HiBurn" + burn_tool_path = get_file_absolute_path( + os.path.join("tools", burn_tool_name), self.paths) + patch_file = get_file_absolute_path(self.burn_file, self.paths) + deploy_serial_port = device.device.com_dict.get( + ComType.deploy_com).serial_port + deploy_baudrate = device.device.com_dict.\ + get(ComType.deploy_com).baud_rate + port_number = re.findall(r'\d+$', deploy_serial_port) + if not port_number: + raise LiteDeviceError("The config of serial port {} to deploy is " + "invalid".format(deploy_serial_port), + error_no="00108") + new_temp_tool_path = copy_file_as_temp(burn_tool_path, 10) + cmd = '{} -com:{} -bin:{} -signalbaud:{}' \ + .format(new_temp_tool_path, port_number[0], patch_file, + deploy_baudrate) + LOG.info('The running cmd is {}'.format(cmd)) + LOG.info('The burn tool is running, please wait..') + return_code, out = subprocess.getstatusoutput(cmd) + LOG.info( + 'Deploy kit to execute burn tool finished with return code: {} ' + 'output: {}'.format(return_code, out)) + os.remove(new_temp_tool_path) + if 0 != return_code: + device.device_allocation_state = DeviceAllocationState.unusable + raise LiteDeviceError("%s port set_up wifiiot failed" % + deploy_serial_port, error_no="00402") + + def __setup__(self, device, **kwargs): + """ + Execute reset command on the device by cmd serial port and then upload + patch file by deploy tool. + Parameters: + device: the instance of LocalController with one or more + ComController + """ + del kwargs + self._reset(device) + self._send_file(device) + + def __teardown__(self, device): + pass + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.mount) +class MountKit(ITestKit): + def __init__(self): + self.remote = None + self.paths = "" + self.mount_list = [] + self.mounted_dir = set() + self.server = "" + self.file_name_list = [] + self.remote_info = None + + def __check_config__(self, config): + self.remote = get_config_value('server', config, is_list=False) + self.paths = get_config_value('paths', config) + self.mount_list = get_config_value('mount', config, is_list=True) + self.server = get_config_value('server', config, is_list=False, + default="NfsServer") + if not self.mount_list: + msg = "The config for mount kit is invalid with mount:{}" \ + .format(self.mount_list) + LOG.error(msg, error_no="00108") + raise TypeError("Load Error[00108]") + + def mount_on_board(self, device=None, remote_info=None, case_type=""): + """ + Init the environment on the device server, eg. mount the testcases to + server + + Parameters: + device: DeviceLite, device lite on local or remote + remote_info: dict, includes + linux_host: str, nfs_server ip + linux_directory: str, the directory on the linux + is_remote: str, server is remote or not + case_type: str, CppTestLite or CTestLite, default value is + DeviceTestType.cpp_test_lite + + Returns: + True or False, represent init Failed or success + """ + if not remote_info: + raise ParamError("failed to get server environment", + error_no="00108") + + linux_host = remote_info.get("ip", "") + linux_directory = remote_info.get("dir", "") + is_remote = remote_info.get("remote", "false") + liteos_commands = ["cd /", "umount device_directory", + "mount nfs_ip:nfs_directory device" + "_directory nfs"] + linux_commands = ["cd /{}".format("storage"), + "umount -f /{}/{}".format("storage", "device_directory"), + "toybox mount -t nfs -o nolock,addr=nfs_ip nfs_ip:nfs_directory " + "/{}/{}".format("storage", "device_directory"), + "chmod 755 -R /{}/{}".format( + "storage", "device_directory")] + if not linux_host or not linux_directory: + raise LiteDeviceMountError( + "nfs server miss ip or directory[00108]", error_no="00108") + + commands = [] + if device.label == "ipcamera": + env_result, status, _ = device.execute_command_with_timeout( + command="uname", timeout=1, retry=2) + if status: + if env_result.find(DeviceLiteKernel.linux_kernel) != -1 or \ + env_result.find("Linux") != -1: + commands = linux_commands + device.__set_device_kernel__(DeviceLiteKernel.linux_kernel) + else: + commands = liteos_commands + device.__set_device_kernel__(DeviceLiteKernel.lite_kernel) + else: + raise LiteDeviceMountError("failed to get device env[00402]", + error_no="00402") + + for mount_file in self.mount_list: + target = mount_file.get("target", "/test_root") + if target in self.mounted_dir: + LOG.debug("%s is mounted" % target) + continue + mkdir_on_board(device, target) + + # local nfs server need use alias of dir to mount + if is_remote.lower() == "false": + linux_directory = get_mount_dir(linux_directory) + for command in commands: + command = command.replace("nfs_ip", linux_host). \ + replace("nfs_directory", linux_directory).replace( + "device_directory", target).replace("//", "/") + timeout = 15 if command.startswith("mount") else 1 + if command.startswith("mount"): + self.mounted_dir.add(target) + for mount_time in range(1, 4): + result, status, _ = device.\ + execute_command_with_timeout(command=command, + case_type=case_type, + timeout=timeout) + if status: + break + if "already mounted" in result: + LOG.info("{} is mounted".format(target)) + break + LOG.info("Mount failed,try " + "again {} time".format(mount_time)) + if mount_time == 3: + raise LiteDeviceMountError("Failed to mount the " + "device[00402]", + error_no="00402") + else: + result, status, _ = device.execute_command_with_timeout( + command=command, case_type=case_type, timeout=timeout) + LOG.info('Prepare environment success') + + def __setup__(self, device, **kwargs): + """ + Mount the file to the board by the nfs server. + """ + LOG.debug("Start mount kit setup") + + request = kwargs.get("request", None) + if not request: + raise ParamError("MountKit setup request is None", + error_no="02401") + device.connect() + + config_manager = UserConfigManager( + config_file=request.get(ConfigConst.configfile, ""), + env=request.get(ConfigConst.test_environment, "")) + remote_info = config_manager.get_user_config("testcases/server", + filter_name=self.server) + + copy_list = self.copy_to_server(remote_info.get("dir"), + remote_info.get("ip"), + request, request.config.testcases_path) + + self.mount_on_board(device=device, remote_info=remote_info, + case_type=DeviceTestType.cpp_test_lite) + + return copy_list + + def copy_to_server(self, linux_directory, linux_host, request, + testcases_dir): + file_local_paths = [] + for mount_file in self.mount_list: + source = mount_file.get("source") + if not source: + raise TypeError("The source of MountKit cant be empty " + "in Test.json!") + source = source.replace("$testcases/", "").\ + replace("$resources/", "") + file_path = get_file_absolute_path(source, self.paths) + if os.path.isdir(file_path): + for root, _, files in os.walk(file_path): + for _file in files: + if _file.endswith(".json"): + continue + file_local_paths.append(os.path.join(root, _file)) + else: + file_local_paths.append(file_path) + + config_manager = UserConfigManager( + config_file=request.get(ConfigConst.configfile, ""), + env=request.get(ConfigConst.test_environment, "")) + remote_info = config_manager.get_user_config("testcases/server", + filter_name=self.server) + self.remote_info = remote_info + + if not remote_info: + err_msg = "The name of remote device {} does not match". \ + format(self.remote) + LOG.error(err_msg, error_no="00403") + raise TypeError(err_msg) + is_remote = remote_info.get("remote", "false") + if (str(get_local_ip()) == linux_host) and ( + linux_directory == ("/data%s" % testcases_dir)): + return + ip = remote_info.get("ip", "") + port = remote_info.get("port", "") + remote_dir = remote_info.get("dir", "") + if not ip or not port or not remote_dir: + LOG.warning("Nfs server's ip or port or dir is empty") + return + for _file in file_local_paths: + # remote copy + LOG.info("Trying to copy the file from {} to nfs server". + format(_file)) + if not is_remote.lower() == "false": + try: + import paramiko + client = paramiko.Transport(ip, int(port)) + client.connect(username=remote_info.get("username"), + password=remote_info.get("password")) + sftp = paramiko.SFTPClient.from_transport(client) + sftp.put(localpath=_file, remotepath=os.path.join( + remote_info.get("dir"), os.path.basename(_file))) + client.close() + except (OSError, Exception) as exception: + msg = "copy file to nfs server failed with error {}" \ + .format(exception) + LOG.error(msg, error_no="00403") + # local copy + else: + for count in range(1, 4): + try: + os.remove(os.path.join(remote_info.get("dir"), + os.path.basename(_file))) + except OSError as _: + pass + shutil.copy(_file, remote_info.get("dir")) + if check_server_file(_file, remote_info.get("dir")): + break + else: + LOG.info( + "Trying to copy the file from {} to nfs " + "server {} times".format(_file, count)) + if count == 3: + msg = "Copy {} to nfs server " \ + "failed {} times".format( + os.path.basename(_file), count) + LOG.error(msg, error_no="00403") + LOG.debug("Nfs server:{}".format(glob.glob( + os.path.join(remote_info.get("dir"), '*.*')))) + + self.file_name_list.append(os.path.basename(_file)) + + return self.file_name_list + + def __teardown__(self, device): + if device.__get_device_kernel__() == DeviceLiteKernel.linux_kernel: + device.execute_command_with_timeout(command="cd /storage", + timeout=1) + for mounted_dir in self.mounted_dir: + device.execute_command_with_timeout(command="umount -f " + "/storage{}". + format(mounted_dir), + timeout=2) + device.execute_command_with_timeout(command="rm -r /storage{}". + format(mounted_dir), + timeout=1) + else: + device.execute_command_with_timeout(command="cd /", timeout=1) + for mounted_dir in self.mounted_dir: + for mount_time in range(1, 3): + result, status, _ = device.execute_command_with_timeout( + command="umount {}".format(mounted_dir), + timeout=2) + if result.find("Resource busy") == -1: + device.execute_command_with_timeout(command="rm -r {}". + format(mounted_dir) + , timeout=1) + if status: + break + LOG.info("Umount failed,try " + "again {} time".format(mount_time)) + time.sleep(1) + + +def copy_file_as_temp(original_file, str_length): + """ + To obtain a random string with specified length + Parameters: + original_file : the original file path + str_length: the length of random string + """ + if os.path.isfile(original_file): + random_str = random.sample(string.ascii_letters + string.digits, + str_length) + new_temp_tool_path = '{}_{}{}'.format( + os.path.splitext(original_file)[0], "".join(random_str), + os.path.splitext(original_file)[1]) + return shutil.copyfile(original_file, new_temp_tool_path) + + +def mkdir_on_board(device, dir_path): + """ + Liteos L1 board dont support mkdir -p + Parameters: + device : the L1 board + dir_path: the dir path to make + """ + if device.__get_device_kernel__() == DeviceLiteKernel.linux_kernel: + device.execute_command_with_timeout(command="cd /storage", timeout=1) + else: + device.execute_command_with_timeout(command="cd /", timeout=1) + for sub_dir in dir_path.split("/"): + if sub_dir in ["", "/"]: + continue + device.execute_command_with_timeout(command="mkdir {}".format(sub_dir), + timeout=1) + device.execute_command_with_timeout(command="cd {}".format(sub_dir), + timeout=1) + if device.__get_device_kernel__() == DeviceLiteKernel.linux_kernel: + device.execute_command_with_timeout(command="cd /storage", timeout=1) + else: + device.execute_command_with_timeout(command="cd /", timeout=1) + + +def get_mount_dir(mount_dir): + """ + Use windows path to mount directly when the system is windows + Parameters: + mount_dir : the dir to mount that config in user_config.xml + such as: the mount_dir is: D:\mount\root + the mount command should be: mount ip:/d/mount/root + """ + if platform.system() == "Windows": + mount_dir = mount_dir.replace(":", "").replace("\\", "/") + _list = mount_dir.split("/") + if mount_dir.startswith("/"): + _list[1] = _list[1].lower() + else: + _list[0] = _list[0].lower() + mount_dir = "/".join(_list) + mount_dir = "/%s" % mount_dir + return mount_dir + + +def check_server_file(local_file, target_path): + for file_list in glob.glob(os.path.join(target_path, '*.*')): + if os.path.basename(local_file) in file_list: + return True + return False + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.rootfs) +class RootFsKit(ITestKit): + def __init__(self): + self.checksum_command = None + self.hash_file_name = None + self.device_label = None + + def __check_config__(self, config): + self.checksum_command = get_config_value("command", config, + is_list=False) + self.hash_file_name = get_config_value("hash_file_name", config, + is_list=False) + self.device_label = get_config_value("device_label", config, + is_list=False) + if not self.checksum_command or not self.hash_file_name or \ + not self.device_label: + msg = "The config for rootfs kit is invalid : checksum :{}" \ + " hash file name:{} device label:{}" \ + .format(self.checksum_command, self.hash_file_name, + self.device_label) + LOG.error(msg, error_no="00108") + return TypeError(msg) + + def __setup__(self, device, **kwargs): + del kwargs + + # check device label + if not device.label == self.device_label: + LOG.error("Device label is not match '%s '" % "demo label", + error_no="00108") + return False + else: + report_path = self._get_report_dir() + if report_path and os.path.exists(report_path): + + # execute command of checksum + device.connect() + device.execute_command_with_timeout( + command="cd /", case_type=DeviceTestType.cpp_test_lite) + result, _, _ = device.execute_command_with_timeout( + command=self.checksum_command, + case_type=DeviceTestType.cpp_test_lite) + device.close() + # get serial from device and then join new file name + pos = self.hash_file_name.rfind(".") + serial = "_%s" % device.__get_serial__() + if pos > 0: + hash_file_name = "".join((self.hash_file_name[:pos], + serial, + self.hash_file_name[pos:])) + else: + hash_file_name = "".join((self.hash_file_name, serial)) + hash_file_path = os.path.join(report_path, hash_file_name) + # write result to file + hash_file_path_open = os.open(hash_file_path, os.O_WRONLY | + os.O_CREAT | os.O_APPEND, + FilePermission.mode_755) + + with os.fdopen(hash_file_path_open, mode="w") as hash_file: + hash_file.write(result) + hash_file.flush() + else: + msg = "RootFsKit teardown, log path [%s] not exists!" \ + % report_path + LOG.error(msg, error_no="00440") + return False + return True + + def __teardown__(self, device): + pass + + @staticmethod + def _get_report_dir(): + from xdevice import Variables + report_path = os.path.join(Variables.exec_dir, + Variables.report_vars.report_dir, + Variables.task_name) + return report_path + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.query) +class QueryKit(ITestKit): + def __init__(self): + self.mount_kit = MountKit() + self.query = "" + self.properties = "" + + def __check_config__(self, config): + setattr(self.mount_kit, "mount_list", + get_config_value('mount', config)) + setattr(self.mount_kit, "server", get_config_value( + 'server', config, is_list=False, default="NfsServer")) + self.query = get_config_value('query', config, is_list=False) + self.properties = get_config_value('properties', config, is_list=False) + + if not self.query: + msg = "The config for query kit is invalid with query:{}" \ + .format(self.query) + LOG.error(msg, error_no="00108") + raise TypeError(msg) + + def __setup__(self, device, **kwargs): + LOG.debug("Start query kit setup") + if device.label != DeviceLabelType.ipcamera: + return + request = kwargs.get("request", None) + if not request: + raise ParamError("the request of queryKit is None", + error_no="02401") + self.mount_kit.__setup__(device, request=request) + if device.__get_device_kernel__() == DeviceLiteKernel.linux_kernel: + device.execute_command_with_timeout(command="cd /storage", + timeout=0.2) + output, _, _ = device.execute_command_with_timeout( + command=".{}{}".format("/storage", self.query), timeout=5) + else: + device.execute_command_with_timeout(command="cd /", timeout=0.2) + output, _, _ = device.execute_command_with_timeout( + command=".{}".format(self.query), timeout=5) + product_info = {} + for line in output.split("\n"): + process_product_info(line, product_info) + product_info["version"] = get_test_component_version(request.config) + request.product_info = product_info + + def __teardown__(self, device): + if device.label != DeviceLabelType.ipcamera: + return + device.connect() + self.mount_kit.__teardown__(device) + device.close() + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.liteshell) +class LiteShellKit(ITestKit): + def __init__(self): + self.command_list = [] + self.tear_down_command = [] + self.paths = None + + def __check_config__(self, config): + self.command_list = get_config_value('run-command', config) + self.tear_down_command = get_config_value('teardown-command', config) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("LiteShellKit setup, device:{}".format(device.device_sn)) + if len(self.command_list) == 0: + LOG.info("No setup command to run, skipping!") + return + for command in self.command_list: + run_command(device, command) + + def __teardown__(self, device): + LOG.debug("LiteShellKit teardown: device:{}".format(device.device_sn)) + if len(self.tear_down_command) == 0: + LOG.info("No teardown command to run, skipping!") + return + for command in self.tear_down_command: + run_command(device, command) + + +def run_command(device, command): + LOG.debug("The command:{} is running".format(command)) + if command.strip() == "reset": + device.reboot() + else: + device.execute_shell_command(command) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.liteinstall) +class LiteAppInstallKit(ITestKit): + def __init__(self): + self.app_list = "" + self.is_clean = "" + self.alt_dir = "" + self.bundle_name = None + self.paths = "" + self.signature = False + + def __check_config__(self, options): + self.app_list = get_config_value('test-file-name', options) + self.is_clean = get_config_value('cleanup-apps', options, False) + self.signature = get_config_value('signature', options, False) + self.alt_dir = get_config_value('alt-dir', options, False) + if self.alt_dir and self.alt_dir.startswith("resource/"): + self.alt_dir = self.alt_dir[len("resource/"):] + self.paths = get_config_value('paths', options) + + def __setup__(self, device, **kwargs): + del kwargs + LOG.debug("LiteAppInstallKit setup, device:{}". + format(device.device_sn)) + if len(self.app_list) == 0: + LOG.info("No app to install, skipping!") + return + + for app in self.app_list: + if app.endswith(".hap"): + device.execute_command_with_timeout("cd /", timeout=1) + if self.signature: + device.execute_command_with_timeout( + command="./bin/bm set -d enable", timeout=10) + else: + device.execute_command_with_timeout( + command="./bin/bm set -s disable", timeout=10) + + device.execute_command_with_timeout( + "./bin/bm install -p %s" % app, timeout=60) + + def __teardown__(self, device): + LOG.debug("LiteAppInstallKit teardown: device:{}".format( + device.device_sn)) + if self.is_clean and str(self.is_clean).lower() == "true" \ + and self.bundle_name: + device.execute_command_with_timeout( + "./bin/bm uninstall -n %s" % self.bundle_name, timeout=90) + + +def process_product_info(message, product_info): + if "The" in message: + message = message[message.index("The"):] + items = message[len("The "):].split(" is ") + product_info.setdefault(items[0].strip(), + items[1].strip().strip("[").strip("]")) + + +@Plugin(type=Plugin.TEST_KIT, id=CKit.deploytool) +class DeployToolKit(ITestKit): + def __init__(self): + self.config = None + self.auto_deploy = None + self.device_label = None + self.time_out = None + self.paths = None + self.upgrade_file_path = None + self.burn_tools = None + + def __check_config__(self, config): + self.config = config + self.paths = get_config_value("paths", config) + self.burn_file = get_config_value("burn_file", config, is_list=False) + self.auto_deploy = get_config_value('auto_deploy', + config, is_list=False) + self.device_label = get_config_value("device_label", config, + is_list=False) + self.time_out = get_config_value("timeout", config, + is_list=False) + self.upgrade_file_path = get_config_value("upgrade_file_path", config, is_list=False) + self.burn_tools = get_config_value("burn_tools", config, is_list=False) + + if not self.auto_deploy or not self.upgrade_file_path or not self.time_out: + msg = "The config for deploy tool kit is" \ + "invalid: upgrade_file_path :{} time out:{}".format( + self.upgrade_file_path, self.time_out) + LOG.error(msg, error_no="00108") + return TypeError(msg) + + def __setup__(self, device, **kwargs): + LOG.info("Upgrade file path:{}".format(self.upgrade_file_path)) + upgrade_file_name = os.path.basename(self.upgrade_file_path) + if self.upgrade_file_path.startswith("resource"): + self.upgrade_file_path = get_file_absolute_path( + os.path.join("tools", upgrade_file_name), self.paths) + sys.path.insert(0, os.path.dirname(self.upgrade_file_path)) + serial_port = device.device.com_dict.get(ComType.deploy_com).serial_port + LOG.debug("Serial port:{}".format(serial_port)) + baud_rate = device.device.com_dict.get(ComType.deploy_com).baud_rate + usb_port = device.device.com_dict.get(ComType.cmd_com).usb_port + patch_file = get_file_absolute_path(self.burn_file, self.paths) + upgrade_name = upgrade_file_name.split(".py")[0] + import_cmd_str = "from {} import {} as upgrade_device".format( + upgrade_name, upgrade_name) + scope = {} + exec(import_cmd_str, scope) + upgrade_device = scope.get("upgrade_device", "None") + upgrade = upgrade_device(serial_port=serial_port, baud_rate=baud_rate, + patch_file=patch_file, usb_port=usb_port) + upgrade_result = upgrade.burn() + if upgrade_result: + return upgrade.reset_device() + return None + + def __teardown__(self, device): + pass diff --git a/xdevice/run.bat b/xdevice/run.bat new file mode 100644 index 0000000..d71da82 --- /dev/null +++ b/xdevice/run.bat @@ -0,0 +1,67 @@ +@rem Copyright (c) 2020-2021 Huawei Device Co., Ltd. +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem http://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. + +@echo off +set BASE_DIR=%~dp0 +set PYTHON=python +set TOOLS=tools +cd /d %BASE_DIR% + +(where %PYTHON% | findstr %PYTHON%) >nul 2>&1 || ( + @echo "Python3.7 or higher version required!" + pause + goto:eof +) + +python -c "import sys; exit(1) if sys.version_info.major < 3 or sys.version_info.minor < 7 else exit(0)" +@if errorlevel 1 ( + @echo "Python3.7 or higher version required!" + pause + goto:eof +) + +python -c "import pip" +@if errorlevel 1 ( + @echo "Please install pip first!" + pause + goto:eof +) + +python -c "import easy_install" +@if errorlevel 1 ( + @echo "Please install setuptools first!" + goto:eof +) + +if not exist %TOOLS% ( + @echo "no %TOOLS% directory exist" + goto:eof +) + +python -m pip uninstall -y xdevice +python -m pip uninstall -y xdevice-extension +python -m pip uninstall -y xdevice-ohos + +for %%a in (%TOOLS%/*.egg) do ( + python -m easy_install --user %TOOLS%/%%a + @if errorlevel 1 ( + @echo "Error occurs to install %%a!" + ) +) +for %%a in (%TOOLS%/*.tar.gz) do ( + python -m pip install --user %TOOLS%/%%a + @if errorlevel 1 ( + @echo "Error occurs to install %%a!" + ) +) +python -m xdevice %* diff --git a/xdevice/run.sh b/xdevice/run.sh new file mode 100644 index 0000000..26ea270 --- /dev/null +++ b/xdevice/run.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# +# Copyright (C) 2020-2021 Huawei Device Co., Ltd. +# 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. +# + +set -e + +error() +{ + echo "$1" + exit 1 +} +PYTHON="python3" +TOOLS_DIR="tools" + +flag=$(command -v $PYTHON | grep -c $PYTHON) +if [ "$flag" -eq 0 ]; then + error "Python3.7 or higher version required!" +fi + +$PYTHON -c 'import sys; exit(1) if sys.version_info.major < 3 or sys.version_info.minor < 7 else exit(0)' || \ +error "Python3.7 or higher version required!" +cd $(dirname "$0") || error "Failure to change direcory!" +$PYTHON -c "import pip" || error "Please install pip first!" +$PYTHON -c "import easy_install" || error "Please install setuptools first!" + +if [ ! -d "$TOOLS_DIR" ]; then + error "$TOOLS_DIR directory not exists" +fi + +$PYTHON -m pip uninstall -y xdevice +$PYTHON -m pip uninstall -y xdevice-extension +$PYTHON -m pip uninstall -y xdevice-ohos + +for f in "$TOOLS_DIR"/*.egg +do + if [ ! -e "$f" ]; then + error "Can not find xdevice package!" + fi + $PYTHON -m easy_install --user "$f" || echo "Error occurs to install $f!" +done + +for f in "$TOOLS_DIR"/*.tar.gz +do + if [ ! -e "$f" ]; then + error "Can not find xdevice package!" + fi + $PYTHON -m pip install --user "$f" || echo "Error occurs to install $f!" +done + +$PYTHON -m xdevice "$@" +exit 0 diff --git a/xdevice/setup.py b/xdevice/setup.py new file mode 100644 index 0000000..f7e4660 --- /dev/null +++ b/xdevice/setup.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from setuptools import setup + +INSTALL_REQUIRES = [] + + +def main(): + setup(name='xdevice', + description='xdevice test framework', + url='', + package_dir={'': 'src'}, + packages=['xdevice', + 'xdevice._core', + 'xdevice._core.command', + 'xdevice._core.config', + 'xdevice._core.driver', + 'xdevice._core.environment', + 'xdevice._core.executor', + 'xdevice._core.report', + 'xdevice._core.testkit' + ], + package_data={ + 'xdevice._core': [ + 'resource/*.txt', + 'resource/config/*.xml', + 'resource/template/*.html', + 'resource/tools/*' + ] + }, + entry_points={ + 'console_scripts': [ + 'xdevice=xdevice.__main__:main_process', + 'xdevice_report=xdevice._core.report.__main__:main_report' + ] + }, + zip_safe=False, + install_requires=INSTALL_REQUIRES, + ) + + +if __name__ == "__main__": + main() diff --git a/xdevice/src/xdevice.egg-info/PKG-INFO b/xdevice/src/xdevice.egg-info/PKG-INFO new file mode 100644 index 0000000..21e7039 --- /dev/null +++ b/xdevice/src/xdevice.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 1.0 +Name: xdevice +Version: 0.0.0 +Summary: xdevice test framework +Home-page: UNKNOWN +Author: UNKNOWN +Author-email: UNKNOWN +License: UNKNOWN +Description: UNKNOWN +Platform: UNKNOWN diff --git a/xdevice/src/xdevice.egg-info/SOURCES.txt b/xdevice/src/xdevice.egg-info/SOURCES.txt new file mode 100644 index 0000000..1d1a9f1 --- /dev/null +++ b/xdevice/src/xdevice.egg-info/SOURCES.txt @@ -0,0 +1,50 @@ +LICENSE +README.md +setup.py +src/xdevice/__init__.py +src/xdevice/__main__.py +src/xdevice/variables.py +src/xdevice.egg-info/PKG-INFO +src/xdevice.egg-info/SOURCES.txt +src/xdevice.egg-info/dependency_links.txt +src/xdevice.egg-info/entry_points.txt +src/xdevice.egg-info/not-zip-safe +src/xdevice.egg-info/top_level.txt +src/xdevice/_core/__init__.py +src/xdevice/_core/common.py +src/xdevice/_core/constants.py +src/xdevice/_core/exception.py +src/xdevice/_core/interface.py +src/xdevice/_core/logger.py +src/xdevice/_core/plugin.py +src/xdevice/_core/utils.py +src/xdevice/_core/command/__init__.py +src/xdevice/_core/command/console.py +src/xdevice/_core/config/__init__.py +src/xdevice/_core/config/config_manager.py +src/xdevice/_core/config/resource_manager.py +src/xdevice/_core/driver/__init__.py +src/xdevice/_core/driver/parser_lite.py +src/xdevice/_core/environment/__init__.py +src/xdevice/_core/environment/device_monitor.py +src/xdevice/_core/environment/device_state.py +src/xdevice/_core/environment/env_pool.py +src/xdevice/_core/environment/manager_env.py +src/xdevice/_core/executor/__init__.py +src/xdevice/_core/executor/concurrent.py +src/xdevice/_core/executor/listener.py +src/xdevice/_core/executor/request.py +src/xdevice/_core/executor/scheduler.py +src/xdevice/_core/executor/source.py +src/xdevice/_core/report/__init__.py +src/xdevice/_core/report/__main__.py +src/xdevice/_core/report/encrypt.py +src/xdevice/_core/report/reporter_helper.py +src/xdevice/_core/report/result_reporter.py +src/xdevice/_core/report/suite_reporter.py +src/xdevice/_core/resource/version.txt +src/xdevice/_core/resource/config/user_config.xml +src/xdevice/_core/resource/template/report.html +src/xdevice/_core/testkit/__init__.py +src/xdevice/_core/testkit/json_parser.py +src/xdevice/_core/testkit/kit.py \ No newline at end of file diff --git a/xdevice/src/xdevice.egg-info/dependency_links.txt b/xdevice/src/xdevice.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/xdevice/src/xdevice.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/xdevice/src/xdevice.egg-info/entry_points.txt b/xdevice/src/xdevice.egg-info/entry_points.txt new file mode 100644 index 0000000..73f55d9 --- /dev/null +++ b/xdevice/src/xdevice.egg-info/entry_points.txt @@ -0,0 +1,4 @@ +[console_scripts] +xdevice = xdevice.__main__:main_process +xdevice_report = xdevice._core.report.__main__:main_report + diff --git a/xdevice/src/xdevice.egg-info/not-zip-safe b/xdevice/src/xdevice.egg-info/not-zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/xdevice/src/xdevice.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/xdevice/src/xdevice.egg-info/top_level.txt b/xdevice/src/xdevice.egg-info/top_level.txt new file mode 100644 index 0000000..2887204 --- /dev/null +++ b/xdevice/src/xdevice.egg-info/top_level.txt @@ -0,0 +1 @@ +xdevice diff --git a/xdevice/src/xdevice/__init__.py b/xdevice/src/xdevice/__init__.py new file mode 100644 index 0000000..1a1660f --- /dev/null +++ b/xdevice/src/xdevice/__init__.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import pkg_resources + +from .variables import Variables +from _core.plugin import Plugin +from _core.plugin import get_plugin +from _core.logger import platform_logger +from _core.interface import IDriver +from _core.interface import IDevice +from _core.interface import IDeviceManager +from _core.interface import IParser +from _core.interface import LifeCycle +from _core.interface import IShellReceiver +from _core.interface import ITestKit +from _core.interface import IListener +from _core.interface import IReporter +from _core.interface import IFilter +from _core.exception import ParamError +from _core.exception import DeviceError +from _core.exception import LiteDeviceError +from _core.exception import ExecuteTerminate +from _core.exception import ReportException +from _core.exception import HdcError +from _core.exception import HdcCommandRejectedException +from _core.exception import ShellCommandUnresponsiveException +from _core.exception import DeviceUnresponsiveException +from _core.exception import AppInstallError +from _core.exception import HapNotSupportTest +from _core.constants import DeviceTestType +from _core.constants import DeviceLabelType +from _core.constants import ManagerType +from _core.constants import DeviceOsType +from _core.constants import ProductForm +from _core.constants import TestType +from _core.constants import CKit +from _core.constants import ConfigConst +from _core.constants import ReportConst +from _core.constants import ModeType +from _core.constants import TestExecType +from _core.constants import ListenerType +from _core.constants import GTestConst +from _core.constants import CommonParserType +from _core.constants import FilePermission +from _core.constants import HostDrivenTestType +from _core.constants import DeviceConnectorType +from _core.constants import AdvanceDeviceOption +from _core.constants import Platform +from _core.config.config_manager import UserConfigManager +from _core.config.resource_manager import ResourceManager +from _core.executor.listener import CaseResult +from _core.executor.listener import SuiteResult +from _core.executor.listener import SuitesResult +from _core.executor.listener import StateRecorder +from _core.executor.listener import TestDescription +from _core.executor.listener import CollectingTestListener +from _core.testkit.json_parser import JsonParser +from _core.testkit.kit import junit_para_parse +from _core.testkit.kit import gtest_para_parse +from _core.testkit.kit import reset_junit_para +from _core.testkit.kit import get_app_name_by_tool +from _core.testkit.kit import get_install_args +from _core.testkit.kit import remount +from _core.testkit.kit import disable_keyguard +from _core.testkit.kit import unlock_screen +from _core.testkit.kit import unlock_device +from _core.testkit.kit import get_class +from _core.driver.parser_lite import ShellHandler +from _core.report.encrypt import check_pub_key_exist +from _core.utils import get_file_absolute_path +from _core.utils import check_result_report +from _core.utils import get_device_log_file +from _core.utils import get_kit_instances +from _core.utils import get_config_value +from _core.utils import exec_cmd +from _core.utils import check_device_name +from _core.utils import do_module_kit_setup +from _core.utils import do_module_kit_teardown +from _core.utils import convert_serial +from _core.utils import convert_ip +from _core.utils import convert_port +from _core.utils import check_mode +from _core.utils import get_filename_extension +from _core.utils import get_test_component_version +from _core.utils import get_local_ip +from _core.utils import create_dir +from _core.utils import is_proc_running +from _core.utils import check_path_legal +from _core.utils import modify_props +from _core.utils import get_shell_handler +from _core.utils import get_decode +from _core.utils import get_cst_time +from _core.utils import get_delta_time_ms +from _core.utils import get_device_proc_pid +from _core.utils import start_standing_subprocess +from _core.utils import stop_standing_subprocess +from _core.logger import LogQueue +from _core.environment.manager_env import DeviceSelectionOption +from _core.environment.manager_env import EnvironmentManager +from _core.environment.env_pool import EnvPool +from _core.environment.env_pool import XMLNode +from _core.environment.env_pool import Selector +from _core.environment.env_pool import DeviceNode +from _core.environment.env_pool import DeviceSelector +from _core.environment.env_pool import is_env_pool_run_mode +from _core.environment.device_state import DeviceEvent +from _core.environment.device_state import TestDeviceState +from _core.environment.device_state import DeviceState +from _core.environment.device_state import \ + handle_allocation_event +from _core.environment.device_state import \ + DeviceAllocationState +from _core.environment.device_monitor import DeviceStateListener +from _core.environment.device_monitor import DeviceStateMonitor +from _core.executor.scheduler import Scheduler +from _core.report.suite_reporter import SuiteReporter +from _core.report.suite_reporter import ResultCode +from _core.report.reporter_helper import ExecInfo +from _core.report.result_reporter import ResultReporter +from _core.report.reporter_helper import DataHelper +from _core.report.__main__ import main_report +from _core.command.console import Console + +__all__ = [ + "Variables", + "Console", + "platform_logger", + "Plugin", + "get_plugin", + "IDriver", + "IDevice", + "IDeviceManager", + "IParser", + "IFilter", + "LifeCycle", + "IShellReceiver", + "ITestKit", + "IListener", + "IReporter", + "ParamError", + "DeviceError", + "LiteDeviceError", + "ExecuteTerminate", + "ReportException", + "HdcError", + "HdcCommandRejectedException", + "ShellCommandUnresponsiveException", + "DeviceUnresponsiveException", + "AppInstallError", + "HapNotSupportTest", + "DeviceTestType", + "DeviceLabelType", + "ManagerType", + "DeviceOsType", + "ProductForm", + "TestType", + "CKit", + "ConfigConst", + "ReportConst", + "ModeType", + "TestExecType", + "ListenerType", + "GTestConst", + "CommonParserType", + "FilePermission", + "HostDrivenTestType", + "DeviceConnectorType", + "AdvanceDeviceOption", + "Platform", + "UserConfigManager", + "ResourceManager", + "CaseResult", + "SuiteResult", + "SuitesResult", + "StateRecorder", + "TestDescription", + "CollectingTestListener", + "Scheduler", + "SuiteReporter", + "DeviceSelectionOption", + "EnvironmentManager", + "EnvPool", + "XMLNode", + "Selector", + "DeviceNode", + "DeviceSelector", + "is_env_pool_run_mode", + "DeviceEvent", + "TestDeviceState", + "DeviceState", + "handle_allocation_event", + "DeviceAllocationState", + "DeviceStateListener", + "DeviceStateMonitor", + "JsonParser", + "junit_para_parse", + "gtest_para_parse", + "reset_junit_para", + "get_app_name_by_tool", + "get_install_args", + "remount", + "disable_keyguard", + "unlock_screen", + "unlock_device", + "get_class", + "ShellHandler", + "ResultCode", + "check_pub_key_exist", + "check_result_report", + "get_file_absolute_path", + "get_device_log_file", + "get_kit_instances", + "get_config_value", + "exec_cmd", + "check_device_name", + "do_module_kit_setup", + "do_module_kit_teardown", + "convert_serial", + "convert_ip", + "convert_port", + "check_mode", + "get_filename_extension", + "get_test_component_version", + "get_local_ip", + "create_dir", + "is_proc_running", + "check_path_legal", + "modify_props", + "get_shell_handler", + "get_decode", + "get_cst_time", + "get_delta_time_ms", + "get_device_proc_pid", + "start_standing_subprocess", + "stop_standing_subprocess", + "ExecInfo", + "ResultReporter", + "DataHelper", + "main_report", + "LogQueue" +] + + +def _load_external_plugins(): + plugins = [Plugin.SCHEDULER, Plugin.DRIVER, Plugin.DEVICE, Plugin.LOG, + Plugin.PARSER, Plugin.LISTENER, Plugin.TEST_KIT, Plugin.MANAGER, + Plugin.REPORTER] + for plugin_group in plugins: + for entry_point in pkg_resources.iter_entry_points(group=plugin_group): + entry_point.load() + return + + +_load_external_plugins() +del _load_external_plugins diff --git a/xdevice/src/xdevice/__main__.py b/xdevice/src/xdevice/__main__.py new file mode 100644 index 0000000..e152b01 --- /dev/null +++ b/xdevice/src/xdevice/__main__.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# +import os +import sys +from xdevice import Console +from xdevice import platform_logger +from _core.utils import get_version + + +srcpath = os.path.dirname(os.path.dirname(__file__)) +sys.path.append(srcpath) + +LOG = platform_logger("Main") + + +def main_process(command=None): + LOG.info( + "*************** xDevice Test Framework %s Starting ***************" % + get_version()) + if command: + args = str(command).split(" ") + args.insert(0, "xDevice") + else: + args = sys.argv + console = Console() + console.console(args) + return + + +if __name__ == "__main__": + main_process() diff --git a/xdevice/src/xdevice/_core/__init__.py b/xdevice/src/xdevice/_core/__init__.py new file mode 100644 index 0000000..f1b275b --- /dev/null +++ b/xdevice/src/xdevice/_core/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# diff --git a/xdevice/src/xdevice/_core/command/__init__.py b/xdevice/src/xdevice/_core/command/__init__.py new file mode 100644 index 0000000..f1b275b --- /dev/null +++ b/xdevice/src/xdevice/_core/command/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# diff --git a/xdevice/src/xdevice/_core/command/console.py b/xdevice/src/xdevice/_core/command/console.py new file mode 100644 index 0000000..c71528c --- /dev/null +++ b/xdevice/src/xdevice/_core/command/console.py @@ -0,0 +1,925 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import argparse +import os +import platform +import signal +import sys +import threading +import copy +from collections import namedtuple + +from _core.config.config_manager import UserConfigManager +from _core.constants import SchedulerType +from _core.constants import ConfigConst +from _core.constants import ReportConst +from _core.constants import ModeType +from _core.constants import ToolCommandType +from _core.environment.manager_env import EnvironmentManager +from _core.exception import ParamError +from _core.exception import ExecuteTerminate +from _core.executor.request import Task +from _core.executor.scheduler import Scheduler +from _core.logger import platform_logger +from _core.plugin import Plugin +from _core.plugin import get_plugin +from _core.utils import SplicingAction +from _core.utils import get_instance_name +from _core.utils import is_python_satisfied +from _core.report.result_reporter import ResultReporter + +__all__ = ["Console"] + +LOG = platform_logger("Console") +try: + if platform.system() != 'Windows': + import readline +except (ModuleNotFoundError, ImportError): # pylint:disable=undefined-variable + LOG.warning("Readline module is not exist.") + +MAX_VISIBLE_LENGTH = 49 +MAX_RESERVED_LENGTH = 46 +Argument = namedtuple('Argument', 'options unparsed valid_param parser') + + +class Console(object): + """ + Class representing an console for executing test. + Main xDevice console providing user with the interface to interact + """ + __instance = None + + def __new__(cls, *args, **kwargs): + """ + Singleton instance + """ + if cls.__instance is None: + cls.__instance = super(Console, cls).__new__(cls, *args, **kwargs) + return cls.__instance + + def __init__(self): + pass + + @classmethod + def handler_terminate_signal(cls, signalnum, frame): + # ctrl+c + del signalnum, frame + if not Scheduler.is_execute: + return + LOG.info("Get terminate input") + terminate_thread = threading.Thread( + target=Scheduler.terminate_cmd_exec) + terminate_thread.setDaemon(True) + terminate_thread.start() + + def console(self, args): + """ + Main xDevice console providing user with the interface to interact + """ + if not is_python_satisfied(): + sys.exit(0) + + if args is None or len(args) < 2: + # init environment manager + EnvironmentManager() + # Enter xDevice console + self._console() + else: + # init environment manager + EnvironmentManager() + # Enter xDevice command parser + self.command_parser(" ".join(args[1:])) + + def _console(self): + # Enter xDevice console + signal.signal(signal.SIGINT, self.handler_terminate_signal) + + while True: + try: + usr_input = input(">>> ") + if usr_input == "": + continue + + self.command_parser(usr_input) + + except SystemExit as _: + LOG.info("Program exit normally!") + break + except ExecuteTerminate as _: + LOG.info("Execution terminated") + except (IOError, EOFError, KeyboardInterrupt) as error: + LOG.exception("Input Error: {}".format(error), + exc_info=False) + + def argument_parser(self, para_list): + """ + Argument parser + """ + options = None + unparsed = [] + valid_param = True + parser = None + + try: + parser = argparse.ArgumentParser( + description="Specify tests to run.") + group = parser.add_mutually_exclusive_group() + parser.add_argument("action", + type=str.lower, + help="Specify action") + parser.add_argument("task", + type=str, + default=None, + help="Specify task name") + group.add_argument("-l", "--testlist", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.testlist, + default="", + help="Specify test list" + ) + group.add_argument("-tf", "--testfile", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.testfile, + default="", + help="Specify test list file" + ) + parser.add_argument("-tc", "--testcase", + action="store", + type=str, + dest=ConfigConst.testcase, + default="", + help="Specify test case" + ) + parser.add_argument("-c", "--config", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.configfile, + default="", + help="Specify config file path" + ) + parser.add_argument("-sn", "--device_sn", + action="store", + type=str, + dest=ConfigConst.device_sn, + default="", + help="Specify device serial number" + ) + parser.add_argument("-rp", "--reportpath", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.report_path, + default="", + help="Specify test report path" + ) + parser.add_argument("-respath", "--resourcepath", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.resource_path, + default="", + help="Specify test resource path" + ) + parser.add_argument("-tcpath", "--testcasespath", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.testcases_path, + default="", + help="Specify testcases path" + ) + parser.add_argument("-ta", "--testargs", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.testargs, + default={}, + help="Specify test arguments" + ) + parser.add_argument("-pt", "--passthrough", + action="store_true", + dest=ConfigConst.pass_through, + help="Pass through test arguments" + ) + parser.add_argument("-env", "--environment", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.test_environment, + default="", + help="Specify test environment" + ) + parser.add_argument("-e", "--exectype", + action="store", + type=str, + dest=ConfigConst.exectype, + default="device", + help="Specify test execute type" + ) + parser.add_argument("-t", "--testtype", + nargs='*', + dest=ConfigConst.testtype, + default=[], + help="Specify test type" + + "(UT,MST,ST,PERF,SEC,RELI,DST,ALL)" + ) + parser.add_argument("-td", "--testdriver", + action="store", + type=str, + dest=ConfigConst.testdriver, + default="", + help="Specify test driver id" + ) + parser.add_argument("-tl", "--testlevel", + action="store", + type=str, + dest="testlevel", + default="", + help="Specify test level" + ) + parser.add_argument("-bv", "--build_variant", + action="store", + type=str, + dest="build_variant", + default="release", + help="Specify build variant(release,debug)" + ) + parser.add_argument("-cov", "--coverage", + action="store", + type=str, + dest="coverage", + default="", + help="Specify coverage" + ) + parser.add_argument("--retry", + action="store", + type=str, + dest=ConfigConst.retry, + default="", + help="Specify retry command" + ) + parser.add_argument("--session", + action=SplicingAction, + type=str, + nargs='+', + dest=ConfigConst.session, + help="retry task by session id") + parser.add_argument("--dryrun", + action="store_true", + dest=ConfigConst.dry_run, + help="show retry test case list") + parser.add_argument("--reboot-per-module", + action="store_true", + dest=ConfigConst.reboot_per_module, + help="reboot devices before executing each " + "module") + parser.add_argument("--check-device", + action="store_true", + dest=ConfigConst.check_device, + help="check the test device meets the " + "requirements") + parser.add_argument("--repeat", + type=int, + default=0, + dest=ConfigConst.repeat, + help="number of times that a task is executed" + " repeatedly") + parser.add_argument("-le", "--local_execution_log_path", + dest="local_execution_log_path", + help="- The local execution log path.") + parser.add_argument("-s", "--subsystem", + dest="subsystems", + action="store", + type=str, + help="- Specify the list of subsystem") + parser.add_argument("-p", "--part", + dest="parts", + action="store", + type=str, + help="- Specify the list of part") + parser.add_argument("-kim", "--kits_in_module", + dest=ConfigConst.kits_in_module, + action=SplicingAction, + type=str, + nargs='+', + default="", + help="- kits that are used for specify module") + parser.add_argument("--kp", "--kits_params", + dest=ConfigConst.kits_params, + action=SplicingAction, + type=str, + nargs='+', + default="", + help="- the params of kits that related to module") + parser.add_argument("--auto_retry", + dest=ConfigConst.auto_retry, + type=int, + default=0, + help="- the count of auto retry") + self._params_pre_processing(para_list) + (options, unparsed) = parser.parse_known_args(para_list) + if unparsed: + LOG.warning("Unparsed input: %s", " ".join(unparsed)) + self._params_post_processing(options) + + except SystemExit as _: + valid_param = False + parser.print_help() + LOG.warning("Parameter parsing system exit exception.") + return Argument(options, unparsed, valid_param, parser) + + @classmethod + def _params_pre_processing(cls, para_list): + if len(para_list) <= 1 or ( + len(para_list) > 1 and "-" in str(para_list[1])): + para_list.insert(1, Task.EMPTY_TASK) + for index, param in enumerate(para_list): + if param == "--retry": + if index + 1 == len(para_list): + para_list.append("retry_previous_command") + elif "-" in str(para_list[index + 1]): + para_list.insert(index + 1, "retry_previous_command") + elif param == "-->": + para_list[index] = "!%s" % param + + def _params_post_processing(self, options): + # params post-processing + if options.task == Task.EMPTY_TASK: + setattr(options, ConfigConst.task, "") + if options.testargs: + if not options.pass_through: + test_args = self._parse_combination_param(options.testargs) + setattr(options, ConfigConst.testargs, test_args) + else: + setattr(options, ConfigConst.testargs, { + ConfigConst.pass_through: options.testargs}) + if not options.resource_path: + resource_path = UserConfigManager( + config_file=options.config, env=options.test_environment).\ + get_resource_path() + setattr(options, ConfigConst.resource_path, resource_path) + if not options.testcases_path: + testcases_path = UserConfigManager( + config_file=options.config, env=options.test_environment).\ + get_testcases_dir() + setattr(options, ConfigConst.testcases_path, testcases_path) + if options.testcases_path: + if Scheduler.task_type in ["ets", "hits"]: + testcases_path = "".join((options.testcases_path, + "/special/android-ets/testcases")) + setattr(options, "testcases_path", testcases_path) + device_log_dict = UserConfigManager( + config_file=options.config, env=options.test_environment). \ + get_device_log_status() + setattr(options, ConfigConst.device_log, device_log_dict) + if options.subsystems: + subsystem_list = str(options.subsystems).split(";") + setattr(options, ConfigConst.subsystems, subsystem_list) + if options.parts: + part_list = str(options.parts).split(";") + setattr(options, ConfigConst.parts, part_list) + + def command_parser(self, args): + try: + Scheduler.command_queue.append(args) + LOG.info("Input command: {}".format(args)) + para_list = args.split() + argument = self.argument_parser(para_list) + options = argument.options + if options is None or not argument.valid_param: + LOG.warning("Options is None.") + return None + if options.action == ToolCommandType.toolcmd_key_run and \ + options.retry: + if hasattr(options, ConfigConst.auto_retry): + setattr(options, ConfigConst.auto_retry, 0) + options = self._get_retry_options(options, argument.parser) + if options.dry_run: + history_report_path = getattr(options, + "history_report_path", "") + self._list_retry_case(history_report_path) + return + else: + from xdevice import SuiteReporter + SuiteReporter.clear_failed_case_list() + SuiteReporter.clear_report_result() + + command = options.action + if command == "": + LOG.info("Command is empty.") + return + + self._process_command(command, options, para_list, argument.parser) + except (ParamError, ValueError, TypeError, SyntaxError, + AttributeError) as exception: + error_no = getattr(exception, "error_no", "00000") + LOG.exception("%s: %s" % (get_instance_name(exception), exception), + exc_info=False, error_no=error_no) + if Scheduler.upload_address: + Scheduler.upload_unavailable_result(str(exception.args)) + Scheduler.upload_report_end() + finally: + if isinstance(Scheduler.command_queue[-1], str): + Scheduler.command_queue.pop() + + def _process_command(self, command, options, para_list, parser): + if command.startswith(ToolCommandType.toolcmd_key_help): + self._process_command_help(parser, para_list) + elif command.startswith(ToolCommandType.toolcmd_key_show): + self._process_command_show(para_list) + elif command.startswith(ToolCommandType.toolcmd_key_run): + self._process_command_run(command, options) + elif command.startswith(ToolCommandType.toolcmd_key_quit): + self._process_command_quit(command) + elif command.startswith(ToolCommandType.toolcmd_key_list): + self._process_command_list(command, para_list) + elif command.startswith(ToolCommandType.toolcmd_key_tool): + self._process_command_tool(command, para_list, options) + else: + LOG.error("Unsupported command action", error_no="00100", + action=command) + + def _get_retry_options(self, options, parser): + input_options = copy.deepcopy(options) + # get history command, history report path + history_command, history_report_path = self._parse_retry_option( + options) + LOG.info("History command: %s", history_command) + if not os.path.exists(history_report_path) and \ + Scheduler.mode != ModeType.decc: + raise ParamError( + "history report path %s not exists" % history_report_path) + + # parse history command, set history report path + is_dry_run = True if options.dry_run else False + + history_command = self._wash_history_command(history_command) + + argument = self.argument_parser(history_command.split()) + argument.options.dry_run = is_dry_run + setattr(argument.options, "history_report_path", history_report_path) + # modify history_command -rp param and -sn param + for option_tuple in self._get_to_be_replaced_option(parser): + history_command = self._replace_history_option( + history_command, (input_options, argument.options), + option_tuple) + + # add history command to Scheduler.command_queue + LOG.info("Retry command: %s", history_command) + Scheduler.command_queue[-1] = history_command + return argument.options + + @classmethod + def _process_command_help(cls, parser, para_list): + if para_list[0] == ToolCommandType.toolcmd_key_help: + if len(para_list) == 2: + cls.display_help_command_info(para_list[1]) + else: + parser.print_help() + else: + LOG.error("Wrong help command. Use 'help' to print help") + return + + @classmethod + def _process_command_show(cls, para_list): + if para_list[0] == ToolCommandType.toolcmd_key_show: + pass + else: + LOG.error("Wrong show command.") + return + + @classmethod + def _process_command_run(cls, command, options): + + scheduler = get_plugin(plugin_type=Plugin.SCHEDULER, + plugin_id=SchedulerType.scheduler)[0] + if scheduler is None: + LOG.error("Can not find the scheduler plugin.") + else: + scheduler.exec_command(command, options) + + return + + def _process_command_list(self, command, para_list): + if command != ToolCommandType.toolcmd_key_list: + LOG.error("Wrong list command.") + return + if len(para_list) > 1: + if para_list[1] == "history": + self._list_history() + elif para_list[1] == "devices" or para_list[1] == Task.EMPTY_TASK: + env_manager = EnvironmentManager() + env_manager.list_devices() + else: + self._list_task_id(para_list[1]) + return + # list devices + env_manager = EnvironmentManager() + env_manager.list_devices() + return + + @classmethod + def _process_command_quit(cls, command): + if command == ToolCommandType.toolcmd_key_quit: + env_manager = EnvironmentManager() + env_manager.env_stop() + sys.exit(0) + else: + LOG.error("Wrong exit command. Use 'quit' to quit program") + return + + def _process_command_tool(cls, command, para_list, options): + if not command.startswith(ToolCommandType.toolcmd_key_tool): + LOG.error("Wrong tool command.") + return + if len(para_list) > 2: + if para_list[1] == ConfigConst.renew_report: + if options.report_path: + report_list = str(options.report_path).split(";") + cls._renew_report(report_list) + + @staticmethod + def _parse_combination_param(combination_value): + # sample: size:xxx1;exclude-annotation:xxx + parse_result = {} + key_value_pairs = str(combination_value).split(";") + for key_value_pair in key_value_pairs: + key, value = key_value_pair.split(":", 1) + if not value: + raise ParamError("'%s' no value" % key) + value_list = str(value).split(",") + exist_list = parse_result.get(key, []) + exist_list.extend(value_list) + parse_result[key] = exist_list + return parse_result + + @classmethod + def _list_history(cls): + print("Command history:") + print("{0:<16}{1:<50}{2:<50}".format( + "TaskId", "Command", "ReportPath")) + for command_info in Scheduler.command_queue[:-1]: + command, report_path = command_info[1], command_info[2] + if len(command) > MAX_VISIBLE_LENGTH: + command = "%s..." % command[:MAX_RESERVED_LENGTH] + if len(report_path) > MAX_VISIBLE_LENGTH: + report_path = "%s..." % report_path[:MAX_RESERVED_LENGTH] + print("{0:<16}{1:<50}{2:<50}".format( + command_info[0], command, report_path)) + + @classmethod + def _list_task_id(cls, task_id): + print("List task:") + task_id, command, report_path = task_id, "", "" + for command_info in Scheduler.command_queue[:-1]: + if command_info[0] != task_id: + continue + task_id, command, report_path = command_info + break + print("{0:<16}{1:<100}".format("TaskId:", task_id)) + print("{0:<16}{1:<100}".format("Command:", command)) + print("{0:<16}{1:<100}".format("ReportPath:", report_path)) + + @classmethod + def _list_retry_case(cls, history_path): + params = ResultReporter.get_task_info_params(history_path) + if not params: + raise ParamError("no retry case exists") + session_id, command, report_path, failed_list = \ + params[ReportConst.session_id], params[ReportConst.command], \ + params[ReportConst.report_path], \ + [(module, failed) for module, case_list in params[ReportConst.unsuccessful_params].items() + for failed in case_list] + if Scheduler.mode == ModeType.decc: + from xdevice import SuiteReporter + SuiteReporter.failed_case_list = failed_list + return + + # draw tables in console + left, middle, right = 23, 49, 49 + two_segments = "{0:-<%s}{1:-<%s}+" % (left, middle + right) + two_rows = "|{0:^%s}|{1:^%s}|" % (left - 1, middle + right - 1) + + three_segments = "{0:-<%s}{1:-<%s}{2:-<%s}+" % (left, middle, right) + three_rows = "|{0:^%s}|{1:^%s}|{2:^%s}|" % \ + (left - 1, middle - 1, right - 1) + if len(session_id) > middle + right - 1: + session_id = "%s..." % session_id[:middle + right - 4] + if len(command) > middle + right - 1: + command = "%s..." % command[:middle + right - 4] + if len(report_path) > middle + right - 1: + report_path = "%s..." % report_path[:middle + right - 4] + + print(two_segments.format("+", '+')) + print(two_rows.format("SessionId", session_id)) + print(two_rows.format("Command", command)) + print(two_rows.format("ReportPath", report_path)) + + print(three_segments.format("+", '+', '+')) + print(three_rows.format("Module", "Testsuite", "Testcase")) + print(three_segments.format("+", '+', '+')) + for module, failed in failed_list: + # all module is failed + if "#" not in failed: + class_name = "-" + test = "-" + # others, get failed cases info + else: + pos = failed.rfind("#") + class_name = failed[:pos] + test = failed[pos + 1:] + if len(module) > left - 1: + module = "%s..." % module[:left - 4] + if len(class_name) > middle - 1: + class_name = "%s..." % class_name[:middle - 4] + if len(test) > right - 1: + test = "%s..." % test[:right - 4] + print(three_rows.format(module, class_name, test)) + print(three_segments.format("+", '+', '+')) + + @classmethod + def _find_history_path(cls, session): + from xdevice import Variables + if os.path.isdir(session): + return session + + target_path = os.path.join( + Variables.exec_dir, Variables.report_vars.report_dir, session) + if not os.path.isdir(target_path): + raise ParamError("session '%s' is invalid!" % session) + + return target_path + + def _parse_retry_option(self, options): + if Scheduler.mode == ModeType.decc: + if len(Scheduler.command_queue) < 2: + raise ParamError("no previous command executed") + _, history_command, history_report_path = \ + Scheduler.command_queue[-2] + return history_command, history_report_path + + # get history_command, history_report_path + if options.retry == "retry_previous_command": + from xdevice import Variables + history_path = os.path.join(Variables.temp_dir, "latest") + if options.session: + history_path = self._find_history_path(options.session) + + params = ResultReporter.get_task_info_params(history_path) + if not params: + error_msg = "no previous command executed" if not \ + options.session else "'%s' has no command executed" % \ + options.session + raise ParamError(error_msg) + history_command, history_report_path = params[ReportConst.command], params[ReportConst.report_path] + else: + history_command, history_report_path = "", "" + for command_tuple in Scheduler.command_queue[:-1]: + if command_tuple[0] != options.retry: + continue + history_command, history_report_path = \ + command_tuple[1], command_tuple[2] + break + if not history_command: + raise ParamError("wrong task id input: %s" % options.retry) + return history_command, history_report_path + + @classmethod + def display_help_command_info(cls, command): + if command == ToolCommandType.toolcmd_key_run: + print(RUN_INFORMATION) + elif command == ToolCommandType.toolcmd_key_list: + print(LIST_INFORMATION) + elif command == "empty": + print(GUIDE_INFORMATION) + else: + print("'%s' command no help information." % command) + + @classmethod + def _replace_history_option(cls, history_command, options_tuple, + target_option_tuple): + input_options, history_options = options_tuple + option_name, short_option_str, full_option_str = target_option_tuple + history_value = getattr(history_options, option_name, "") + input_value = getattr(input_options, option_name, "") + if history_value: + if input_value: + history_command = history_command.replace(history_value, + input_value) + setattr(history_options, option_name, input_value) + else: + history_command = str(history_command).replace( + history_value, "").replace(full_option_str, "").\ + replace(short_option_str, "") + else: + if input_value: + history_command = "{}{}".format( + history_command, " %s %s" % (short_option_str, + input_value)) + setattr(history_options, option_name, input_value) + + return history_command.strip() + + @classmethod + def _get_to_be_replaced_option(cls, parser): + name_list = ["report_path", "device_sn"] + option_str_list = list() + action_list = getattr(parser, "_actions", []) + if action_list: + for action in action_list: + if action.dest not in name_list: + continue + option_str_list.append((action.dest, action.option_strings[0], + action.option_strings[1])) + else: + option_str_list = [("report_path", "-rp", "--reportpath"), + ("device_sn", "-sn", "--device_sn")] + return option_str_list + + @classmethod + def _renew_report(cls, report_list): + from _core.report.__main__ import main_report + for report in report_list: + run_command = Scheduler.command_queue.pop() + Scheduler.command_queue.append(("", run_command, report)) + sys.argv.insert(1, report) + main_report() + sys.argv.pop(1) + + @classmethod + def _wash_history_command(cls, history_command): + # clear redundant content in history command. e.g. repeat,sn + if "--repeat" in history_command or "-sn" in history_command\ + or "--auto_retry" in history_command: + split_list = list(history_command.split()) + if "--repeat" in split_list: + pos = split_list.index("--repeat") + split_list = split_list[:pos] + split_list[pos + 2:] + if "-sn" in split_list: + pos = split_list.index("-sn") + split_list = split_list[:pos] + split_list[pos + 2:] + if "--auto_retry" in split_list: + pos = split_list.index("--auto_retry") + split_list = split_list[:pos] + split_list[pos + 2:] + return " ".join(split_list) + else: + return history_command + + +RUN_INFORMATION = """run: + This command is used to execute the selected testcases. + It includes a series of processes such as use case compilation, \ +execution, and result collection. + +usage: run [-l TESTLIST [TESTLIST ...] | -tf TESTFILE + [TESTFILE ...]] [-tc TESTCASE] [-c CONFIG] [-sn DEVICE_SN] + [-rp REPORT_PATH [REPORT_PATH ...]] + [-respath RESOURCE_PATH [RESOURCE_PATH ...]] + [-tcpath TESTCASES_PATH [TESTCASES_PATH ...]] + [-ta TESTARGS [TESTARGS ...]] [-pt] + [-env TEST_ENVIRONMENT [TEST_ENVIRONMENT ...]] + [-e EXECTYPE] [-t [TESTTYPE [TESTTYPE ...]]] + [-td TESTDRIVER] [-tl TESTLEVEL] [-bv BUILD_VARIANT] + [-cov COVERAGE] [--retry RETRY] [--session SESSION] + [--dryrun] [--reboot-per-module] [--check-device] + [--repeat REPEAT] + action task + +Specify tests to run. + +positional arguments: + action Specify action + task Specify task name,such as "ssts", "acts", "hits" + +optional arguments: + -h, --help show this help message and exit + -l TESTLIST [TESTLIST ...], --testlist TESTLIST [TESTLIST ...] + Specify test list + -tf TESTFILE [TESTFILE ...], --testfile TESTFILE [TESTFILE ...] + Specify test list file + -tc TESTCASE, --testcase TESTCASE + Specify test case + -c CONFIG, --config CONFIG + Specify config file path + -sn DEVICE_SN, --device_sn DEVICE_SN + Specify device serial number + -rp REPORT_PATH [REPORT_PATH ...], --reportpath REPORT_PATH [REPORT_PATH \ +...] + Specify test report path + -respath RESOURCE_PATH [RESOURCE_PATH ...], --resourcepath RESOURCE_PATH \ +[RESOURCE_PATH ...] + Specify test resource path + -tcpath TESTCASES_PATH [TESTCASES_PATH ...], --testcasespath \ +TESTCASES_PATH [TESTCASES_PATH ...] + Specify testcases path + -ta TESTARGS [TESTARGS ...], --testargs TESTARGS [TESTARGS ...] + Specify test arguments + -pt, --passthrough Pass through test arguments + -env TEST_ENVIRONMENT [TEST_ENVIRONMENT ...], --environment \ +TEST_ENVIRONMENT [TEST_ENVIRONMENT ...] + Specify test environment + -e EXECTYPE, --exectype EXECTYPE + Specify test execute type + -t [TESTTYPE [TESTTYPE ...]], --testtype [TESTTYPE [TESTTYPE ...]] + Specify test type(UT,MST,ST,PERF,SEC,RELI,DST,ALL) + -td TESTDRIVER, --testdriver TESTDRIVER + Specify test driver id + -tl TESTLEVEL, --testlevel TESTLEVEL + Specify test level + -bv BUILD_VARIANT, --build_variant BUILD_VARIANT + Specify build variant(release,debug) + -cov COVERAGE, --coverage COVERAGE + Specify coverage + --retry RETRY Specify retry command + --session SESSION retry task by session id + --dryrun show retry test case list + --reboot-per-module reboot devices before executing each module + --check-device check the test device meets the requirements + --repeat REPEAT number of times that a task is executed repeatedly + +Examples: + run -l ; + run -tf test/resource/.txt + + run –l -sn ; + run –l -respath + run –l -ta size:large + run –l –ta class:## + run –l -ta size:large -pt + run –l –env + run –l –e device + run –l –t ALL + run –l –td CppTest + run –l -tcpath resource/testcases + + run ssts + run ssts –tc ; + run ssts -sn ; + run ssts -respath + ... ... + + run acts + run acts –tc ; + run acts -sn ; + run acts -respath + ... ... + + run hits + ... ... + + run --retry + run --retry --session + run --retry --dryrun +""" + +LIST_INFORMATION = "list:" + """ + This command is used to display device list and task record.\n +usage: + list + list history + list + +Introduction: + list: display device list + list history: display history record of a serial of tasks + list : display history record about task what contains specific id + +Examples: + list + list history + list 6e****90 +""" + + +GUIDE_INFORMATION = """help: + use help to get information. + +usage: + run: Display a list of supported run command. + list: Display a list of supported device and task record. + +Examples: + help run + help list +""" diff --git a/xdevice/src/xdevice/_core/common.py b/xdevice/src/xdevice/_core/common.py new file mode 100644 index 0000000..6621161 --- /dev/null +++ b/xdevice/src/xdevice/_core/common.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os + +__all__ = ["get_source_code_rootpath"] + + +def is_source_code_rootpath(path): + check_name_list = ["build.sh", "base", "build"] + for item in check_name_list: + check_path = os.path.join(path, item) + if not os.path.exists(check_path): + return False + return True + + +def get_source_code_rootpath(path): + source_code_rootpath = path + while True: + if source_code_rootpath == "": + break + if source_code_rootpath == "/" or source_code_rootpath.endswith(":\\"): + source_code_rootpath = "" + break + if is_source_code_rootpath(source_code_rootpath): + break + source_code_rootpath = os.path.dirname(source_code_rootpath) + return source_code_rootpath diff --git a/xdevice/src/xdevice/_core/config/__init__.py b/xdevice/src/xdevice/_core/config/__init__.py new file mode 100644 index 0000000..f1b275b --- /dev/null +++ b/xdevice/src/xdevice/_core/config/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# diff --git a/xdevice/src/xdevice/_core/config/config_manager.py b/xdevice/src/xdevice/_core/config/config_manager.py new file mode 100644 index 0000000..1f85510 --- /dev/null +++ b/xdevice/src/xdevice/_core/config/config_manager.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import xml.etree.ElementTree as ET +from dataclasses import dataclass + +from _core.exception import ParamError +from _core.logger import platform_logger +from _core.utils import get_local_ip +from _core.constants import ConfigConst + +__all__ = ["UserConfigManager"] +LOG = platform_logger("ConfigManager") + + +@dataclass +class ConfigFileConst(object): + userconfig_filepath = "user_config.xml" + + +class UserConfigManager(object): + def __init__(self, config_file="", env=""): + from xdevice import Variables + try: + if env: + self.config_content = ET.fromstring(env) + else: + if config_file: + self.file_path = config_file + else: + user_path = os.path.join(Variables.exec_dir, "config") + top_user_path = os.path.join(Variables.top_dir, "config") + config_path = os.path.join(Variables.res_dir, "config") + paths = [user_path, top_user_path, config_path] + + for path in paths: + if os.path.exists(os.path.abspath(os.path.join( + path, ConfigFileConst.userconfig_filepath))): + self.file_path = os.path.abspath(os.path.join( + path, ConfigFileConst.userconfig_filepath)) + break + + LOG.debug("User config path: %s" % self.file_path) + if os.path.exists(self.file_path): + tree = ET.parse(self.file_path) + self.config_content = tree.getroot() + else: + raise ParamError("%s not found" % self.file_path, + error_no="00115") + + except SyntaxError as error: + if env: + raise ParamError( + "Parse environment parameter fail! Error: %s" % error.args, + error_no="00115") + else: + raise ParamError( + "Parse %s fail! Error: %s" % (self.file_path, error.args), + error_no="00115") + + def get_user_config_list(self, tag_name): + data_dic = {} + for child in self.config_content: + if tag_name == child.tag: + for sub in child: + data_dic[sub.tag] = sub.text + return data_dic + + @staticmethod + def remove_strip(value): + return value.strip() + + @staticmethod + def _verify_duplicate(items): + if len(set(items)) != len(items): + LOG.warning("Find duplicate sn config, configuration incorrect") + return False + return True + + def _handle_str(self, input_string): + config_list = map(self.remove_strip, input_string.split(';')) + config_list = [item for item in config_list if item] + if config_list: + if not self._verify_duplicate(config_list): + return [] + return config_list + + def get_sn_list(self, input_string): + sn_select_list = [] + if input_string: + sn_select_list = self._handle_str(input_string) + return sn_select_list + + def get_remote_config(self): + remote_dic = {} + data_dic = self.get_user_config_list("remote") + + if "ip" in data_dic.keys() and "port" in data_dic.keys(): + remote_ip = data_dic.get("ip", "") + remote_port = data_dic.get("port", "") + else: + remote_ip = "" + remote_port = "" + + if (not remote_ip) or (not remote_port): + remote_ip = "" + remote_port = "" + if remote_ip == get_local_ip(): + remote_ip = "127.0.0.1" + remote_dic["ip"] = remote_ip + remote_dic["port"] = remote_port + return remote_dic + + def get_testcases_dir_config(self): + data_dic = self.get_user_config_list("testcases") + if "dir" in data_dic.keys(): + testcase_dir = data_dic.get("dir", "") + if testcase_dir is None: + testcase_dir = "" + else: + testcase_dir = "" + return testcase_dir + + def get_user_config(self, target_name, filter_name=None): + data_dic = {} + all_nodes = self.config_content.findall(target_name) + if not all_nodes: + return data_dic + + for node in all_nodes: + if filter_name: + if node.get('label') != filter_name: + continue + for sub in node: + data_dic[sub.tag] = sub.text if sub.text else "" + + return data_dic + + def get_node_attr(self, target_name, attr_name): + nodes = self.config_content.find(target_name) + if attr_name in nodes.attrib: + return nodes.attrib.get(attr_name) + + def get_com_device(self, target_name): + devices = [] + + for node in self.config_content.findall(target_name): + if node.attrib["type"] != "com": + continue + + device = [node.attrib] + + # get remote device + data_dic = {} + for sub in node: + if sub.text is not None and sub.tag != "serial": + data_dic[sub.tag] = sub.text + if data_dic: + if data_dic.get("ip", "") == get_local_ip(): + data_dic["ip"] = "127.0.0.1" + device.append(data_dic) + devices.append(device) + continue + + # get local device + for serial in node.findall("serial"): + data_dic = {} + for sub in serial: + if sub.text is None: + data_dic[sub.tag] = "" + else: + data_dic[sub.tag] = sub.text + device.append(data_dic) + devices.append(device) + return devices + + def get_device(self, target_name): + for node in self.config_content.findall(target_name): + data_dic = {} + if node.attrib["type"] != "usb-hdc" and \ + node.attrib["type"] != "usb-adb": + continue + data_dic["usb_type"] = node.attrib["type"] + for sub in node: + if sub.text is None: + data_dic[sub.tag] = "" + else: + data_dic[sub.tag] = sub.text + if data_dic.get("ip", "") == get_local_ip(): + data_dic["ip"] = "127.0.0.1" + return data_dic + return None + + def get_aosp_device(self, target_name): + for node in self.config_content.findall(target_name): + data_dic = {} + if node.get("label", None) is None: + continue + if node.attrib["type"] != "usb-hdc" and \ + node.attrib["type"] != "usb-adb" and \ + node.attrib["label"] != "aosp": + continue + data_dic["usb_type"] = node.attrib["type"] + for sub in node: + if sub.text is None: + data_dic[sub.tag] = "" + else: + data_dic[sub.tag] = sub.text + if data_dic.get("ip", "") == get_local_ip(): + data_dic["ip"] = "127.0.0.1" + return data_dic + return self.get_device(target_name) + + def get_ios_device(self, target_name): + for node in self.config_content.findall(target_name): + data_dic = {} + if node.get("label", None) is None: + continue + if node.attrib["type"] != "usb-ios" and node.attrib["label"] != "ios": + continue + data_dic["usb_type"] = node.attrib["type"] + for sub in node: + if sub.text is None: + data_dic[sub.tag] = "" + else: + data_dic[sub.tag] = sub.text + if data_dic.get("ip", "") == get_local_ip(): + data_dic["ip"] = "127.0.0.1" + return data_dic + return self.get_device(target_name) + + +def get_testcases_dir(self): + from xdevice import Variables + testcases_dir = self.get_testcases_dir_config() + if testcases_dir: + if os.path.isabs(testcases_dir): + return testcases_dir + return os.path.abspath(os.path.join(Variables.exec_dir, + testcases_dir)) + + return os.path.abspath(os.path.join(Variables.exec_dir, "testcases")) + + +def get_resource_path(self): + from xdevice import Variables + data_dic = self.get_user_config_list("resource") + if "dir" in data_dic.keys(): + resource_dir = data_dic.get("dir", "") + if resource_dir: + if os.path.isabs(resource_dir): + return resource_dir + return os.path.abspath( + os.path.join(Variables.exec_dir, resource_dir)) + + return os.path.abspath( + os.path.join(Variables.exec_dir, "resource")) + + +def get_log_level(self): + data_dic = {} + node = self.config_content.find("loglevel") + if node is not None: + if node.find("console") is None and node.find("file") is None: + # neither loglevel/console nor loglevel/file exists + data_dic.update({"console": str(node.text).strip()}) + else: + for child in node: + data_dic.update({child.tag: str(child.text).strip()}) + return data_dic + + +def get_device_log_status(self): + data_dic = {} + node = self.config_content.find("devicelog") + if node is not None: + if node.find(ConfigConst.tag_enable) is not None \ + or node.find(ConfigConst.tag_dir) is not None: + for child in node: + data_dic.update({child.tag: str(child.text).strip()}) + else: + data_dic.update({ConfigConst.tag_enable: str(node.text).strip()}) + data_dic.update({ConfigConst.tag_dir: None}) + return data_dic diff --git a/xdevice/src/xdevice/_core/config/resource_manager.py b/xdevice/src/xdevice/_core/config/resource_manager.py new file mode 100644 index 0000000..2d56948 --- /dev/null +++ b/xdevice/src/xdevice/_core/config/resource_manager.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import xml.etree.ElementTree as ElementTree + +from _core.logger import platform_logger + +LOG = platform_logger("ResourceManager") +DEFAULT_TIMEOUT = "300" + + +class ResourceManager(object): + def __init__(self): + pass + + def get_resource_data(self, xml_filepath, target_name): + data_dic = {} + if os.path.exists(xml_filepath): + data_dic = self._parse_test_xml_file(xml_filepath, + target_name) + return data_dic + + def _parse_test_xml_file(self, filepath, targetname): + data_dic = {} + + node = self._find_node_by_target(filepath, targetname) + if node: + target_attrib_list = [] + target_attrib_list.append(node.attrib) + environment_data_list = [] + env_node = node.find("environment") + if env_node: + environment_data_list.append(env_node.attrib) + for element in env_node.findall("device"): + environment_data_list.append(element.attrib) + for option_element in element.findall("option"): + environment_data_list.append(option_element.attrib) + + preparer_data_list = [] + pre_node = node.find("preparer") + if pre_node: + preparer_data_list.append(pre_node.attrib) + for element in pre_node.findall("option"): + preparer_data_list.append(element.attrib) + + cleaner_data_list = [] + clr_node = node.find("cleaner") + if clr_node: + cleaner_data_list.append(clr_node.attrib) + for element in clr_node.findall("option"): + cleaner_data_list.append(element.attrib) + + data_dic["nodeattrib"] = target_attrib_list + data_dic["environment"] = environment_data_list + data_dic["preparer"] = preparer_data_list + data_dic["cleaner"] = cleaner_data_list + + return data_dic + + @staticmethod + def _find_node_by_target(filepath, targetname): + node = None + try: + if os.path.exists(filepath): + tree = ElementTree.parse(filepath) + root = tree.getroot() + targets = root.getiterator("target") + for target in targets: + curr_dic = target.attrib + if curr_dic.get("name") == targetname: + node = target + break + except (SyntaxError, ValueError, AttributeError, TypeError) as error: + LOG.error("Error: resource_test.xml parsing failed. %s", error.args) + return node + + @staticmethod + def _get_filename_extension(filepath): + _, fullname = os.path.split(filepath) + filename, ext = os.path.splitext(fullname) + return filename, ext + + @staticmethod + def process_resource_file(resource_dir, preparer_list, device): + for item in preparer_list: + if "name" not in item.keys(): + continue + + if item["name"] == "push": + push_value = item["value"] + + find_key = "->" + pos = push_value.find(find_key) + src = os.path.join(resource_dir, push_value[0:pos].strip()) + dst = push_value[pos + len(find_key):len(push_value)].strip() + + device.execute_shell_command("mkdir -p %s" % dst) + device.push_file(src, dst) + elif item["name"] == "pull": + push_value = item["value"] + + find_key = "->" + pos = push_value.find(find_key) + src = os.path.join(resource_dir, push_value[0:pos].strip()) + dst = push_value[pos + len(find_key):len(push_value)].strip() + + device.pull_file(src, dst) + elif item["name"] == "shell": + command = item["value"].strip() + device.execute_shell_command(command) + else: + command = "".join((item["name"], " ", item["value"])) + command = command.strip() + device.execute_command(command) + + @staticmethod + def _get_xml_filepath(testsuite_filepath): + xml_filepath = "" + resource_dir = os.path.join(os.path.dirname(testsuite_filepath), + "resource") + if os.path.exists(resource_dir): + xml_filepath = os.path.join(resource_dir, "resource_test.xml") + return xml_filepath + + def get_resource_data_dic(self, testsuit_filepath): + resource_dir = "" + data_dic = {} + + target_name, _ = self._get_filename_extension(testsuit_filepath) + xml_filepath = self._get_xml_filepath(testsuit_filepath) + if not os.path.exists(xml_filepath): + return data_dic, resource_dir + + data_dic = self.get_resource_data(xml_filepath, target_name) + resource_dir = os.path.abspath(os.path.dirname(xml_filepath)) + return data_dic, resource_dir + + def process_preparer_data(self, data_dic, resource_dir, device): + if "preparer" in data_dic.keys(): + LOG.info("++++++++++++++preparer+++++++++++++++") + preparer_list = data_dic["preparer"] + self.process_resource_file(resource_dir, preparer_list, device) + return + + def process_cleaner_data(self, data_dic, resource_dir, device): + if "cleaner" in data_dic.keys(): + LOG.info("++++++++++++++cleaner+++++++++++++++") + cleaner_list = data_dic["cleaner"] + self.process_resource_file(resource_dir, cleaner_list, device) + return diff --git a/xdevice/src/xdevice/_core/constants.py b/xdevice/src/xdevice/_core/constants.py new file mode 100644 index 0000000..cc6f066 --- /dev/null +++ b/xdevice/src/xdevice/_core/constants.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from dataclasses import dataclass + +__all__ = ["DeviceOsType", "ProductForm", "TestType", "TestExecType", + "DeviceTestType", "HostTestType", "HostDrivenTestType", + "SchedulerType", "ListenerType", "ToolCommandType", + "TEST_DRIVER_SET", "LogType", "CKit", + "DeviceLabelType", "GTestConst", "ManagerType", + "ModeType", "ConfigConst", "FilePermission", "CommonParserType", + "DeviceConnectorType", "AdvanceDeviceOption", "Platform"] + + +@dataclass +class DeviceOsType(object): + """ + DeviceOsType enumeration + """ + default = "default" + lite = "lite" + aosp = "aosp" + ios = "ios" + + +@dataclass +class ProductForm(object): + """ + ProductForm enumeration + """ + phone = "phone" + car = "car" + television = "tv" + watch = "watch" + tablet = 'tablet' + + +@dataclass +class TestType(object): + """ + TestType enumeration + """ + unittest = "unittest" + mst = "moduletest" + systemtest = "systemtest" + perf = "performance" + sec = "security" + reli = "reliability" + dst = "distributedtest" + benchmark = "benchmark" + all = "ALL" + + +@dataclass +class DeviceLabelType(object): + """ + DeviceLabelType enumeration + """ + wifiiot = "wifiiot" + ipcamera = "ipcamera" + watch_gt = "watchGT" + phone = "phone" + watch = "watch" + + +TEST_TYPE_DICT = { + "UT": TestType.unittest, + "MST": TestType.mst, + "ST": TestType.systemtest, + "PERF": TestType.perf, + "SEC": TestType.sec, + "RELI": TestType.reli, + "DST": TestType.dst, + "ALL": TestType.all, +} + + +@dataclass +class TestExecType(object): + """ + TestExecType enumeration according to test execution method + """ + # A test running on the device + device_test = "device" + # A test running on the host (pc) + host_test = "host" + # A test running on the host that interacts with one or more devices. + host_driven_test = "hostdriven" + + +@dataclass +class DeviceTestType(object): + """ + DeviceTestType enumeration + """ + cpp_test = "CppTest" + dex_test = "DexTest" + dex_junit_test = "DexJUnitTest" + hap_test = "HapTest" + junit_test = "JUnitTest" + jsunit_test = "JSUnitTest" + jsunit_test_lite = "JSUnitTestLite" + ctest_lite = "CTestLite" + cpp_test_lite = "CppTestLite" + lite_cpp_test = "LiteUnitTest" + open_source_test = "OpenSourceTest" + build_only_test = "BuildOnlyTestLite" + ltp_posix_test = "LtpPosixTest" + oh_kernel_test = "OHKernelTest" + oh_jsunit_test = "OHJSUnitTest" + hm_os_jsunit_test = "HMOSJSUnitTest" + oh_rust_test = "OHRustTest" + oh_yara_test = "OHYaraTest" + validator_test = "ValidatorTest" + arkuix_jsunit_test = "ARKUIXJSUnitTest" + + +@dataclass +class HostTestType(object): + """ + HostTestType enumeration + """ + host_gtest = "HostGTest" + host_junit_test = "HostJUnitTest" + + +@dataclass +class HostDrivenTestType(object): + """ + HostDrivenType enumeration + """ + device_test = "DeviceTest" + device_testsuite = "DeviceTestSuite" + windows_test = "WindowsTest" + app_test = "AppTest" + + +TEST_DRIVER_SET = { + DeviceTestType.cpp_test, + DeviceTestType.dex_test, + DeviceTestType.hap_test, + DeviceTestType.junit_test, + DeviceTestType.dex_junit_test, + DeviceTestType.jsunit_test, + DeviceTestType.jsunit_test_lite, + DeviceTestType.cpp_test_lite, + DeviceTestType.ctest_lite, + DeviceTestType.lite_cpp_test, + DeviceTestType.ltp_posix_test, + DeviceTestType.oh_kernel_test, + DeviceTestType.oh_jsunit_test, + HostDrivenTestType.device_test +} + + +@dataclass +class SchedulerType(object): + """ + SchedulerType enumeration + """ + # default scheduler + scheduler = "Scheduler" + + +@dataclass +class LogType: + tool = "Tool" + device = "Device" + + +@dataclass +class ListenerType: + log = "Log" + report = "Report" + upload = "Upload" + collect = "Collect" + collect_lite = "CollectLite" + collect_pass = "CollectPass" + + +@dataclass +class CommonParserType: + jsunit = "JSUnit" + cpptest = "CppTest" + cpptest_list = "CppTestList" + junit = "JUnit" + oh_kernel_test = "OHKernel" + oh_jsunit = "OHJSUnit" + oh_jsunit_list = "OHJSUnitList" + oh_rust = "OHRust" + oh_yara = "OHYara" + + +@dataclass +class ManagerType: + device = "device" + lite_device = "device_lite" + aosp_device = "device_aosp" + ios_device = "device_ios" + + +@dataclass +class ToolCommandType(object): + toolcmd_key_help = "help" + toolcmd_key_show = "show" + toolcmd_key_run = "run" + toolcmd_key_quit = "quit" + toolcmd_key_list = "list" + toolcmd_key_tool = "tool" + + +@dataclass +class CKit: + query = "QueryKit" + component = "ComponentKit" + + +@dataclass +class GTestConst(object): + exec_para_filter = "--gtest_filter" + exec_para_level = "--gtest_testsize" + + +@dataclass +class ModeType(object): + decc = "decc" + factory = "factory" + developer = "developer" + + +@dataclass +class ConfigConst(object): + action = "action" + task = "task" + testlist = "testlist" + testfile = "testfile" + testcase = "testcase" + testdict = "testdict" + device_sn = "device_sn" + report_path = "report_path" + resource_path = "resource_path" + testcases_path = "testcases_path" + testargs = "testargs" + pass_through = "pass_through" + test_environment = "test_environment" + exectype = "exectype" + testtype = "testtype" + testdriver = "testdriver" + retry = "retry" + session = "session" + dry_run = "dry_run" + reboot_per_module = "reboot_per_module" + check_device = "check_device" + configfile = "config" + repeat = "repeat" + subsystems = "subsystems" + parts = "parts" + renew_report = "renew_report" + kits_in_module = "kits_in_module" + kits_params = "kits_params" + auto_retry = "auto_retry" + + # Runtime Constant + history_report_path = "history_report_path" + product_info = "product_info" + task_state = "task_state" + recover_state = "recover_state" + need_kit_setup = "need_kit_setup" + task_kits = "task_kits" + module_kits = "module_kits" + spt = "spt" + version = "version" + component_mapper = "_component_mapper" + component_base_kit = "component_base_kit" + support_component = "support_component" + + # Device log + device_log = "device_log" + device_log_on = "ON" + device_log_off = "OFF" + tag_dir = "dir" + tag_enable = "enable" + tag_loglevel = "loglevel" + + env_pool_cache = "env_pool_cache" + + +@dataclass +class ReportConst(object): + session_id = "session_id" + command = "command" + report_path = "report_path" + unsuccessful_params = "unsuccessful_params" + data_reports = "data_reports" + + +class FilePermission(object): + mode_777 = 0o777 + mode_755 = 0o755 + mode_644 = 0o644 + + +@dataclass +class DeviceConnectorType: + hdc = "usb-hdc" + + +@dataclass +class AdvanceDeviceOption(object): + """ + Advance Device Option + """ + advance = "advance" + type = "type" + command = "command" + product = "product" + version = "version" + product_cmd = "product_cmd" + version_cmd = "version_cmd" + label = "label" + + +@dataclass +class Platform(object): + """ + Platform enumeration + """ + ohos = "OpenHarmony" + aosp = "Android" + ios = "IOS" diff --git a/xdevice/src/xdevice/_core/driver/__init__.py b/xdevice/src/xdevice/_core/driver/__init__.py new file mode 100644 index 0000000..f1b275b --- /dev/null +++ b/xdevice/src/xdevice/_core/driver/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# diff --git a/xdevice/src/xdevice/_core/driver/parser_lite.py b/xdevice/src/xdevice/_core/driver/parser_lite.py new file mode 100644 index 0000000..4d934ae --- /dev/null +++ b/xdevice/src/xdevice/_core/driver/parser_lite.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# +import types + +from queue import Queue +from _core.interface import IParser +from _core.report.encrypt import check_pub_key_exist +from _core.logger import platform_logger + +__all__ = ["ShellHandler"] + +LOG = platform_logger("ParserLite") + + +class ShellHandler: + def __init__(self, parsers): + self.parsers = [] + self.unfinished_line = "" + self.output_queue = Queue() + self.process_output_methods = [] + for parser in parsers: + if isinstance(parser, IParser): + self.parsers.append(parser) + else: + raise TypeError( + "Parser {} must implement IOutputParser interface.".format( + parser, )) + + def _process_output(self, output, end_mark="\n"): + if self.process_output_methods: + method = self.process_output_methods[0] + if callable(method): + return method(self, output, end_mark) + else: + content = output + if self.unfinished_line: + content = "".join((self.unfinished_line, content)) + self.unfinished_line = "" + lines = content.split(end_mark) + if content.endswith(end_mark): + # get rid of the tail element of this list contains empty str + return lines[:-1] + else: + self.unfinished_line = lines[-1] + # not return the tail element of this list contains unfinished str, + # so we set position -1 + return lines + + def add_process_method(self, func): + if isinstance(func, types.FunctionType): + self.process_output_methods.clear() + self.process_output_methods.append(func) + + def __read__(self, output): + lines = self._process_output(output) + for line in lines: + for parser in self.parsers: + try: + parser.__process__([line]) + except (ValueError, TypeError, SyntaxError, AttributeError) \ + as error: + LOG.debug("Parse %s line error: %s" % (line, error)) + + def __error__(self, message): + if message: + for parser in self.parsers: + parser.__process__([message]) + + def __done__(self, result_code="", message=""): + msg_fmt = "" + if message: + msg_fmt = ", message is {}".format(message) + for parser in self.parsers: + parser.__process__([message]) + if not check_pub_key_exist(): + LOG.debug("Result code is: {}{}".format(result_code, msg_fmt)) + for parser in self.parsers: + parser.__done__() diff --git a/xdevice/src/xdevice/_core/environment/__init__.py b/xdevice/src/xdevice/_core/environment/__init__.py new file mode 100644 index 0000000..f1b275b --- /dev/null +++ b/xdevice/src/xdevice/_core/environment/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# diff --git a/xdevice/src/xdevice/_core/environment/device_monitor.py b/xdevice/src/xdevice/_core/environment/device_monitor.py new file mode 100644 index 0000000..87d398f --- /dev/null +++ b/xdevice/src/xdevice/_core/environment/device_monitor.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import time +from threading import Condition + +from _core.environment.device_state import TestDeviceState +from _core.utils import convert_serial + +CHECK_POLL_TIME = 3 +MAX_CHECK_POLL_TIME = 30 + + +class DeviceStateListener(object): + def __init__(self, expected_state): + self.expected_state = expected_state + self.condition = Condition() + + def state_changed(self, new_state): + if self.expected_state == new_state: + with self.condition: + self.condition.notify_all() + + def get_expected_state(self): + return self.expected_state + + +class DeviceStateMonitor(object): + """ + Provides facilities for monitoring the state of a Device. + """ + + def __init__(self, device): + self.state_listener = [] + self.device_state = device.test_device_state + self.device = device + self.default_online_timeout = 1 * 60 * 1000 + self.default_available_timeout = 6 * 60 * 1000 + + def wait_for_device_state(self, state, wait_time): + listener = DeviceStateListener(state) + if self.device_state == state: + return True + self.device.log.debug( + "wait device %s for %s" % (convert_serial(self.device.device_sn), + state)) + + self.add_device_state_listener(listener) + with listener.condition: + try: + listener.condition.wait(wait_time / 1000) + finally: + self.remove_device_state_listener(listener) + + return self.device_state == state + + def wait_for_device_not_available(self, wait_time): + return self.wait_for_device_state(TestDeviceState.NOT_AVAILABLE, + wait_time) + + def wait_for_device_online(self, wait_time=None): + if not wait_time: + wait_time = self.default_online_timeout + return self.wait_for_device_state(TestDeviceState.ONLINE, wait_time) + + def wait_for_boot_complete(self, wait_time): + counter = 1 + start_time = int(time.time() * 1000) + self.device.log.debug("wait for boot complete, and wait time: %s ms" % + wait_time) + while int(time.time() * 1000) - start_time < wait_time: + try: + result = self.device.get_recover_result(retry=0) + if self.device.check_recover_result(result): + time.sleep(6) + return True + except Exception as exception: + self.device.log.error("wait for boot complete exception: %s" + % exception) + time.sleep(min(CHECK_POLL_TIME * counter, MAX_CHECK_POLL_TIME)) + counter = counter + 1 + return False + + def wait_for_device_available(self, wait_time=None): + if not wait_time: + wait_time = self.default_available_timeout + start_time = int(time.time() * 1000) + if not self.wait_for_device_online(wait_time): + return False + elapsed_time = int(time.time() * 1000) - start_time + if not self.wait_for_boot_complete(wait_time - elapsed_time): + return False + return True + + def remove_device_state_listener(self, listener): + self.state_listener.remove(listener) + + def add_device_state_listener(self, listener): + self.state_listener.append(listener) + + def set_state(self, new_state): + if not new_state: + return + self.device_state = new_state + for listener in self.state_listener: + listener.state_changed(new_state) diff --git a/xdevice/src/xdevice/_core/environment/device_state.py b/xdevice/src/xdevice/_core/environment/device_state.py new file mode 100644 index 0000000..6fe255f --- /dev/null +++ b/xdevice/src/xdevice/_core/environment/device_state.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +from dataclasses import dataclass +from enum import Enum +from enum import unique + + +@unique +class TestDeviceState(Enum): + FASTBOOT = "FASTBOOT" + ONLINE = "ONLINE" + RECOVERY = "RECOVERY" + NOT_AVAILABLE = "NOT_AVAILABLE" + + @staticmethod + def get_test_device_state(device_state): + if device_state is None: + return TestDeviceState.NOT_AVAILABLE + elif device_state == DeviceState.ONLINE: + return TestDeviceState.ONLINE + elif device_state == DeviceState.CONNECTED: + return TestDeviceState.ONLINE + elif device_state == DeviceState.OFFLINE: + return TestDeviceState.NOT_AVAILABLE + elif device_state == DeviceState.RECOVERY: + return TestDeviceState.RECOVERY + elif device_state == DeviceState.BOOTLOADER: + return TestDeviceState.FASTBOOT + else: + return TestDeviceState.NOT_AVAILABLE + + +@unique +class DeviceState(Enum): + BOOTLOADER = "bootloader" + OFFLINE = "offline" + ONLINE = "device" + CONNECTED = "connected" + RECOVERY = "recovery" + + @staticmethod + def get_state(state): + for device_state in DeviceState: + if device_state.value == state.lower(): + return device_state + return None + + +@unique +class DeviceEvent(Enum): + """ + Represents a test device event that can change allocation state + """ + CONNECTED_ONLINE = "CONNECTED_ONLINE" + CONNECTED_OFFLINE = "CONNECTED_OFFLINE" + STATE_CHANGE_ONLINE = "STATE_CHANGE_ONLINE" + STATE_CHANGE_OFFLINE = "STATE_CHANGE_OFFLINE" + DISCONNECTED = "DISCONNECTED" + FORCE_AVAILABLE = "FORCE_AVAILABLE" + AVAILABLE_CHECK_PASSED = "AVAILABLE_CHECK_PASSED" + AVAILABLE_CHECK_FAILED = "AVAILABLE_CHECK_FAILED" + AVAILABLE_CHECK_IGNORED = "AVAILABLE_CHECK_IGNORED" + ALLOCATE_REQUEST = "ALLOCATE_REQUEST" + FORCE_ALLOCATE_REQUEST = "FORCE_ALLOCATE_REQUEST" + FREE_AVAILABLE = "FREE_AVAILABLE" + FREE_UNRESPONSIVE = "FREE_UNRESPONSIVE" + FREE_UNAVAILABLE = "FREE_UNAVAILABLE" + FREE_UNKNOWN = "FREE_UNKNOWN" + + +def handle_allocation_event(old_state, event): + new_state = None + if event == DeviceEvent.CONNECTED_ONLINE \ + or event == DeviceEvent.STATE_CHANGE_ONLINE: + if old_state == DeviceAllocationState.allocated: + new_state = old_state + else: + new_state = DeviceAllocationState.checking_availability + elif event == DeviceEvent.CONNECTED_OFFLINE \ + or event == DeviceEvent.STATE_CHANGE_OFFLINE \ + or event == DeviceEvent.DISCONNECTED: + if old_state == DeviceAllocationState.allocated: + new_state = old_state + else: + new_state = DeviceAllocationState.unknown + elif event == DeviceEvent.ALLOCATE_REQUEST: + new_state = DeviceAllocationState.allocated + elif event == DeviceEvent.FREE_AVAILABLE: + new_state = DeviceAllocationState.available + elif event == DeviceEvent.FREE_UNAVAILABLE: + new_state = DeviceAllocationState.unknown + elif event == DeviceEvent.AVAILABLE_CHECK_IGNORED: + new_state = DeviceAllocationState.ignored + elif event == DeviceEvent.AVAILABLE_CHECK_PASSED: + new_state = DeviceAllocationState.available + + return new_state + + +@dataclass +class DeviceAllocationState: + ignored = "Ignored" + available = "Available" + allocated = "Allocated" + checking_availability = "Checking_Availability" + unavailable = "Unavailable" + unusable = "Unusable" + unknown = "unknown" diff --git a/xdevice/src/xdevice/_core/environment/env_pool.py b/xdevice/src/xdevice/_core/environment/env_pool.py new file mode 100644 index 0000000..7a0bab3 --- /dev/null +++ b/xdevice/src/xdevice/_core/environment/env_pool.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +import json +import os +import time +import sys + +from abc import abstractmethod +from abc import ABCMeta +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + + +from _core.interface import IFilter +from _core.interface import IDeviceManager +from _core.logger import platform_logger +from _core.plugin import Plugin +from _core.plugin import get_plugin +from _core.utils import convert_serial +from _core.utils import get_cst_time +from _core.logger import change_logger_level +from _core.constants import ConfigConst +from _core.constants import FilePermission +from _core.executor.scheduler import Scheduler + +LOG = platform_logger("EnvPool") + +__all__ = ["EnvPool", "XMLNode", "Selector", "DeviceSelector", "DeviceNode", "is_env_pool_run_mode"] + + +class EnvPool(object): + """ + Class representing environment pool that + managing the set of available devices for testing. + this class is used directly by users without going through the command flow. + """ + instance = None + __init_flag = False + report_path = None + resource_path = None + + def __new__(cls, *args, **kwargs): + """ + Singleton instance + """ + del args, kwargs + if cls.instance is None: + cls.instance = super(EnvPool, cls).__new__(cls) + return cls.instance + + def __init__(self, **kwargs): + EnvPool.report_path = kwargs.get("report_path", "") + self._stop_task_log() + self._start_task_log() + if EnvPool.__init_flag: + return + self._managers = {} + self._filters = {} + self._init_log_level(kwargs.get("log_level", "info")) + self._load_managers() + EnvPool.__init_flag = True + EnvPool.resource_path = kwargs.get("resource_path", + os.path.join(os.path.abspath(os.getcwd()), "resource")) + setattr(sys, "ecotest_resource_path", EnvPool.resource_path) + # init cache file and check if expire + cache_file = Cache() + cache_file.check_cache_if_expire() + self.devices = list() + + def _load_managers(self): + LOG.info("Load Managers ...") + manager_plugins = get_plugin(Plugin.MANAGER) + for manager_plugin in manager_plugins: + try: + manager_instance = manager_plugin.__class__() + self._managers[manager_instance.__class__.__name__] = \ + manager_instance + except Exception as error: + LOG.error("Pool start error: {}".format(error)) + # reverse sort + if self._managers: + self._managers = dict(sorted(self._managers.items(), reverse=True)) + + def _unload_manager(self): + for manager in self._managers.values(): + if manager.__class__.__name__ not in self._filters: + continue + manager.devices_list = [] + self._managers = {} + EnvPool.__init_flag = False + + def get_device(self, selector, timeout=10): + LOG.info("Get device by selector") + device = self._apply_device(selector, timeout) + if device is not None: + LOG.info("Device {}: extend value: {}".format( + convert_serial(device.device_sn), device.extend_value)) + self.devices.append(device) + else: + LOG.info("Require label is '{}', can't get device". + format(selector.label)) + return device + + def init_pool(self, node): + LOG.info("Prepare to init pool") + for manager in self._managers.values(): + if not isinstance(manager, IFilter): + continue + if not manager.__filter_xml_node__(node): + continue + if not isinstance(manager, IDeviceManager): + continue + manager.init_environment(node.format(), "") + self._filters[manager.__class__.__name__] = manager + LOG.info("Pool is prepared") + if not self._filters: + LOG.info("Can't find any manager, may be no connector are assign" + "or the plugins of manager are not installed!") + + def shutdown(self): + # clear device rpc port + for device in self.devices: + if hasattr(device, "remove_ports"): + device.remove_ports() + self._unload_manager() + self._stop_task_log() + + def _apply_device(self, selector, timeout=3): + LOG.info("Apply device in pool") + for manager_type, manager in self._filters.items(): + if not manager.__filter_selector__(selector): + continue + device_option = selector.format() + if not device_option: + continue + support_labels = getattr(manager, "support_labels", []) + support_types = getattr(manager, "support_types", []) + if device_option.required_manager not in support_types: + LOG.info("'{}' not in {}'s support types".format( + device_option.required_manager, manager_type)) + continue + if not support_labels: + continue + if device_option.label is None: + if manager_type != "ManagerDevice": + continue + else: + if support_labels and \ + device_option.label not in support_labels: + continue + device = manager.apply_device(device_option, timeout) + if hasattr(device, "env_index"): + device.env_index = device_option.get_env_index() + if device: + return device + else: + return None + + @classmethod + def _init_log_level(cls, level): + if str(level).lower() not in ["debug", "info"]: + LOG.info("Level str must be 'debug' or 'info'") + return + change_logger_level({"console": level}) + + @classmethod + def _start_task_log(cls): + report_folder_path = EnvPool.report_path + if not report_folder_path: + report_folder_path = os.path.join( + os.path.abspath(os.getcwd()), "reports", get_cst_time().strftime("%Y-%m-%d-%H-%M-%S")) + if not os.path.exists(report_folder_path): + os.makedirs(report_folder_path) + LOG.info("Report path: {}".format(report_folder_path)) + EnvPool.report_path = report_folder_path + Scheduler.start_task_log(report_folder_path) + + @classmethod + def _stop_task_log(cls): + Scheduler.stop_task_logcat() + + +class XMLNode(metaclass=ABCMeta): + + @abstractmethod + def __init__(self): + self.__device_ele = Element("device") + self.__connectors = [] + + @abstractmethod + def __on_root_attrib__(self, attrib_dict): + pass + + def add_element_string(self, element_str=""): + if element_str: + device_ele = ElementTree.fromstring(element_str) + if device_ele.tag == "device": + self.__device_ele = device_ele + return self + + @classmethod + def create_node(cls, tag): + return Element(tag) + + def build_connector(self, connector_name): + self.__connectors.append(connector_name) + return self + + def get_connectors(self): + return self.__connectors + + def format(self): + attrib_dict = dict() + self.__on_root_attrib__(attrib_dict) + self.__device_ele.attrib = attrib_dict + env = self.create_node("environment") + env.append(self.__device_ele) + root = self.create_node("user_config") + root.append(env) + return ElementTree.tostring(root, encoding="utf-8") + + def get_root_node(self): + return self.__device_ele + + +class Selector(metaclass=ABCMeta): + + @abstractmethod + def __init__(self, _type, label): + self.__device_dict = dict() + self.__config = dict() + self.label = label + self.type = _type + + def add_environment_content(self, content): + _content = content + if isinstance(_content, str): + _content = _content.strip() + if _content.startswith("[") and _content.endswith("]"): + self.__device_dict.update(json.loads(_content)[0]) + elif _content.startswith("{") and _content.endswith("}"): + self.__device_dict.update(json.loads(content)) + else: + raise RuntimeError("Invalid str input! ['{}']".format(_content)) + elif isinstance(_content, list): + self.__device_dict.update(_content[0]) + elif isinstance(_content, dict): + self.__device_dict.update(_content) + return self + + @abstractmethod + def __on_config__(self, config, device_dict): + pass + + @abstractmethod + def __on_selection_option__(self, selection_option): + pass + + def add_label(self, label): + self.label = label + return self + + def add_type(self, _type): + self.type = _type + return self + + def format(self): + if self.type or self.label: + self.__device_dict.update({"type": self.type}) + self.__device_dict.update({"label": self.label}) + self.__on_config__(self.__config, self.__device_dict) + index = 1 + label = self.__device_dict.get("label", "phone") + required_manager = self.__device_dict.get("type", "device") + device_option = SelectionOption(self.__config, label) + self.__device_dict.pop("type", None) + self.__device_dict.pop("label", None) + device_option.required_manager = required_manager + device_option.extend_value = self.__device_dict + if hasattr(device_option, "env_index"): + device_option.env_index = index + index += 1 + self.__on_selection_option__(device_option) + self.__device_dict.clear() + self.__config.clear() + return device_option + + +class SelectionOption: + def __init__(self, options, label=None): + self.device_sn = [x for x in options["device_sn"].split(";") if x] + self.label = label + self.source_file = "" + self.extend_value = {} + self.required_manager = "" + self.env_index = None + + def get_label(self): + return self.label + + def get_env_index(self): + return self.env_index + + def matches(self, device): + LOG.info("Do matches, device:[state:{}, sn:{}, label:{}], selection " + "option:[device sn:{}, label:{}]".format( + device.device_allocation_state, + convert_serial(device.device_sn), + device.label, + [convert_serial(sn) if sn else "" for sn in self.device_sn], + self.label)) + if not getattr(device, "task_state", True): + return False + + if len(self.device_sn) != 0 and device.device_sn not in self.device_sn: + return False + + return True + + +class DeviceNode(XMLNode): + + def __init__(self, usb_type, label=""): + super().__init__() + self.usb_type = usb_type + self.label = label + self.get_root_node().append(self.create_node("sn")) + self.get_root_node().append(self.create_node("ip")) + self.get_root_node().append(self.create_node("port")) + + def __on_root_attrib__(self, attrib_dict): + attrib_dict.update({"type": self.usb_type}) + if self.label: + attrib_dict.update({"label": self.label}) + + def add_address(self, host, port): + host_ele = self.get_root_node().find("ip") + port_ele = self.get_root_node().find("port") + host_ele.text = host + port_ele.text = port + return self + + def add_device_sn(self, device_sn): + sn_ele = self.get_root_node().find("sn") + if sn_ele.text: + sn_ele.text = "{};{}".format(sn_ele.text, device_sn) + else: + sn_ele.text = device_sn + return self + + +class DeviceSelector(Selector): + + def __init__(self, _type="", label=""): + super().__init__(_type, label) + self.device_sn = "" + + def __on_config__(self, config, device_dict): + config.update({"device_sn": self.device_sn}) + + def __on_selection_option__(self, selection_option): + pass + + def add_device_sn(self, device_sn): + self.device_sn = device_sn + return self + + +class Cache: + def __init__(self): + from xdevice import Variables + self.cache_file = os.path.join(Variables.temp_dir, "cache.dat") + self.expire_time = 1 # days + + def check_cache_if_expire(self): + if os.path.exists(self.cache_file): + current_modify_time = os.path.getmtime(self.cache_file) + current_time = time.time() + if Cache.get_delta_days(current_modify_time, current_time) < self.expire_time: + setattr(sys, ConfigConst.env_pool_cache, True) + LOG.info("Env pool running in cache mode.") + return + self.update_cache() + setattr(sys, ConfigConst.env_pool_cache, False) + LOG.info("Env pool running in normal mode.") + + @staticmethod + def get_delta_days(t1, t2): + import datetime + dt2 = datetime.datetime.fromtimestamp(t2) + dt1 = datetime.datetime.fromtimestamp(t1) + return (dt2 - dt1).days + + def update_cache(self): + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + with os.fdopen(os.open(self.cache_file, flags, FilePermission.mode_755), + "wb") as f: + f.write(b'123') + + +def is_env_pool_run_mode(): + return False if EnvPool.instance is None else True \ No newline at end of file diff --git a/xdevice/src/xdevice/_core/environment/manager_env.py b/xdevice/src/xdevice/_core/environment/manager_env.py new file mode 100644 index 0000000..9dfc407 --- /dev/null +++ b/xdevice/src/xdevice/_core/environment/manager_env.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from dataclasses import dataclass + +from _core.config.config_manager import UserConfigManager +from _core.logger import platform_logger +from _core.logger import change_logger_level +from _core.plugin import Plugin +from _core.plugin import get_plugin +from _core.utils import convert_serial +from _core.constants import ProductForm +from _core.constants import ConfigConst +from _core.environment.device_state import DeviceAllocationState + +__all__ = ["EnvironmentManager", "DeviceSelectionOption", "Environment"] + +LOG = platform_logger("ManagerEnv") + + +class Environment(object): + """ + Environment required for each dispatch + """ + device_mapper = { + ProductForm.phone: "Phone", + ProductForm.tablet: "Tablet", + ProductForm.car: "Car", + ProductForm.television: "Tv", + ProductForm.watch: "Watch", + } + + def __init__(self): + self.devices = [] + self.phone = 0 + self.wifiiot = 0 + self.ipcamera = 0 + self.device_recorder = dict() + + def __get_serial__(self): + device_serials = [] + for device in self.devices: + device_serials.append(convert_serial(device.__get_serial__())) + return ";".join(device_serials) + + def get_devices(self): + return self.devices + + def check_serial(self): + if self.__get_serial__(): + return True + return False + + def add_device(self, device, index=None): + label = self.device_mapper.get(device.label, "DUT") + if index: + current = index + else: + current = self.device_recorder.get(label, 0) + 1 + device.device_id = "%s%s" % (label, current) + LOG.debug("add_device, sn: {}, id: {}".format(device.device_sn, device.device_id)) + self.device_recorder.update({label: current}) + self.devices.append(device) + + +class EnvironmentManager(object): + """ + Class representing environment manager that + managing the set of available devices for testing + """ + __instance = None + __init_flag = False + + def __new__(cls, *args, **kwargs): + """ + Singleton instance + """ + del args, kwargs + if cls.__instance is None: + cls.__instance = super(EnvironmentManager, cls).__new__(cls) + return cls.__instance + + def __init__(self, environment="", user_config_file=""): + if EnvironmentManager.__init_flag: + return + self.managers = {} + self.env_start(environment, user_config_file) + EnvironmentManager.__init_flag = True + + def env_start(self, environment="", user_config_file=""): + + log_level_dict = UserConfigManager( + config_file=user_config_file, env=environment).get_log_level() + if log_level_dict: + # change log level when load or reset EnvironmentManager object + change_logger_level(log_level_dict) + + manager_plugins = get_plugin(Plugin.MANAGER) + for manager_plugin in manager_plugins: + try: + manager_instance = manager_plugin.__class__() + manager_instance.init_environment(environment, + user_config_file) + self.managers[manager_instance.__class__.__name__] = \ + manager_instance + except Exception as error: + LOG.debug("Env start error: %s" % error) + if len(self.managers): + self.managers = dict(sorted(self.managers.items(), reverse=True)) + + def env_stop(self): + for manager in self.managers.values(): + manager.env_stop() + manager.devices_list = [] + self.managers = {} + + EnvironmentManager.__init_flag = False + + def apply_environment(self, device_options): + environment = Environment() + for device_option in device_options: + LOG.debug("Visit options to find device") + device = self.apply_device(device_option) + if device is not None: + index = self.get_config_device_index(device) + environment.add_device(device, index) + device.extend_value = device_option.extend_value + LOG.debug("Device %s: extend value: %s", convert_serial( + device.device_sn), device.extend_value) + else: + LOG.debug("Require label is '%s', then next" % + device_option.label) + return environment + + def release_environment(self, environment): + for device in environment.devices: + device.extend_value = {} + self.release_device(device) + + def reset_environment(self, used_devices): + for _, device in used_devices.items(): + self.reset_device(device) + + def apply_device(self, device_option, timeout=3): + LOG.debug("Apply device from managers:%s" % self.managers) + for manager_type, manager in self.managers.items(): + support_labels = getattr(manager, "support_labels", []) + support_types = getattr(manager, "support_types", []) + if device_option.required_manager not in support_types: + LOG.warning("'%s' not in %s's support types" % ( + device_option.required_manager, manager_type)) + continue + if not support_labels: + continue + if device_option.label is None: + if manager_type != "ManagerDevice" and \ + manager_type != "ManagerAospDevice": + continue + else: + if support_labels and \ + device_option.label not in support_labels: + continue + device = manager.apply_device(device_option, timeout) + if hasattr(device, "env_index"): + device.env_index = device_option.get_env_index() + if device: + return device + else: + return None + + def get_config_device_index(self, device): + if device and hasattr(device, "device_sn"): + sn = device.device_sn + for manager in self.managers.items(): + if hasattr(manager[1], "global_device_filter"): + index = 1 + for s in manager[1].global_device_filter: + if s == sn: + return index + else: + index += 1 + return None + + def check_device_exist(self, device_options): + """ + Check if there are matched devices which can be allocated or available. + """ + devices = [] + for device_option in device_options: + for manager_type, manager in self.managers.items(): + support_labels = getattr(manager, "support_labels", []) + support_types = getattr(manager, "support_types", []) + if device_option.required_manager not in support_types: + continue + if device_option.label is None: + if manager_type != "ManagerDevice" and \ + manager_type != "ManagerAospDevice": + continue + else: + if support_labels and \ + device_option.label not in support_labels: + continue + for device in manager.devices_list: + if device.device_sn in devices: + continue + if device_option.matches(device, False): + devices.append(device.device_sn) + break + else: + continue + break + else: + return False + return True + + def release_device(self, device): + for manager in self.managers.values(): + if device in manager.devices_list: + manager.release_device(device) + + def reset_device(self, device): + for manager in self.managers.values(): + if device in manager.devices_list: + manager.reset_device(device) + + def list_devices(self): + LOG.info("List devices.") + for manager in self.managers.values(): + manager.list_devices() + + +class DeviceSelectionOption(object): + """ + Class representing device selection option + """ + + def __init__(self, options, label=None, test_source=None): + self.device_sn = [x for x in options["device_sn"].split(";") if x] + self.label = label + self.test_driver = test_source.test_type + self.source_file = "" + self.extend_value = {} + self.required_manager = "" + self.required_component = "" + self.env_index = None + + def get_label(self): + return self.label + + def get_env_index(self): + return self.env_index + + def matches(self, device, allocate=True): + LOG.debug("Do matches, device:{state:%s, sn:%s, label:%s}, selection " + "option:{device sn:%s, label:%s}" % ( + device.device_allocation_state, + convert_serial(device.device_sn), + device.label, + [convert_serial(sn) if sn else "" for sn in self.device_sn], + self.label)) + if not getattr(device, "task_state", True): + return False + if allocate and device.device_allocation_state != \ + DeviceAllocationState.available: + return False + + if not allocate: + if device.device_allocation_state != \ + DeviceAllocationState.available and \ + device.device_allocation_state != \ + DeviceAllocationState.allocated: + return False + + if len(self.device_sn) != 0 and device.device_sn not in self.device_sn: + return False + + if self.label and self.label != device.label: + return False + if self.required_component and \ + hasattr(device, ConfigConst.support_component): + subsystems, parts = getattr(device, ConfigConst.support_component) + required_subsystems, require_part = self.required_component + if required_subsystems not in subsystems and \ + require_part not in parts: + return False + return True diff --git a/xdevice/src/xdevice/_core/exception.py b/xdevice/src/xdevice/_core/exception.py new file mode 100644 index 0000000..41a26c9 --- /dev/null +++ b/xdevice/src/xdevice/_core/exception.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + + +class ParamError(Exception): + def __init__(self, error_msg, error_no=""): + super(ParamError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class DeviceError(Exception): + def __init__(self, error_msg, error_no=""): + super(DeviceError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class ExecuteTerminate(Exception): + def __init__(self, error_msg="ExecuteTerminate", error_no=""): + super(ExecuteTerminate, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class ReportException(Exception): + """ + Exception thrown when a shell command executed on a device takes too long + to send its output. + """ + def __init__(self, error_msg="ReportException", error_no=""): + super(ReportException, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class LiteDeviceError(Exception): + def __init__(self, error_msg, error_no=""): + super(LiteDeviceError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class HdcError(DeviceError): + """ + Raised when there is an error in hdc operations. + """ + + def __init__(self, error_msg, error_no=""): + super(HdcError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class HdcCommandRejectedException(HdcError): + """ + Exception thrown when hdc refuses a command. + """ + + def __init__(self, error_msg, error_no=""): + super(HdcCommandRejectedException, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class ShellCommandUnresponsiveException(HdcError): + """ + Exception thrown when a shell command executed on a device takes too long + to send its output. + """ + def __init__(self, error_msg="ShellCommandUnresponsiveException", + error_no=""): + super(ShellCommandUnresponsiveException, self).\ + __init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class DeviceUnresponsiveException(HdcError): + """ + Exception thrown when a shell command executed on a device takes too long + to send its output. + """ + def __init__(self, error_msg="DeviceUnresponsiveException", error_no=""): + super(DeviceUnresponsiveException, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class AppInstallError(DeviceError): + def __init__(self, error_msg, error_no=""): + super(AppInstallError, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) + + +class HapNotSupportTest(DeviceError): + def __init__(self, error_msg, error_no=""): + super(HapNotSupportTest, self).__init__(error_msg, error_no) + self.error_msg = error_msg + self.error_no = error_no + + def __str__(self): + return str(self.error_msg) \ No newline at end of file diff --git a/xdevice/src/xdevice/_core/executor/__init__.py b/xdevice/src/xdevice/_core/executor/__init__.py new file mode 100644 index 0000000..f1b275b --- /dev/null +++ b/xdevice/src/xdevice/_core/executor/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# diff --git a/xdevice/src/xdevice/_core/executor/concurrent.py b/xdevice/src/xdevice/_core/executor/concurrent.py new file mode 100644 index 0000000..cfae0fa --- /dev/null +++ b/xdevice/src/xdevice/_core/executor/concurrent.py @@ -0,0 +1,693 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import copy +import os +import shutil +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import wait + +from _core.constants import ModeType +from _core.constants import ConfigConst +from _core.constants import ReportConst +from _core.executor.request import Request +from _core.logger import platform_logger +from _core.plugin import Config +from _core.utils import get_instance_name +from _core.utils import check_mode +from _core.exception import ParamError +from _core.exception import ExecuteTerminate +from _core.exception import DeviceError +from _core.exception import LiteDeviceError +from _core.report.reporter_helper import VisionHelper +from _core.report.reporter_helper import ReportConstant +from _core.report.reporter_helper import DataHelper +from _core.report.reporter_helper import Suite +from _core.report.reporter_helper import Case + +LOG = platform_logger("Concurrent") + + +class Concurrent: + @classmethod + def executor_callback(cls, worker): + worker_exception = worker.exception() + if worker_exception: + LOG.error("Worker return exception: {}".format(worker_exception)) + + @classmethod + def concurrent_execute(cls, func, params_list, max_size=8): + """ + Provider the ability to execute target function concurrently + :param func: target function name + :param params_list: the list of params in these target functions + :param max_size: the max size of thread you wanted in thread pool + :return: + """ + with ThreadPoolExecutor(max_size) as executor: + future_params = dict() + for params in params_list: + future = executor.submit(func, *params) + future_params.update({future: params}) + future.add_done_callback(cls.executor_callback) + wait(future_params) # wait all function complete + result_list = [] + for future in future_params: + result_list.append((future.result(), future_params[future])) + return result_list + + +class DriversThread(threading.Thread): + def __init__(self, test_driver, task, environment, message_queue): + threading.Thread.__init__(self) + self.test_driver = test_driver + self.listeners = None + self.task = task + self.environment = environment + self.message_queue = message_queue + self.thread_id = None + self.error_message = "" + + def set_listeners(self, listeners): + self.listeners = listeners + if self.environment is None: + return + + for listener in listeners: + listener.device_sn = self.environment.devices[0].device_sn + + def set_thread_id(self, thread_id): + self.thread_id = thread_id + + def run(self): + from xdevice import Scheduler + LOG.debug("Thread id: %s start" % self.thread_id) + start_time = time.time() + execute_message = ExecuteMessage('', self.environment, + self.test_driver, self.thread_id) + driver, test = None, None + try: + if self.test_driver and Scheduler.is_execute: + # construct params + driver, test = self.test_driver + driver_request = self._get_driver_request(test, + execute_message) + if driver_request is None: + return + + # setup device + self._do_task_setup(driver_request) + + # driver execute + self.reset_device(driver_request.config) + driver.__execute__(driver_request) + + except Exception as exception: + error_no = getattr(exception, "error_no", "00000") + if self.environment is None: + LOG.exception("Exception: %s", exception, exc_info=False, + error_no=error_no) + else: + LOG.exception( + "Device: %s, exception: %s" % ( + self.environment.__get_serial__(), exception), + exc_info=False, error_no=error_no) + self.error_message = "{}: {}".format( + get_instance_name(exception), str(exception)) + + finally: + self._handle_finally(driver, execute_message, start_time, test) + + @staticmethod + def reset_device(config): + if getattr(config, "reboot_per_module", False): + for device in config.environment.devices: + device.reboot() + + def _handle_finally(self, driver, execute_message, start_time, test): + from xdevice import Scheduler + # output execute time + end_time = time.time() + execute_time = VisionHelper.get_execute_time(int( + end_time - start_time)) + source_content = self.test_driver[1].source.source_file or \ + self.test_driver[1].source.source_string + LOG.info("Executed: %s, Execution Time: %s" % ( + source_content, execute_time)) + + # inherit history report under retry mode + if driver and test: + execute_result = driver.__result__() + LOG.debug("Execute result:%s" % execute_result) + if getattr(self.task.config, "history_report_path", ""): + execute_result = self._inherit_execute_result( + execute_result, test) + execute_message.set_result(execute_result) + + # set execute state + if self.error_message: + execute_message.set_state(ExecuteMessage.DEVICE_ERROR) + else: + execute_message.set_state(ExecuteMessage.DEVICE_FINISH) + + # free environment + if self.environment: + LOG.debug("Thread %s free environment", + execute_message.get_thread_id()) + Scheduler.__free_environment__(execute_message.get_environment()) + + LOG.debug("Put thread %s result", self.thread_id) + self.message_queue.put(execute_message) + LOG.info("") + + def _do_task_setup(self, driver_request): + if check_mode(ModeType.decc) or getattr( + driver_request.config, ConfigConst.check_device, False): + return + + if self.environment is None: + return + + from xdevice import Scheduler + for device in self.environment.devices: + if not getattr(device, ConfigConst.need_kit_setup, True): + LOG.debug("Device %s need kit setup is false" % device) + continue + + # do task setup for device + kits_copy = copy.deepcopy(self.task.config.kits) + setattr(device, ConfigConst.task_kits, kits_copy) + for kit in getattr(device, ConfigConst.task_kits, []): + if not Scheduler.is_execute: + break + try: + kit.__setup__(device, request=driver_request) + except (ParamError, ExecuteTerminate, DeviceError, + LiteDeviceError, ValueError, TypeError, + SyntaxError, AttributeError) as exception: + error_no = getattr(exception, "error_no", "00000") + LOG.exception( + "Task setup device: %s, exception: %s" % ( + self.environment.__get_serial__(), + exception), exc_info=False, error_no=error_no) + LOG.debug("Set device %s need kit setup to false" % device) + setattr(device, ConfigConst.need_kit_setup, False) + + # set product_info to self.task + if getattr(driver_request, ConfigConst.product_info, "") and not \ + getattr(self.task, ConfigConst.product_info, ""): + product_info = getattr(driver_request, ConfigConst.product_info) + if not isinstance(product_info, dict): + LOG.warning("Product info should be dict, %s", + product_info) + return + setattr(self.task, ConfigConst.product_info, product_info) + + def _get_driver_request(self, root_desc, execute_message): + config = Config() + config.update(copy.deepcopy(self.task.config).__dict__) + config.environment = self.environment + if getattr(config, "history_report_path", ""): + # modify config.testargs + history_report_path = getattr(config, "history_report_path", "") + module_name = root_desc.source.module_name + unpassed_test_params = self._get_unpassed_test_params( + history_report_path, module_name) + if not unpassed_test_params: + LOG.info("%s all test cases are passed, no need retry", + module_name) + driver_request = Request(self.thread_id, root_desc, + self.listeners, config) + execute_message.set_request(driver_request) + return None + if unpassed_test_params[0] != module_name and \ + unpassed_test_params[0] != str(module_name).split(".")[0]: + test_args = getattr(config, "testargs", {}) + test_params = [] + for unpassed_test_param in unpassed_test_params: + if unpassed_test_param not in test_params: + test_params.append(unpassed_test_param) + test_args["test"] = test_params + if "class" in test_args.keys(): + test_args.pop("class") + setattr(config, "testargs", test_args) + if getattr(config, "tf_suite", ""): + if root_desc.source.module_name in config.tf_suite.keys(): + config.tf_suite = config.tf_suite.get( + root_desc.source.module_name) + else: + config.tf_suite = dict() + for listener in self.listeners: + LOG.debug("Thread id %s, listener %s" % (self.thread_id, listener)) + driver_request = Request(self.thread_id, root_desc, self.listeners, + config) + execute_message.set_request(driver_request) + return driver_request + + @classmethod + def _get_unpassed_test_params(cls, history_report_path, module_name): + unpassed_test_params = [] + from _core.report.result_reporter import ResultReporter + params = ResultReporter.get_task_info_params(history_report_path) + if not params: + return unpassed_test_params + failed_list = [] + try: + from devicetest.agent.decc import Handler + if Handler.DAV.retry_select: + for i in Handler.DAV.case_id_list: + failed_list.append(i + "#" + i) + else: + failed_list = params[ReportConst.unsuccessful_params].get(module_name, []) + except Exception: + failed_list = params[ReportConst.unsuccessful_params].get(module_name, []) + if not failed_list: + failed_list = params[ReportConst.unsuccessful_params].get(str(module_name).split(".")[0], []) + unpassed_test_params.extend(failed_list) + LOG.debug("Get unpassed test params %s", unpassed_test_params) + return unpassed_test_params + + @classmethod + def _append_unpassed_test_param(cls, history_report_file, + unpassed_test_params): + + testsuites_element = DataHelper.parse_data_report(history_report_file) + for testsuite_element in testsuites_element: + suite_name = testsuite_element.get("name", "") + suite = Suite() + suite.set_cases(testsuite_element) + for case in suite.cases: + if case.is_passed(): + continue + unpassed_test_param = "{}#{}#{}".format( + suite_name, case.classname, case.name) + unpassed_test_params.append(unpassed_test_param) + + def _inherit_execute_result(self, execute_result, root_desc): + module_name = root_desc.source.module_name + execute_result_name = "%s.xml" % module_name + history_execute_result = self._get_history_execute_result( + execute_result_name) + if not history_execute_result: + LOG.warning("%s no history execute result exists", + execute_result_name) + return execute_result + + if not check_mode(ModeType.decc): + if not os.path.exists(execute_result): + result_dir = \ + os.path.join(self.task.config.report_path, "result") + os.makedirs(result_dir, exist_ok=True) + target_execute_result = os.path.join(result_dir, + execute_result_name) + shutil.copyfile(history_execute_result, target_execute_result) + LOG.info("Copy %s to %s" % (history_execute_result, + target_execute_result)) + return target_execute_result + + real_execute_result = self._get_real_execute_result(execute_result) + + # inherit history execute result + testsuites_element = DataHelper.parse_data_report(real_execute_result) + if self._is_empty_report(testsuites_element): + if check_mode(ModeType.decc): + LOG.info("Empty report no need to inherit history execute" + " result") + else: + LOG.info("Empty report '%s' no need to inherit history execute" + " result", history_execute_result) + return execute_result + + real_history_execute_result = self._get_real_history_execute_result( + history_execute_result, module_name) + + history_testsuites_element = DataHelper.parse_data_report( + real_history_execute_result) + if self._is_empty_report(history_testsuites_element): + LOG.info("History report '%s' is empty", history_execute_result) + return execute_result + if check_mode(ModeType.decc): + LOG.info("Inherit history execute result") + else: + LOG.info("Inherit history execute result: %s", + history_execute_result) + self._inherit_element(history_testsuites_element, testsuites_element) + + if check_mode(ModeType.decc): + from xdevice import SuiteReporter + SuiteReporter.append_report_result( + (execute_result, DataHelper.to_string(testsuites_element))) + else: + # generate inherit execute result + DataHelper.generate_report(testsuites_element, execute_result) + return execute_result + + def _inherit_element(self, history_testsuites_element, testsuites_element): + for history_testsuite_element in history_testsuites_element: + history_testsuite_name = history_testsuite_element.get("name", "") + target_testsuite_element = None + for testsuite_element in testsuites_element: + if history_testsuite_name == testsuite_element.get("name", ""): + target_testsuite_element = testsuite_element + break + + if target_testsuite_element is None: + testsuites_element.append(history_testsuite_element) + inherited_test = int(testsuites_element.get( + ReportConstant.tests, 0)) + int( + history_testsuite_element.get(ReportConstant.tests, 0)) + testsuites_element.set(ReportConstant.tests, + str(inherited_test)) + continue + + pass_num = 0 + for history_testcase_element in history_testsuite_element: + if self._check_testcase_pass(history_testcase_element): + target_testsuite_element.append(history_testcase_element) + pass_num += 1 + + inherited_test = int(target_testsuite_element.get( + ReportConstant.tests, 0)) + pass_num + target_testsuite_element.set(ReportConstant.tests, + str(inherited_test)) + inherited_test = int(testsuites_element.get( + ReportConstant.tests, 0)) + pass_num + testsuites_element.set(ReportConstant.tests, str(inherited_test)) + + def _get_history_execute_result(self, execute_result_name): + if execute_result_name.endswith(".xml"): + execute_result_name = execute_result_name[:-4] + history_execute_result = \ + self._get_data_report_from_record(execute_result_name) + if history_execute_result: + return history_execute_result + for root_dir, _, files in os.walk( + self.task.config.history_report_path): + for result_file in files: + if result_file.endswith(execute_result_name): + history_execute_result = os.path.abspath( + os.path.join(root_dir, result_file)) + return history_execute_result + + @classmethod + def _check_testcase_pass(cls, history_testcase_element): + case = Case() + case.result = history_testcase_element.get(ReportConstant.result, "") + case.status = history_testcase_element.get(ReportConstant.status, "") + case.message = history_testcase_element.get(ReportConstant.message, "") + if len(history_testcase_element) > 0: + if not case.result: + case.result = ReportConstant.false + case.message = history_testcase_element[0].get( + ReportConstant.message) + + return case.is_passed() + + @classmethod + def _is_empty_report(cls, testsuites_element): + if len(testsuites_element) < 1: + return True + if len(testsuites_element) >= 2: + return False + + if int(testsuites_element[0].get(ReportConstant.unavailable, 0)) > 0: + return True + return False + + def _get_data_report_from_record(self, execute_result_name): + history_report_path = \ + getattr(self.task.config, "history_report_path", "") + if history_report_path: + from _core.report.result_reporter import ResultReporter + params = ResultReporter.get_task_info_params(history_report_path) + if params: + report_data_dict = dict(params[ReportConst.data_reports]) + if execute_result_name in report_data_dict.keys(): + return report_data_dict.get(execute_result_name) + elif execute_result_name.split(".")[0] in \ + report_data_dict.keys(): + return report_data_dict.get( + execute_result_name.split(".")[0]) + return "" + + @classmethod + def _get_real_execute_result(cls, execute_result): + from xdevice import SuiteReporter + LOG.debug("Get real execute result length is: %s" % + len(SuiteReporter.get_report_result())) + if check_mode(ModeType.decc): + for suite_report, report_result in \ + SuiteReporter.get_report_result(): + if os.path.splitext(suite_report)[0] == \ + os.path.splitext(execute_result)[0]: + return report_result + return "" + else: + return execute_result + + @classmethod + def _get_real_history_execute_result(cls, history_execute_result, + module_name): + from xdevice import SuiteReporter + LOG.debug("Get real history execute result: %s" % + SuiteReporter.history_report_result) + if check_mode(ModeType.decc): + virtual_report_path, report_result = SuiteReporter. \ + get_history_result_by_module(module_name) + return report_result + else: + return history_execute_result + + +class DriversDryRunThread(threading.Thread): + def __init__(self, test_driver, task, environment, message_queue): + threading.Thread.__init__(self) + self.test_driver = test_driver + self.listeners = None + self.task = task + self.environment = environment + self.message_queue = message_queue + self.thread_id = None + self.error_message = "" + + def set_thread_id(self, thread_id): + self.thread_id = thread_id + + def run(self): + from xdevice import Scheduler + LOG.debug("Thread id: %s start" % self.thread_id) + start_time = time.time() + execute_message = ExecuteMessage('', self.environment, + self.test_driver, self.thread_id) + driver, test = None, None + try: + if self.test_driver and Scheduler.is_execute: + # construct params + driver, test = self.test_driver + driver_request = self._get_driver_request(test, + execute_message) + if driver_request is None: + return + + # setup device + self._do_task_setup(driver_request) + + # driver execute + self.reset_device(driver_request.config) + driver.__dry_run_execute__(driver_request) + + except Exception as exception: + error_no = getattr(exception, "error_no", "00000") + if self.environment is None: + LOG.exception("Exception: %s", exception, exc_info=False, + error_no=error_no) + else: + LOG.exception( + "Device: %s, exception: %s" % ( + self.environment.__get_serial__(), exception), + exc_info=False, error_no=error_no) + self.error_message = "{}: {}".format( + get_instance_name(exception), str(exception)) + + finally: + self._handle_finally(driver, execute_message, start_time, test) + + @staticmethod + def reset_device(config): + if getattr(config, "reboot_per_module", False): + for device in config.environment.devices: + device.reboot() + + def _handle_finally(self, driver, execute_message, start_time, test): + from xdevice import Scheduler + # output execute time + end_time = time.time() + execute_time = VisionHelper.get_execute_time(int( + end_time - start_time)) + source_content = self.test_driver[1].source.source_file or \ + self.test_driver[1].source.source_string + LOG.info("Executed: %s, Execution Time: %s" % ( + source_content, execute_time)) + + # set execute state + if self.error_message: + execute_message.set_state(ExecuteMessage.DEVICE_ERROR) + else: + execute_message.set_state(ExecuteMessage.DEVICE_FINISH) + + # free environment + if self.environment: + LOG.debug("Thread %s free environment", + execute_message.get_thread_id()) + Scheduler.__free_environment__(execute_message.get_environment()) + + LOG.debug("Put thread %s result", self.thread_id) + self.message_queue.put(execute_message) + + def _do_task_setup(self, driver_request): + if check_mode(ModeType.decc) or getattr( + driver_request.config, ConfigConst.check_device, False): + return + + if self.environment is None: + return + + from xdevice import Scheduler + for device in self.environment.devices: + if not getattr(device, ConfigConst.need_kit_setup, True): + LOG.debug("Device %s need kit setup is false" % device) + continue + + # do task setup for device + kits_copy = copy.deepcopy(self.task.config.kits) + setattr(device, ConfigConst.task_kits, kits_copy) + for kit in getattr(device, ConfigConst.task_kits, []): + if not Scheduler.is_execute: + break + try: + kit.__setup__(device, request=driver_request) + except (ParamError, ExecuteTerminate, DeviceError, + LiteDeviceError, ValueError, TypeError, + SyntaxError, AttributeError) as exception: + error_no = getattr(exception, "error_no", "00000") + LOG.exception( + "Task setup device: %s, exception: %s" % ( + self.environment.__get_serial__(), + exception), exc_info=False, error_no=error_no) + LOG.debug("Set device %s need kit setup to false" % device) + setattr(device, ConfigConst.need_kit_setup, False) + + # set product_info to self.task + if getattr(driver_request, ConfigConst.product_info, "") and not \ + getattr(self.task, ConfigConst.product_info, ""): + product_info = getattr(driver_request, ConfigConst.product_info) + if not isinstance(product_info, dict): + LOG.warning("Product info should be dict, %s", + product_info) + return + setattr(self.task, ConfigConst.product_info, product_info) + + def _get_driver_request(self, root_desc, execute_message): + config = Config() + config.update(copy.deepcopy(self.task.config).__dict__) + config.environment = self.environment + if self.listeners: + for listener in self.listeners: + LOG.debug("Thread id %s, listener %s" % (self.thread_id, listener)) + driver_request = Request(self.thread_id, root_desc, self.listeners, + config) + execute_message.set_request(driver_request) + return driver_request + + +class QueueMonitorThread(threading.Thread): + + def __init__(self, message_queue, current_driver_threads, test_drivers): + threading.Thread.__init__(self) + self.message_queue = message_queue + self.current_driver_threads = current_driver_threads + self.test_drivers = test_drivers + + def run(self): + from xdevice import Scheduler + LOG.debug("Queue monitor thread start") + while self.test_drivers or self.current_driver_threads: + if not self.current_driver_threads: + time.sleep(3) + continue + execute_message = self.message_queue.get() + + self.current_driver_threads.pop(execute_message.get_thread_id()) + + if execute_message.get_state() == ExecuteMessage.DEVICE_FINISH: + LOG.debug("Thread id: %s execute finished" % + execute_message.get_thread_id()) + elif execute_message.get_state() == ExecuteMessage.DEVICE_ERROR: + LOG.debug("Thread id: %s execute error" % + execute_message.get_thread_id()) + + if Scheduler.upload_address: + Scheduler.upload_module_result(execute_message) + + LOG.debug("Queue monitor thread end") + if not Scheduler.is_execute: + LOG.info("Terminate success") + Scheduler.terminate_result.put("terminate success") + + +class ExecuteMessage: + DEVICE_RUN = 'device_run' + DEVICE_FINISH = 'device_finish' + DEVICE_ERROR = 'device_error' + + def __init__(self, state, environment, drivers, thread_id): + self.state = state + self.environment = environment + self.drivers = drivers + self.thread_id = thread_id + self.request = None + self.result = None + + def set_state(self, state): + self.state = state + + def get_state(self): + return self.state + + def set_request(self, request): + self.request = request + + def get_request(self): + return self.request + + def set_result(self, result): + self.result = result + + def get_result(self): + return self.result + + def get_environment(self): + return self.environment + + def get_thread_id(self): + return self.thread_id + + def get_drivers(self): + return self.drivers diff --git a/xdevice/src/xdevice/_core/executor/listener.py b/xdevice/src/xdevice/_core/executor/listener.py new file mode 100644 index 0000000..a1f557e --- /dev/null +++ b/xdevice/src/xdevice/_core/executor/listener.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import uuid +from dataclasses import dataclass + +from _core.plugin import Plugin +from _core.plugin import get_plugin +from _core.constants import ListenerType +from _core.constants import TestType +from _core.interface import LifeCycle +from _core.interface import IListener +from _core.logger import platform_logger +from _core.report.suite_reporter import SuiteReporter +from _core.report.suite_reporter import ResultCode +from _core.report.encrypt import check_pub_key_exist + +__all__ = ["LogListener", "ReportListener", "UploadListener", + "CollectingTestListener", "CollectingLiteGTestListener", + "CaseResult", "SuiteResult", "SuitesResult", "StateRecorder", + "TestDescription"] + +LOG = platform_logger("Listener") + + +@dataclass +class CaseResult: + index = "" + code = ResultCode.FAILED.value + test_name = None + test_class = None + stacktrace = "" + run_time = 0 + is_completed = False + num_tests = 0 + current = 0 + + def is_running(self): + return self.test_name is not None and not self.is_completed + + +@dataclass +class SuiteResult: + index = "" + code = ResultCode.UNKNOWN.value + suite_name = None + test_num = 0 + stacktrace = "" + run_time = 0 + is_completed = False + is_started = False + suite_num = 0 + + +@dataclass +class SuitesResult: + index = "" + code = ResultCode.UNKNOWN.value + suites_name = None + test_num = 0 + stacktrace = "" + run_time = 0 + is_completed = False + product_info = {} + + +@dataclass +class StateRecorder: + current_suite = None + current_suites = None + current_test = None + trace_logs = [] + running_test_index = 0 + + def is_started(self): + return self.current_suite is not None + + def suites_is_started(self): + return self.current_suites is not None + + def suite_is_running(self): + suite = self.current_suite + return suite is not None and suite.suite_name is not None and \ + not suite.is_completed + + def suites_is_running(self): + suites = self.current_suites + return suites is not None and suites.suites_name is not None and \ + not suites.is_completed + + def test_is_running(self): + test = self.current_test + return test is not None and test.is_running() + + def suite(self, reset=False): + if reset or not self.current_suite: + self.current_suite = SuiteResult() + self.current_suite.index = uuid.uuid4().hex + return self.current_suite + + def get_suites(self, reset=False): + if reset or not self.current_suites: + self.current_suites = SuitesResult() + self.current_suites.index = uuid.uuid4().hex + return self.current_suites + + def test(self, reset=False, test_index=None): + if reset or not self.current_test: + self.current_test = CaseResult() + if test_index: + self.current_test.index = test_index + else: + self.current_test.index = uuid.uuid4().hex + return self.current_test + + +class TestDescription(object): + def __init__(self, class_name, test_name): + self.class_name = class_name + self.test_name = test_name + + def __eq__(self, other): + return self.class_name == other.class_name and \ + self.test_name == other.test_name + + @classmethod + def remove_test(cls, tests, execute_tests): + for execute_test in execute_tests: + if execute_test in tests: + tests.remove(execute_test) + return tests + + +@Plugin(type=Plugin.LISTENER, id=ListenerType.log) +class LogListener(IListener): + """ + Listener test status information to the console and log + """ + test_num = 0 + device_sn = "" + + def __started__(self, lifecycle, test_result): + if check_pub_key_exist(): + return + if lifecycle == LifeCycle.TestSuite: + LOG.debug("Start test suite [{}] with {} tests" + .format(test_result.suite_name, test_result.test_num)) + self.test_num = test_result.test_num + elif lifecycle == LifeCycle.TestCase: + LOG.debug("TestStarted({}#{})" + .format(test_result.test_class, test_result.test_name)) + + def __ended__(self, lifecycle, test_result, **kwargs): + if check_pub_key_exist(): + return + + from _core.utils import convert_serial + del kwargs + if lifecycle == LifeCycle.TestSuite: + LOG.debug("End test suite cost {}ms." + .format(test_result.run_time)) + LOG.info("End test suite [{}]." + .format(test_result.suite_name)) + elif lifecycle == LifeCycle.TestCase: + LOG.debug("TestEnded({}#{})" + .format(test_result.test_class, test_result.test_name)) + ret = ResultCode(test_result.code).name + if self.test_num: + LOG.info("[{}/{} {}] {}#{} {}" + .format(test_result.current, self.test_num, + convert_serial(self.device_sn), test_result.test_class, + test_result.test_name, ret)) + else: + LOG.info("[{}/- {}] {}#{} {}" + .format(test_result.current, convert_serial(self.device_sn), + test_result.test_class, test_result.test_name, ret)) + + @staticmethod + def __skipped__(lifecycle, test_result, **kwargs): + if check_pub_key_exist(): + return + + del kwargs + if lifecycle == LifeCycle.TestSuite: + LOG.debug("Test suite [{}] skipped".format(test_result.suite_name)) + elif lifecycle == LifeCycle.TestCase: + ret = ResultCode(test_result.code).name + LOG.debug("[{}] {}#{}".format(ret, test_result.test_class, + test_result.test_name)) + + @staticmethod + def __failed__(lifecycle, test_result, **kwargs): + pass + + +@Plugin(type=Plugin.LISTENER, id=ListenerType.report) +class ReportListener(IListener): + """ + Listener test status information to the console + """ + + def __init__(self): + self.result = list() + self.suites = dict() + self.tests = dict() + self.current_suite_id = 0 + self.current_test_id = 0 + self.report_path = "" + + def _get_suite_result(self, test_result, create=False): + if test_result.index in self.suites: + return self.suites.get(test_result.index) + elif create: + suite = SuiteResult() + rid = uuid.uuid4().hex if test_result.index == "" else \ + test_result.index + suite.index = rid + return self.suites.setdefault(rid, suite) + else: + return self.suites.get(self.current_suite_id) + + def _get_test_result(self, test_result, create=False): + if test_result.index in self.tests: + return self.tests.get(test_result.index) + elif create: + test = CaseResult() + rid = uuid.uuid4().hex if test_result.index == "" else \ + test_result.index + test.index = rid + return self.tests.setdefault(rid, test) + else: + return self.tests.get(self.current_test_id) + + def _remove_current_test_result(self): + if self.current_test_id in self.tests: + del self.tests[self.current_test_id] + + def __started__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestSuites: + suites = self._get_suite_result(test_result=test_result, + create=True) + suites.suites_name = test_result.suites_name + suites.test_num = test_result.test_num + self.current_suite_id = suites.index + elif lifecycle == LifeCycle.TestSuite: + suite = self._get_suite_result(test_result=test_result, + create=True) + suite.suite_name = test_result.suite_name + suite.test_num = test_result.test_num + self.current_suite_id = suite.index + elif lifecycle == LifeCycle.TestCase: + test = self._get_test_result(test_result=test_result, create=True) + test.test_name = test_result.test_name + test.test_class = test_result.test_class + self.current_test_id = test.index + + def __ended__(self, lifecycle, test_result=None, **kwargs): + if lifecycle == LifeCycle.TestSuite: + suite = self._get_suite_result(test_result=test_result, + create=False) + if not suite: + return + suite.run_time = test_result.run_time + suite.code = test_result.code + is_clear = kwargs.get("is_clear", False) + suite.test_num = max(test_result.test_num, len(self.tests)) + # generate suite report + if not kwargs.get("suite_report", False): + if len(self.result) > 0 and self.result[-1][0].suite_name == \ + self.suites[suite.index].suite_name: + self.result[-1][1].extend(list(self.tests.values())) + self.result[-1][0].test_num = max(suite.test_num, + len(self.result[-1][1])) + else: + self.result.append((self.suites[suite.index], + list(self.tests.values()))) + else: + result_dir = os.path.join(self.report_path, "result") + os.makedirs(result_dir, exist_ok=True) + self.result.append((self.suites[suite.index], + list(self.tests.values()))) + results = [(suite, list(self.tests.values()))] + suite_report = SuiteReporter(results, suite.suite_name, + result_dir) + suite_report.generate_data_report() + if is_clear: + self.tests.clear() + elif lifecycle == LifeCycle.TestSuites: + if not kwargs.get("suite_report", False): + result_dir = os.path.join(self.report_path, "result") + os.makedirs(result_dir, exist_ok=True) + suites_name = kwargs.get("suites_name", "") + product_info = kwargs.get("product_info", "") + suite_report = SuiteReporter(self.result, suites_name, + result_dir, + product_info=product_info) + suite_report.generate_data_report() + elif lifecycle == LifeCycle.TestCase: + test = self._get_test_result(test_result=test_result, create=False) + test.run_time = test_result.run_time + test.stacktrace = test_result.stacktrace + test.code = test_result.code + elif lifecycle == LifeCycle.TestTask: + test_type = str(kwargs.get("test_type", TestType.all)) + reporter = get_plugin(plugin_type=Plugin.REPORTER, + plugin_id=test_type) + if not reporter: + reporter = get_plugin(plugin_type=Plugin.REPORTER, + plugin_id=TestType.all)[0] + else: + reporter = reporter[0] + reporter.__generate_reports__(self.report_path, + task_info=test_result) + + def __skipped__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestCase: + test = self._get_test_result(test_result=test_result, create=False) + test.stacktrace = test_result.stacktrace + test.code = ResultCode.SKIPPED.value + + def __failed__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestSuite: + suite = self._get_suite_result(test_result=test_result, + create=False) + suite.stacktrace = test_result.stacktrace + suite.code = ResultCode.FAILED.value + elif lifecycle == LifeCycle.TestCase: + test = self._get_test_result(test_result=test_result, create=False) + test.stacktrace = test_result.stacktrace + test.code = ResultCode.FAILED.value + + +@Plugin(type=Plugin.LISTENER, id=ListenerType.upload) +class UploadListener(IListener): + def __started__(self, lifecycle, test_result): + pass + + @staticmethod + def __ended__(lifecycle, test_result, **kwargs): + del test_result, kwargs + if lifecycle == LifeCycle.TestCase: + pass + + @staticmethod + def __skipped__(lifecycle, test_result, **kwargs): + pass + + @staticmethod + def __failed__(lifecycle, test_result, **kwargs): + pass + + +@Plugin(type=Plugin.LISTENER, id=ListenerType.collect) +class CollectingTestListener(IListener): + """ + Listener test status information to the console + """ + + def __init__(self): + self.tests = [] + + def __started__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestCase: + if not test_result.test_class or not test_result.test_name: + return + test = TestDescription(test_result.test_class, + test_result.test_name) + if test not in self.tests: + self.tests.append(test) + + def __ended__(self, lifecycle, test_result=None, **kwargs): + pass + + def __skipped__(self, lifecycle, test_result): + pass + + def __failed__(self, lifecycle, test_result): + pass + + def get_current_run_results(self): + return self.tests + + +@Plugin(type=Plugin.LISTENER, id=ListenerType.collect_lite) +class CollectingLiteGTestListener(IListener): + """ + Listener test status information to the console + """ + + def __init__(self): + self.tests = [] + + def __started__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestCase: + if not test_result.test_class or not test_result.test_name: + return + test = TestDescription(test_result.test_class, + test_result.test_name) + if test not in self.tests: + self.tests.append(test) + + def __ended__(self, lifecycle, test_result=None, **kwargs): + pass + + def __skipped__(self, lifecycle, test_result): + pass + + def __failed__(self, lifecycle, test_result): + if lifecycle == LifeCycle.TestCase: + if not test_result.test_class or not test_result.test_name: + return + test = TestDescription(test_result.test_class, + test_result.test_name) + if test not in self.tests: + self.tests.append(test) + + def get_current_run_results(self): + return self.tests diff --git a/xdevice/src/xdevice/_core/executor/request.py b/xdevice/src/xdevice/_core/executor/request.py new file mode 100644 index 0000000..4adf963 --- /dev/null +++ b/xdevice/src/xdevice/_core/executor/request.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import datetime +import os + +from _core.constants import ModeType +from _core.constants import ConfigConst +from _core.exception import ParamError +from _core.executor.source import TestSource +from _core.logger import platform_logger +from _core.plugin import Config +from _core.plugin import Plugin +from _core.plugin import get_plugin +from _core.testkit.json_parser import JsonParser +from _core.utils import get_kit_instances +from _core.utils import get_cst_time + +__all__ = ["Descriptor", "Task", "Request"] +LOG = platform_logger("Request") + + +class Descriptor: + """ + The descriptor for a test or suite + """ + + def __init__(self, uuid=None, name=None, source=None, container=False): + self.unique_id = uuid + self.display_name = name + self.tags = {} + self.source = source + self.parent = None + self.children = [] + self.container = container + + def get_container(self): + return self.container + + def get_unique_id(self): + return self.unique_id + + +class Task: + """ + TestTask describes the tree of tests and suites + """ + EMPTY_TASK = "empty" + TASK_CONFIG_SUFFIX = ".json" + TASK_CONFIG_DIR = "config" + + def __init__(self, root=None, drivers=None, config=None): + self.root = root + self.test_drivers = drivers or [] + self.config = config or Config() + + def init(self, config): + from xdevice import Variables + from xdevice import Scheduler + start_time = get_cst_time() + LOG.debug("StartTime=%s" % start_time.strftime("%Y-%m-%d %H:%M:%S")) + + self.config.update(config.__dict__) + if getattr(config, ConfigConst.report_path, "") == "": + Variables.task_name = start_time.strftime('%Y-%m-%d-%H-%M-%S') + else: + Variables.task_name = config.report_path + + # create a report folder to store test report + report_path = os.path.join(Variables.exec_dir, + Variables.report_vars.report_dir, + Variables.task_name) + os.makedirs(report_path, exist_ok=True) + self._check_report_path(report_path) + + log_path = os.path.join(report_path, Variables.report_vars.log_dir) + os.makedirs(log_path, exist_ok=True) + + self.config.kits = [] + if getattr(config, "task", ""): + task_file = config.task + self.TASK_CONFIG_SUFFIX + task_dir = self._get_task_dir(task_file) + self._load_task(task_dir, task_file) + + self.config.top_dir = Variables.top_dir + self.config.exec_dir = Variables.exec_dir + self.config.report_path = report_path + self.config.log_path = log_path + self.config.start_time = start_time.strftime("%Y-%m-%d %H:%M:%S") + Scheduler.start_task_log(self.config.log_path) + Scheduler.start_encrypt_log(self.config.log_path) + LOG.info("Report path: %s", report_path) + + def _get_task_dir(self, task_file): + from xdevice import Variables + exec_task_dir = os.path.abspath( + os.path.join(Variables.exec_dir, self.TASK_CONFIG_DIR)) + if not os.path.exists(os.path.join(exec_task_dir, task_file)): + if os.path.normcase(Variables.exec_dir) == \ + os.path.normcase(Variables.top_dir): + raise ParamError("task file %s not exists, please add task " + "file to '%s'" % (task_file, exec_task_dir), + error_no="00101") + + top_task_dir = os.path.abspath( + os.path.join(Variables.top_dir, self.TASK_CONFIG_DIR)) + if not os.path.exists(os.path.join(top_task_dir, task_file)): + raise ParamError("task file %s not exists, please add task " + "file to '%s' or '%s'" % ( + task_file, exec_task_dir, top_task_dir), + error_no="00101") + else: + return top_task_dir + else: + return exec_task_dir + + def _load_task(self, task_dir, file_name): + task_file = os.path.join(task_dir, file_name) + if not os.path.exists(task_file): + raise ParamError("task file %s not exists" % task_file, + error_no="00101") + + # add kits to self.config + json_config = JsonParser(task_file) + kits = get_kit_instances(json_config, self.config.resource_path, + self.config.testcases_path) + self.config.kits.extend(kits) + + def set_root_descriptor(self, root): + if not isinstance(root, Descriptor): + raise TypeError("need 'Descriptor' type param") + + self.root = root + self._init_driver(root) + if not self.test_drivers: + LOG.error("No test driver to execute", error_no="00106") + + def _init_driver(self, test_descriptor): + from xdevice import Scheduler + + plugin_id = None + source = test_descriptor.source + ignore_test = "" + if isinstance(source, TestSource): + if source.test_type is not None: + plugin_id = source.test_type + else: + ignore_test = source.module_name + LOG.error("'%s' no test driver specified" % source.test_name, + error_no="00106") + + drivers = get_plugin(plugin_type=Plugin.DRIVER, plugin_id=plugin_id) + if plugin_id is not None: + if len(drivers) == 0: + ignore_test = source.module_name + error_message = "'%s' can not find test driver '%s'" % ( + source.test_name, plugin_id) + LOG.error(error_message, error_no="00106") + if Scheduler.mode == ModeType.decc: + error_message = "Load Error[00106]" + Scheduler.report_not_executed(self.config.report_path, [ + ("", test_descriptor)], error_message) + else: + check_result = False + for driver in drivers: + driver_instance = driver.__class__() + device_options = Scheduler.get_device_options( + self.config.__dict__, source) + check_result = driver_instance.__check_environment__( + device_options) + if check_result or check_result is None: + self.test_drivers.append( + (driver_instance, test_descriptor)) + break + if check_result is False: + LOG.error("'%s' can not find suitable test driver '%s'" % + (source.test_name, plugin_id), error_no="00106") + if ignore_test and hasattr(self.config, ConfigConst.component_mapper): + getattr(self.config, ConfigConst.component_mapper).pop(ignore_test) + + for desc in test_descriptor.children: + self._init_driver(desc) + + @classmethod + def _check_report_path(cls, report_path): + for _, _, files in os.walk(report_path): + for _file in files: + if _file.endswith(".xml"): + raise ParamError("xml file exists in '%s'" % report_path, + error_no="00105") + + +class Request: + """ + Provides the necessary information for TestDriver to execute its tests. + """ + + def __init__(self, uuid=None, root=None, listeners=None, config=None): + self.uuid = uuid + self.root = root + self.listeners = listeners if listeners else [] + self.config = config + + def get_listeners(self): + return self.listeners + + def get_config(self): + return self.config + + def get(self, key=None, default=""): + # get value from self.config + if not key: + return default + return getattr(self.config, key, default) + + def get_devices(self): + if self.config is None: + return [] + if not hasattr(self.config, "environment"): + return [] + if not hasattr(self.config.environment, "devices"): + return [] + return getattr(self.config.environment, "devices", []) + + def get_config_file(self): + return self._get_source_value("config_file") + + def get_source_file(self): + return self._get_source_value("source_file") + + def get_test_name(self): + return self._get_source_value("test_name") + + def get_source_string(self): + return self._get_source_value("source_string") + + def get_test_type(self): + return self._get_source_value("test_type") + + def get_module_name(self): + return self._get_source_value("module_name") + + def _get_source(self): + if not hasattr(self.root, "source"): + return "" + return getattr(self.root, "source", "") + + def _get_source_value(self, key=None, default=""): + if not key: + return default + source = self._get_source() + if not source: + return default + return getattr(source, key, default) diff --git a/xdevice/src/xdevice/_core/executor/scheduler.py b/xdevice/src/xdevice/_core/executor/scheduler.py new file mode 100644 index 0000000..2cccc03 --- /dev/null +++ b/xdevice/src/xdevice/_core/executor/scheduler.py @@ -0,0 +1,1309 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import copy +import datetime +import os +import queue +import time +import uuid +import shutil +from xml.etree import ElementTree + +from _core.utils import unique_id +from _core.utils import check_mode +from _core.utils import get_sub_path +from _core.utils import get_filename_extension +from _core.utils import convert_serial +from _core.utils import get_instance_name +from _core.utils import is_config_str +from _core.utils import check_result_report +from _core.utils import get_cst_time +from _core.environment.manager_env import EnvironmentManager +from _core.environment.manager_env import DeviceSelectionOption +from _core.exception import ParamError +from _core.exception import ExecuteTerminate +from _core.exception import LiteDeviceError +from _core.exception import DeviceError +from _core.interface import LifeCycle +from _core.executor.request import Request +from _core.executor.request import Task +from _core.executor.request import Descriptor +from _core.plugin import get_plugin +from _core.plugin import Plugin +from _core.plugin import Config +from _core.report.reporter_helper import ExecInfo +from _core.report.reporter_helper import ReportConstant +from _core.report.reporter_helper import Case +from _core.report.reporter_helper import DataHelper +from _core.constants import TestExecType +from _core.constants import CKit +from _core.constants import ModeType +from _core.constants import DeviceLabelType +from _core.constants import SchedulerType +from _core.constants import ListenerType +from _core.constants import ConfigConst +from _core.constants import ReportConst +from _core.constants import HostDrivenTestType +from _core.executor.concurrent import DriversThread +from _core.executor.concurrent import QueueMonitorThread +from _core.executor.concurrent import DriversDryRunThread +from _core.executor.source import TestSetSource +from _core.executor.source import find_test_descriptors +from _core.executor.source import find_testdict_descriptors +from _core.executor.source import TestDictSource +from _core.logger import platform_logger +from _core.logger import add_task_file_handler +from _core.logger import remove_task_file_handler +from _core.logger import add_encrypt_file_handler +from _core.logger import remove_encrypt_file_handler + +__all__ = ["Scheduler"] +LOG = platform_logger("Scheduler") + +MAX_VISIBLE_LENGTH = 150 + + +@Plugin(type=Plugin.SCHEDULER, id=SchedulerType.scheduler) +class Scheduler(object): + """ + The Scheduler is the main entry point for client code that wishes to + discover and execute tests. + """ + # factory params + is_execute = True + terminate_result = queue.Queue() + upload_address = "" + task_type = "" + task_name = "" + mode = "" + proxy = None + + # command_queue to store test commands + command_queue = [] + max_command_num = 50 + # the number of tests in current task + test_number = 0 + device_labels = [] + auto_retry = -1 + is_need_auto_retry = False + + def __discover__(self, args): + """Discover task to execute""" + config = Config() + config.update(args) + task = Task(drivers=[]) + task.init(config) + + root_descriptor = self._find_test_root_descriptor(task.config) + task.set_root_descriptor(root_descriptor) + return task + + def __execute__(self, task): + error_message = "" + try: + Scheduler.is_execute = True + if Scheduler.command_queue: + LOG.debug("Run command: %s" % Scheduler.command_queue[-1]) + run_command = Scheduler.command_queue.pop() + task_id = str(uuid.uuid1()).split("-")[0] + Scheduler.command_queue.append((task_id, run_command, + task.config.report_path)) + if len(Scheduler.command_queue) > self.max_command_num: + Scheduler.command_queue.pop(0) + + if getattr(task.config, ConfigConst.test_environment, ""): + self._reset_environment(task.config.get( + ConfigConst.test_environment, "")) + elif getattr(task.config, ConfigConst.configfile, ""): + self._reset_environment(config_file=task.config.get( + ConfigConst.configfile, "")) + + # do with the count of repeat about a task + if getattr(task.config, ConfigConst.repeat, 0) > 0: + drivers_list = list() + for repeat_index in range(task.config.repeat): + for driver_index in range(len(task.test_drivers)): + drivers_list.append( + copy.deepcopy(task.test_drivers[driver_index])) + task.test_drivers = drivers_list + + self.test_number = len(task.test_drivers) + + if task.config.exectype == TestExecType.device_test: + self._device_test_execute(task) + elif task.config.exectype == TestExecType.host_test: + self._host_test_execute(task) + else: + LOG.info("Exec type %s is bypassed" % task.config.exectype) + + except (ParamError, ValueError, TypeError, SyntaxError, AttributeError, + DeviceError, LiteDeviceError, ExecuteTerminate) as exception: + error_no = getattr(exception, "error_no", "") + error_message = "%s[%s]" % (str(exception), error_no) \ + if error_no else str(exception) + error_no = error_no if error_no else "00000" + LOG.exception(exception, exc_info=False, error_no=error_no) + + finally: + Scheduler.reset_test_dict_source() + if getattr(task.config, ConfigConst.test_environment, "") or \ + getattr(task.config, ConfigConst.configfile, ""): + self._restore_environment() + + if Scheduler.upload_address: + Scheduler.upload_task_result(task, error_message) + Scheduler.upload_report_end() + + def _device_test_execute(self, task): + used_devices = {} + try: + self._dynamic_concurrent_execute(task, used_devices) + finally: + Scheduler.__reset_environment__(used_devices) + # generate reports + self._generate_task_report(task, used_devices) + + def _host_test_execute(self, task): + """Execute host test""" + try: + # initial params + current_driver_threads = {} + test_drivers = task.test_drivers + message_queue = queue.Queue() + + # execute test drivers + queue_monitor_thread = self._start_queue_monitor( + message_queue, test_drivers, current_driver_threads) + while test_drivers: + if len(current_driver_threads) > 5: + time.sleep(3) + continue + + # clear remaining test drivers when scheduler is terminated + if not Scheduler.is_execute: + LOG.info("Clear test drivers") + self._clear_not_executed(task, test_drivers) + break + + # get test driver and device + test_driver = test_drivers[0] + + # display executing progress + self._display_executing_process(None, test_driver, + test_drivers) + + # start driver thread + self._start_driver_thread(current_driver_threads, ( + None, message_queue, task, test_driver)) + test_drivers.pop(0) + + # wait for all drivers threads finished and do kit teardown + while True: + if not queue_monitor_thread.is_alive(): + break + time.sleep(3) + + finally: + # generate reports + self._generate_task_report(task) + + def _dry_run_device_test_execute(self, task): + try: + # initial params + used_devices = {} + current_driver_threads = {} + test_drivers = task.test_drivers + message_queue = queue.Queue() + task_unused_env = [] + + # execute test drivers + queue_monitor_thread = self._start_queue_monitor( + message_queue, test_drivers, current_driver_threads) + while test_drivers: + # clear remaining test drivers when scheduler is terminated + if not Scheduler.is_execute: + LOG.info("Clear test drivers") + self._clear_not_executed(task, test_drivers) + break + + # get test driver and device + test_driver = test_drivers[0] + # get environment + try: + environment = self.__allocate_environment__( + task.config.__dict__, test_driver) + except DeviceError as exception: + self._handle_device_error(exception, task, test_drivers) + continue + + if not Scheduler.is_execute: + if environment: + Scheduler.__free_environment__(environment) + continue + + # start driver thread + thread_id = self._get_thread_id(current_driver_threads) + driver_thread = DriversDryRunThread(test_driver, task, environment, + message_queue) + driver_thread.setDaemon(True) + driver_thread.set_thread_id(thread_id) + driver_thread.start() + current_driver_threads.setdefault(thread_id, driver_thread) + + test_drivers.pop(0) + + # wait for all drivers threads finished and do kit teardown + while True: + if not queue_monitor_thread.is_alive(): + break + time.sleep(3) + + self._do_taskkit_teardown(used_devices, task_unused_env) + finally: + LOG.debug("Removing report_path: {}".format(task.config.report_path)) + # delete reports + self.stop_task_logcat() + self.stop_encrypt_log() + shutil.rmtree(task.config.report_path) + + def _generate_task_report(self, task, used_devices=None): + task_info = ExecInfo() + test_type = getattr(task.config, "testtype", []) + task_name = getattr(task.config, "task", "") + if task_name: + task_info.test_type = str(task_name).upper() + else: + task_info.test_type = ",".join(test_type) if test_type else "Test" + if used_devices: + serials = [] + platforms = [] + for serial, device in used_devices.items(): + serials.append(convert_serial(serial)) + platform = str(device.label).capitalize() + if platform not in platforms: + platforms.append(platform) + task_info.device_name = ",".join(serials) + task_info.platform = ",".join(platforms) + else: + task_info.device_name = "None" + task_info.platform = "None" + task_info.test_time = task.config.start_time + task_info.product_info = getattr(task, "product_info", "") + + listeners = self._create_listeners(task) + for listener in listeners: + listener.__ended__(LifeCycle.TestTask, task_info, + test_type=task_info.test_type) + + @classmethod + def _create_listeners(cls, task): + listeners = [] + # append log listeners + log_listeners = get_plugin(Plugin.LISTENER, ListenerType.log) + for log_listener in log_listeners: + log_listener_instance = log_listener.__class__() + listeners.append(log_listener_instance) + # append report listeners + report_listeners = get_plugin(Plugin.LISTENER, ListenerType.report) + for report_listener in report_listeners: + report_listener_instance = report_listener.__class__() + setattr(report_listener_instance, "report_path", + task.config.report_path) + listeners.append(report_listener_instance) + # append upload listeners + upload_listeners = get_plugin(Plugin.LISTENER, ListenerType.upload) + for upload_listener in upload_listeners: + upload_listener_instance = upload_listener.__class__() + listeners.append(upload_listener_instance) + return listeners + + @staticmethod + def _find_device_options(environment_config, options, test_source): + devices_option = [] + index = 1 + for device_dict in environment_config: + label = device_dict.get("label", "") + required_manager = device_dict.get("type", "device") + required_manager = \ + required_manager if required_manager else "device" + if not label: + continue + device_option = DeviceSelectionOption(options, label, test_source) + device_dict.pop("type", None) + device_dict.pop("label", None) + device_option.required_manager = required_manager + device_option.extend_value = device_dict + device_option.source_file = \ + test_source.config_file or test_source.source_string + if hasattr(device_option, "env_index"): + device_option.env_index = index + index += 1 + devices_option.append(device_option) + return devices_option + + def __allocate_environment__(self, options, test_driver): + device_options = self.get_device_options(options, + test_driver[1].source) + environment = None + env_manager = EnvironmentManager() + while True: + if not Scheduler.is_execute: + break + environment = env_manager.apply_environment(device_options) + if len(environment.devices) == len(device_options): + return environment + else: + env_manager.release_environment(environment) + LOG.debug("'%s' is waiting available device", + test_driver[1].source.test_name) + if env_manager.check_device_exist(device_options): + continue + else: + LOG.debug("'%s' required %s devices, actually %s devices" + " were found" % (test_driver[1].source.test_name, + len(device_options), + len(environment.devices))) + raise DeviceError("The '%s' required device does not exist" + % test_driver[1].source.source_file, + error_no="00104") + + return environment + + @classmethod + def get_device_options(cls, options, test_source): + device_options = [] + config_file = test_source.config_file + environment_config = [] + from _core.testkit.json_parser import JsonParser + if test_source.source_string and is_config_str( + test_source.source_string): + json_config = JsonParser(test_source.source_string) + environment_config = json_config.get_environment() + device_options = cls._find_device_options( + environment_config, options, test_source) + elif config_file and os.path.exists(config_file): + json_config = JsonParser(test_source.config_file) + environment_config = json_config.get_environment() + device_options = cls._find_device_options( + environment_config, options, test_source) + + device_options = cls._calculate_device_options( + device_options, environment_config, options, test_source) + + if ConfigConst.component_mapper in options.keys(): + required_component = options.get(ConfigConst.component_mapper). \ + get(test_source.module_name, None) + for device_option in device_options: + device_option.required_component = required_component + return device_options + + @staticmethod + def __free_environment__(environment): + env_manager = EnvironmentManager() + env_manager.release_environment(environment) + + @staticmethod + def __reset_environment__(used_devices): + env_manager = EnvironmentManager() + env_manager.reset_environment(used_devices) + + @classmethod + def _check_device_spt(cls, kit, driver_request, device): + kit_spt = cls._parse_property_value(ConfigConst.spt, + driver_request, kit) + if not kit_spt: + setattr(device, ConfigConst.task_state, False) + LOG.error("Spt is empty", error_no="00108") + return + if getattr(driver_request, ConfigConst.product_info, ""): + product_info = getattr(driver_request, + ConfigConst.product_info) + if not isinstance(product_info, dict): + LOG.warning("Product info should be dict, %s", + product_info) + setattr(device, ConfigConst.task_state, False) + return + device_spt = product_info.get("Security Patch", None) + if not device_spt or not \ + Scheduler.compare_spt_time(kit_spt, device_spt): + LOG.error("The device %s spt is %s, " + "and the test case spt is %s, " + "which does not meet the requirements" % + (device.device_sn, device_spt, kit_spt), + error_no="00116") + setattr(device, ConfigConst.task_state, False) + return + + def _decc_task_setup(self, environment, task): + config = Config() + config.update(task.config.__dict__) + config.environment = environment + driver_request = Request(config=config) + + if environment is None: + return False + + for device in environment.devices: + if not getattr(device, ConfigConst.need_kit_setup, True): + LOG.debug("Device %s need kit setup is false" % device) + continue + + # do task setup for device + kits_copy = copy.deepcopy(task.config.kits) + setattr(device, ConfigConst.task_kits, kits_copy) + for kit in getattr(device, ConfigConst.task_kits, []): + if not Scheduler.is_execute: + break + try: + kit.__setup__(device, request=driver_request) + except (ParamError, ExecuteTerminate, DeviceError, + LiteDeviceError, ValueError, TypeError, + SyntaxError, AttributeError) as exception: + error_no = getattr(exception, "error_no", "00000") + LOG.exception( + "Task setup device: %s, exception: %s" % ( + environment.__get_serial__(), + exception), exc_info=False, error_no=error_no) + if kit.__class__.__name__ == CKit.query and \ + device.label in [DeviceLabelType.ipcamera]: + self._check_device_spt(kit, driver_request, device) + LOG.debug("Set device %s need kit setup to false" % device) + setattr(device, ConfigConst.need_kit_setup, False) + + for device in environment.devices: + if not getattr(device, ConfigConst.task_state, True): + return False + + # set product_info to self.task + if getattr(driver_request, ConfigConst.product_info, "") and \ + not getattr(task, ConfigConst.product_info, ""): + product_info = getattr(driver_request, ConfigConst.product_info) + if not isinstance(product_info, dict): + LOG.warning("Product info should be dict, %s", + product_info) + else: + setattr(task, ConfigConst.product_info, product_info) + return True + + def _dynamic_concurrent_execute(self, task, used_devices): + # initial params + current_driver_threads = {} + test_drivers = task.test_drivers + message_queue = queue.Queue() + task_unused_env = [] + + # execute test drivers + queue_monitor_thread = self._start_queue_monitor( + message_queue, test_drivers, current_driver_threads) + while test_drivers: + # clear remaining test drivers when scheduler is terminated + if not Scheduler.is_execute: + LOG.info("Clear test drivers") + self._clear_not_executed(task, test_drivers) + break + + # get test driver and device + test_driver = test_drivers[0] + + if getattr(task.config, ConfigConst.history_report_path, ""): + module_name = test_driver[1].source.module_name + if not self.is_module_need_retry(task, module_name): + self._display_executing_process(None, test_driver, + test_drivers) + LOG.info("%s are passed, no need to retry" % module_name) + self._append_history_result(task, module_name) + LOG.info("") + test_drivers.pop(0) + continue + + if getattr(task.config, ConfigConst.component_mapper, ""): + module_name = test_driver[1].source.module_name + self.component_task_setup(task, module_name) + + # get environment + try: + environment = self.__allocate_environment__( + task.config.__dict__, test_driver) + except DeviceError as exception: + self._handle_device_error(exception, task, test_drivers) + continue + + if not Scheduler.is_execute: + if environment: + Scheduler.__free_environment__(environment) + continue + + if check_mode(ModeType.decc) or getattr( + task.config, ConfigConst.check_device, False): + LOG.info("Start to check environment: %s" % + environment.__get_serial__()) + status = self._decc_task_setup(environment, task) + if not status: + Scheduler.__free_environment__(environment) + task_unused_env.append(environment) + error_message = "Load Error[00116]" + self.report_not_executed(task.config.report_path, + [test_drivers[0]], + error_message, task) + test_drivers.pop(0) + continue + else: + LOG.info("Environment %s check success", + environment.__get_serial__()) + + # display executing progress + self._display_executing_process(environment, test_driver, + test_drivers) + + # add to used devices and set need_kit_setup attribute + self._append_used_devices(environment, used_devices) + + # start driver thread + self._start_driver_thread(current_driver_threads, ( + environment, message_queue, task, test_driver)) + test_drivers.pop(0) + + # wait for all drivers threads finished and do kit teardown + while True: + if not queue_monitor_thread.is_alive(): + break + time.sleep(3) + + self._do_taskkit_teardown(used_devices, task_unused_env) + + @classmethod + def _append_history_result(cls, task, module_name): + history_report_path = getattr( + task.config, ConfigConst.history_report_path, "") + from _core.report.result_reporter import ResultReporter + params = ResultReporter.get_task_info_params( + history_report_path) + + if not params or not params[ReportConst.data_reports]: + LOG.debug("Task info record data reports is empty") + return + + report_data_dict = dict(params[ReportConst.data_reports]) + if module_name not in report_data_dict.keys(): + module_name_ = str(module_name).split(".")[0] + if module_name_ not in report_data_dict.keys(): + LOG.error("%s not in data reports" % module_name) + return + module_name = module_name_ + + from xdevice import SuiteReporter + if check_mode(ModeType.decc): + virtual_report_path, report_result = SuiteReporter. \ + get_history_result_by_module(module_name) + LOG.debug("Append history result: (%s, %s)" % ( + virtual_report_path, report_result)) + SuiteReporter.append_report_result( + (virtual_report_path, report_result)) + else: + history_execute_result = report_data_dict.get(module_name, "") + LOG.info("Start copy %s" % history_execute_result) + file_name = get_filename_extension(history_execute_result)[0] + if os.path.exists(history_execute_result): + result_dir = \ + os.path.join(task.config.report_path, "result") + os.makedirs(result_dir, exist_ok=True) + target_execute_result = "%s.xml" % os.path.join( + task.config.report_path, "result", file_name) + shutil.copyfile(history_execute_result, target_execute_result) + LOG.info("Copy %s to %s" % ( + history_execute_result, target_execute_result)) + else: + error_msg = "Copy failed! %s not exists!" % \ + history_execute_result + raise ParamError(error_msg) + + def _handle_device_error(self, exception, task, test_drivers): + self._display_executing_process(None, test_drivers[0], test_drivers) + error_message = "%s: %s" % \ + (get_instance_name(exception), exception) + LOG.exception(error_message, exc_info=False, + error_no=exception.error_no) + if check_mode(ModeType.decc): + error_message = "Load Error[00104]" + self.report_not_executed(task.config.report_path, [test_drivers[0]], + error_message, task) + + LOG.info("") + test_drivers.pop(0) + + @classmethod + def _clear_not_executed(cls, task, test_drivers): + if Scheduler.mode != ModeType.decc: + # clear all + test_drivers.clear() + return + # The result is reported only in DECC mode, and also clear all. + LOG.error("Case no run: task execution terminated!", error_no="00300") + error_message = "Execute Terminate[00300]" + cls.report_not_executed(task.config.report_path, test_drivers, + error_message) + test_drivers.clear() + + @classmethod + def report_not_executed(cls, report_path, test_drivers, error_message, + task=None): + # traversing list to get remained elements + for test_driver in test_drivers: + # get report file + if task and getattr(task.config, "testdict", ""): + report_file = os.path.join(get_sub_path( + test_driver[1].source.source_file), + "%s.xml" % test_driver[1].source.test_name) + else: + report_file = os.path.join( + report_path, "result", + "%s.xml" % test_driver[1].source.module_name) + + # get report name + report_name = test_driver[1].source.test_name if \ + not test_driver[1].source.test_name.startswith("{") \ + else "report" + + # get module name + module_name = test_driver[1].source.module_name + + # here, normally create empty report and then upload result + check_result_report(report_path, report_file, error_message, + report_name, module_name) + + def _start_driver_thread(self, current_driver_threads, thread_params): + environment, message_queue, task, test_driver = thread_params + thread_id = self._get_thread_id(current_driver_threads) + driver_thread = DriversThread(test_driver, task, environment, + message_queue) + driver_thread.setDaemon(True) + driver_thread.set_thread_id(thread_id) + driver_thread.set_listeners(self._create_listeners(task)) + driver_thread.start() + current_driver_threads.setdefault(thread_id, driver_thread) + + @classmethod + def _do_taskkit_teardown(cls, used_devices, task_unused_env): + for device in used_devices.values(): + if getattr(device, ConfigConst.need_kit_setup, True): + continue + + for kit in getattr(device, ConfigConst.task_kits, []): + try: + kit.__teardown__(device) + except Exception as error: + LOG.debug("Do task kit teardown: %s" % error) + setattr(device, ConfigConst.task_kits, []) + setattr(device, ConfigConst.need_kit_setup, True) + + for environment in task_unused_env: + for device in environment.devices: + setattr(device, ConfigConst.task_state, True) + setattr(device, ConfigConst.need_kit_setup, True) + + def _display_executing_process(self, environment, test_driver, + test_drivers): + source_content = test_driver[1].source.source_file or \ + test_driver[1].source.source_string + if environment is None: + LOG.info("[%d / %d] Executing: %s, Driver: %s" % + (self.test_number - len(test_drivers) + 1, + self.test_number, source_content, + test_driver[1].source.test_type)) + return + + LOG.info("[%d / %d] Executing: %s, Device: %s, Driver: %s" % + (self.test_number - len(test_drivers) + 1, + self.test_number, source_content, + environment.__get_serial__(), + test_driver[1].source.test_type)) + + @classmethod + def _get_thread_id(cls, current_driver_threads): + thread_id = get_cst_time().strftime( + '%Y-%m-%d-%H-%M-%S-%f') + while thread_id in current_driver_threads.keys(): + thread_id = get_cst_time().strftime( + '%Y-%m-%d-%H-%M-%S-%f') + return thread_id + + @classmethod + def _append_used_devices(cls, environment, used_devices): + if environment is not None: + for device in environment.devices: + device_serial = device.__get_serial__() if device else "None" + if device_serial and device_serial not in used_devices.keys(): + used_devices[device_serial] = device + + @staticmethod + def _start_queue_monitor(message_queue, test_drivers, + current_driver_threads): + queue_monitor_thread = QueueMonitorThread(message_queue, + current_driver_threads, + test_drivers) + queue_monitor_thread.setDaemon(True) + queue_monitor_thread.start() + return queue_monitor_thread + + def exec_command(self, command, options): + """ + Directly executes a command without adding it to the command queue. + """ + if command != "run": + raise ParamError("unsupported command action: %s" % command, + error_no="00100") + exec_type = options.exectype + if exec_type in [TestExecType.device_test, TestExecType.host_test, + TestExecType.host_driven_test]: + self._exec_task(options) + else: + LOG.error("Unsupported execution type '%s'" % exec_type, + error_no="00100") + + return + + def _exec_task(self, options): + """ + Directly allocates a device and execute a device test. + """ + try: + self.check_auto_retry(options) + task = self.__discover__(options.__dict__) + self.__execute__(task) + except (ParamError, ValueError, TypeError, SyntaxError, + AttributeError) as exception: + error_no = getattr(exception, "error_no", "00000") + LOG.exception("%s: %s" % (get_instance_name(exception), exception), + exc_info=False, error_no=error_no) + if Scheduler.upload_address: + Scheduler.upload_unavailable_result(str(exception.args)) + Scheduler.upload_report_end() + finally: + self.stop_task_logcat() + self.stop_encrypt_log() + self.start_auto_retry() + + @classmethod + def _reset_environment(cls, environment="", config_file=""): + env_manager = EnvironmentManager() + env_manager.env_stop() + EnvironmentManager(environment, config_file) + + @classmethod + def _restore_environment(cls): + env_manager = EnvironmentManager() + env_manager.env_stop() + EnvironmentManager() + + @classmethod + def start_task_log(cls, log_path): + tool_file_name = "task_log.log" + tool_log_file = os.path.join(log_path, tool_file_name) + add_task_file_handler(tool_log_file) + + @classmethod + def start_encrypt_log(cls, log_path): + from _core.report.encrypt import check_pub_key_exist + if check_pub_key_exist(): + encrypt_file_name = "task_log.ept" + encrypt_log_file = os.path.join(log_path, encrypt_file_name) + add_encrypt_file_handler(encrypt_log_file) + + @classmethod + def stop_task_logcat(cls): + remove_task_file_handler() + + @classmethod + def stop_encrypt_log(cls): + remove_encrypt_file_handler() + + @staticmethod + def _find_test_root_descriptor(config): + if getattr(config, ConfigConst.task, None) or \ + getattr(config, ConfigConst.testargs, None): + Scheduler._pre_component_test(config) + + if getattr(config, ConfigConst.subsystems, "") or \ + getattr(config, ConfigConst.parts, "") or \ + getattr(config, ConfigConst.component_base_kit, ""): + uid = unique_id("Scheduler", "component") + if config.subsystems or config.parts: + test_set = (config.subsystems, config.parts) + else: + kit = getattr(config, ConfigConst.component_base_kit) + test_set = kit.get_white_list() + + root = Descriptor(uuid=uid, name="component", + source=TestSetSource(test_set), + container=True) + + root.children = find_test_descriptors(config) + return root + # read test list from testdict + if getattr(config, ConfigConst.testdict, "") != "" and getattr( + config, ConfigConst.testfile, "") == "": + uid = unique_id("Scheduler", "testdict") + root = Descriptor(uuid=uid, name="testdict", + source=TestSetSource(config.testdict), + container=True) + root.children = find_testdict_descriptors(config) + return root + + # read test list from testfile, testlist or task + test_set = getattr(config, ConfigConst.testfile, "") or getattr( + config, ConfigConst.testlist, "") or getattr( + config, ConfigConst.task, "") or getattr( + config, ConfigConst.testcase) + # read test list from testfile, testlist or task + test_set = getattr(config, "testfile", "") or getattr( + config, "testlist", "") or getattr(config, "task", "") or getattr( + config, "testcase") + if test_set: + fname, _ = get_filename_extension(test_set) + uid = unique_id("Scheduler", fname) + root = Descriptor(uuid=uid, name=fname, + source=TestSetSource(test_set), container=True) + root.children = find_test_descriptors(config) + return root + else: + raise ParamError("no test file, list, dict, case or task found", + error_no="00102") + + @classmethod + def terminate_cmd_exec(cls): + Scheduler.is_execute = False + Scheduler.auto_retry = -1 + LOG.info("Start to terminate execution") + return Scheduler.terminate_result.get() + + @classmethod + def upload_case_result(cls, upload_param): + if not Scheduler.upload_address: + return + case_id, result, error, start_time, end_time, report_path = \ + upload_param + if error and len(error) > MAX_VISIBLE_LENGTH: + error = "%s..." % error[:MAX_VISIBLE_LENGTH] + LOG.info( + "Get upload params: %s, %s, %s, %s, %s, %s" % ( + case_id, result, error, start_time, end_time, report_path)) + if Scheduler.proxy is not None: + Scheduler.proxy.upload_result(case_id, result, error, start_time, + end_time, report_path) + else: + LOG.debug("There is no proxy, can't upload case result") + + @classmethod + def upload_module_result(cls, exec_message): + if not Scheduler.is_execute: + return + result_file = exec_message.get_result() + request = exec_message.get_request() + + test_name = request.root.source.test_name + if not result_file or not os.path.exists(result_file): + LOG.error("%s result not exists", test_name, error_no="00200") + return + + test_type = request.root.source.test_type + LOG.info("Need upload result: %s, test type: %s" % + (result_file, test_type)) + upload_params, _, _ = cls._get_upload_params(result_file, request) + if not upload_params: + LOG.error("%s no test case result to upload" % result_file, + error_no="00201") + return + LOG.info("Need upload %s case" % len(upload_params)) + upload_suite = [] + for upload_param in upload_params: + case_id, result, error, start_time, end_time, report_path = \ + upload_param + case = {"caseid": case_id, "result": result, "error": error, + "start": start_time, "end": end_time, + "report": report_path} + LOG.info("Case info: %s", case) + upload_suite.append(case) + if Scheduler.proxy is not None: + Scheduler.proxy.upload_batch(upload_suite) + else: + LOG.debug("There is no proxy, can't upload module result") + + @classmethod + def _get_upload_params(cls, result_file, request): + upload_params = [] + report_path = result_file + testsuites_element = DataHelper.parse_data_report(report_path) + start_time, end_time = cls._get_time(testsuites_element) + if request.get_test_type() == HostDrivenTestType.device_test: + for model_element in testsuites_element: + case_id = model_element.get(ReportConstant.name, "") + case_result, error = cls.get_script_result(model_element) + if error and len(error) > MAX_VISIBLE_LENGTH: + error = "$s..." % error[:MAX_VISIBLE_LENGTH] + upload_params.append( + (case_id, case_result, error, start_time, + end_time, request.config.report_path,)) + else: + for testsuite_element in testsuites_element: + if check_mode(ModeType.developer): + module_name = str(get_filename_extension( + report_path)[0]).split(".")[0] + else: + module_name = testsuite_element.get(ReportConstant.name, + "none") + for case_element in testsuite_element: + case_id = cls._get_case_id(case_element, module_name) + case_result, error = cls._get_case_result(case_element) + if error and len(error) > MAX_VISIBLE_LENGTH: + error = "%s..." % error[:MAX_VISIBLE_LENGTH] + if case_result == "Ignored": + LOG.info("Get upload params: %s result is ignored", + case_id) + continue + upload_params.append( + (case_id, case_result, error, start_time, + end_time, request.config.report_path,)) + return upload_params, start_time, end_time + + @classmethod + def get_script_result(cls, model_element): + disabled = int(model_element.get(ReportConstant.disabled)) if \ + model_element.get(ReportConstant.disabled, "") else 0 + failures = int(model_element.get(ReportConstant.failures)) if \ + model_element.get(ReportConstant.failures, "") else 0 + errors = int(model_element.get(ReportConstant.errors)) if \ + model_element.get(ReportConstant.errors, "") else 0 + unavailable = int(model_element.get(ReportConstant.unavailable)) if \ + model_element.get(ReportConstant.unavailable, "") else 0 + if failures > 0 or errors > 0: + result = "Failed" + elif disabled > 0 or unavailable > 0: + result = "Unavailable" + else: + result = "Passed" + + if result == "Passed": + return result, "" + if Scheduler.mode == ModeType.decc: + result = "Failed" + + error_msg = model_element.get(ReportConstant.message, "") + if not error_msg and len(model_element) > 0: + error_msg = model_element[0].get(ReportConstant.message, "") + if not error_msg and len(model_element[0]) > 0: + error_msg = model_element[0][0].get(ReportConstant.message, "") + return result, error_msg + + @classmethod + def _get_case_id(cls, case_element, package_name): + class_name = case_element.get(ReportConstant.class_name, "none") + method_name = case_element.get(ReportConstant.name, "none") + case_id = "{}#{}#{}#{}".format(Scheduler.task_name, package_name, + class_name, method_name) + return case_id + + @classmethod + def _get_case_result(cls, case_element): + # get result + case = Case() + case.status = case_element.get(ReportConstant.status, "") + case.result = case_element.get(ReportConstant.result, "") + if case_element.get(ReportConstant.message, ""): + case.message = case_element.get(ReportConstant.message) + if len(case_element) > 0: + if not case.result: + case.result = ReportConstant.false + case.message = case_element[0].get(ReportConstant.message) + if case.is_passed(): + result = "Passed" + elif case.is_failed(): + result = "Failed" + elif case.is_blocked(): + result = "Blocked" + elif case.is_ignored(): + result = "Ignored" + else: + result = "Unavailable" + return result, case.message + + @classmethod + def _get_time(cls, testsuite_element): + start_time = testsuite_element.get(ReportConstant.start_time, "") + end_time = testsuite_element.get(ReportConstant.end_time, "") + try: + if start_time and end_time: + start_time = int(time.mktime(time.strptime( + start_time, ReportConstant.time_format)) * 1000) + end_time = int(time.mktime(time.strptime( + end_time, ReportConstant.time_format)) * 1000) + else: + timestamp = str(testsuite_element.get( + ReportConstant.time_stamp, "")).replace("T", " ") + cost_time = testsuite_element.get(ReportConstant.time, "") + if timestamp and cost_time: + try: + end_time = int(time.mktime(time.strptime( + timestamp, ReportConstant.time_format)) * 1000) + except ArithmeticError as error: + LOG.error("Get time error %s" % error) + end_time = int(time.time() * 1000) + start_time = int(end_time - float(cost_time) * 1000) + else: + current_time = int(time.time() * 1000) + start_time, end_time = current_time, current_time + except ArithmeticError as error: + LOG.error("Get time error %s" % error) + current_time = int(time.time() * 1000) + start_time, end_time = current_time, current_time + return start_time, end_time + + @classmethod + def upload_task_result(cls, task, error_message=""): + if not Scheduler.task_name: + LOG.info("No need upload summary report") + return + + summary_data_report = os.path.join(task.config.report_path, + ReportConstant.summary_data_report) + if not os.path.exists(summary_data_report): + Scheduler.upload_unavailable_result(str( + error_message) or "summary report not exists", + task.config.report_path) + return + + task_element = ElementTree.parse(summary_data_report).getroot() + start_time, end_time = cls._get_time(task_element) + task_result = cls._get_task_result(task_element) + error_msg = "" + for child in task_element: + if child.get(ReportConstant.message, ""): + error_msg = "{}{}".format( + error_msg, "%s;" % child.get(ReportConstant.message)) + if error_msg: + error_msg = error_msg[:-1] + cls.upload_case_result((Scheduler.task_name, task_result, + error_msg, start_time, end_time, + task.config.report_path)) + + @classmethod + def _get_task_result(cls, task_element): + failures = int(task_element.get(ReportConstant.failures, 0)) + errors = int(task_element.get(ReportConstant.errors, 0)) + disabled = int(task_element.get(ReportConstant.disabled, 0)) + unavailable = int(task_element.get(ReportConstant.unavailable, 0)) + if disabled > 0: + task_result = "Blocked" + elif errors > 0 or failures > 0: + task_result = "Failed" + elif unavailable > 0: + task_result = "Unavailable" + else: + task_result = "Passed" + return task_result + + @classmethod + def upload_unavailable_result(cls, error_msg, report_path=""): + start_time = int(time.time() * 1000) + Scheduler.upload_case_result((Scheduler.task_name, "Unavailable", + error_msg, start_time, start_time, + report_path)) + + @classmethod + def upload_report_end(cls): + if getattr(cls, "tmp_json", None): + os.remove(cls.tmp_json) + del cls.tmp_json + LOG.info("Upload report end") + if Scheduler.proxy is not None: + Scheduler.proxy.report_end() + else: + LOG.debug("There is no proxy, can't upload report end") + + @classmethod + def is_module_need_retry(cls, task, module_name): + failed_flag = False + if check_mode(ModeType.decc): + from xdevice import SuiteReporter + for module, failed in SuiteReporter.get_failed_case_list(): + if module_name == module or str(module_name).split( + ".")[0] == module: + failed_flag = True + break + else: + from xdevice import ResultReporter + history_report_path = \ + getattr(task.config, ConfigConst.history_report_path, "") + params = ResultReporter.get_task_info_params(history_report_path) + if params and params[ReportConst.unsuccessful_params]: + if dict(params[ReportConst.unsuccessful_params]).get(module_name, []): + failed_flag = True + elif dict(params[ReportConst.unsuccessful_params]).get(str(module_name).split(".")[0], []): + failed_flag = True + return failed_flag + + @classmethod + def compare_spt_time(cls, kit_spt, device_spt): + if not kit_spt or not device_spt: + return False + try: + kit_time = str(kit_spt).split("-")[:2] + device_time = str(device_spt).split("-")[:2] + k_spt = datetime.datetime.strptime( + "-".join(kit_time), "%Y-%m") + d_spt = datetime.datetime.strptime("-".join(device_time), "%Y-%m") + except ValueError as value_error: + LOG.debug("Date format is error, %s" % value_error.args) + return False + month_interval = int(k_spt.month) - int(d_spt.month) + year_interval = int(k_spt.year) - int(d_spt.year) + LOG.debug("Kit spt (year=%s, month=%s), device spt (year=%s, month=%s)" + % (k_spt.year, k_spt.month, d_spt.year, d_spt.month)) + if year_interval < 0: + return True + if year_interval == 0 and month_interval in range(-11, 3): + return True + if year_interval == 1 and month_interval + 12 in (1, 2): + return True + + @classmethod + def _parse_property_value(cls, property_name, driver_request, kit): + test_args = copy.deepcopy( + driver_request.config.get(ConfigConst.testargs, dict())) + property_value = "" + if ConfigConst.pass_through in test_args.keys(): + import json + pt_dict = json.loads(test_args.get(ConfigConst.pass_through, "")) + property_value = pt_dict.get(property_name, None) + elif property_name in test_args.keys: + property_value = test_args.get(property_name, None) + return property_value if property_value else \ + kit.properties.get(property_name, None) + + @classmethod + def _calculate_device_options(cls, device_options, environment_config, + options, test_source): + # calculate difference + diff_value = len(environment_config) - len(device_options) + if device_options and diff_value == 0: + return device_options + + else: + diff_value = diff_value if diff_value else 1 + if str(test_source.source_file).endswith(".bin"): + device_option = DeviceSelectionOption( + options, DeviceLabelType.ipcamera, test_source) + else: + device_option = DeviceSelectionOption( + options, None, test_source) + + device_option.source_file = \ + test_source.source_file or test_source.source_string + device_option.required_manager = "device" + device_options.extend([device_option] * diff_value) + LOG.debug("Assign device options and it's length is %s" + % len(device_options)) + return device_options + + @classmethod + def update_test_type_in_source(cls, key, value): + LOG.debug("update test type dict in source") + TestDictSource.test_type[key] = value + + @classmethod + def update_ext_type_in_source(cls, key, value): + LOG.debug("update ext type dict in source") + TestDictSource.exe_type[key] = value + + @classmethod + def clear_test_dict_source(cls): + TestDictSource.clear() + + @classmethod + def reset_test_dict_source(cls): + TestDictSource.reset() + + @classmethod + def _pre_component_test(cls, config): + if not config.kits: + return + cur_kit = None + for kit in config.kits: + if kit.__class__.__name__ == CKit.component: + cur_kit = kit + break + if not cur_kit: + return + get_white_list = getattr(cur_kit, "get_white_list", None) + if not callable(get_white_list): + return + subsystems, parts = get_white_list() + if not subsystems and not parts: + return + setattr(config, ConfigConst.component_base_kit, cur_kit) + + @classmethod + def component_task_setup(cls, task, module_name): + component_kit = task.config.get(ConfigConst.component_base_kit, None) + if not component_kit: + # only -p -s .you do not care about the components that can be + # supported. you only want to run the use cases of the current + # component + return + LOG.debug("Start component task setup") + _component_mapper = task.config.get(ConfigConst.component_mapper) + _subsystem, _part = _component_mapper.get(module_name) + + is_hit = False + # find in cache. if not find, update cache + cache_subsystem, cache_part = component_kit.get_cache() + if _subsystem in cache_subsystem or _part in cache_subsystem: + is_hit = True + if not is_hit: + env_manager = EnvironmentManager() + for _, manager in env_manager.managers.items(): + if getattr(manager, "devices_list", []): + for device in manager.devices_list: + component_kit.__setup__(device) + cache_subsystem, cache_part = component_kit.get_cache() + if _subsystem in cache_subsystem or _part in cache_subsystem: + is_hit = True + if not is_hit: + LOG.warning("%s are skipped, no suitable component found. " + "Require subsystem=%s part=%s, no device match this" + % (module_name, _subsystem, _part)) + + @classmethod + def start_auto_retry(cls): + if not Scheduler.is_need_auto_retry: + Scheduler.auto_retry = -1 + LOG.debug("No need auto retry") + return + if Scheduler.auto_retry > 0: + Scheduler.auto_retry -= 1 + if Scheduler.auto_retry == 0: + Scheduler.auto_retry = -1 + from _core.command.console import Console + console = Console() + console.command_parser("run --retry") + + @classmethod + def check_auto_retry(cls, options): + if Scheduler.auto_retry < 0 and int(getattr(options, ConfigConst.auto_retry, 0)) > 0: + value = int(getattr(options, ConfigConst.auto_retry, 0)) + Scheduler.auto_retry = value if value <= 10 else 10 diff --git a/xdevice/src/xdevice/_core/executor/source.py b/xdevice/src/xdevice/_core/executor/source.py new file mode 100644 index 0000000..32948f1 --- /dev/null +++ b/xdevice/src/xdevice/_core/executor/source.py @@ -0,0 +1,518 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import json +import copy +import stat +from collections import namedtuple + +from _core.constants import DeviceTestType +from _core.constants import ModeType +from _core.constants import HostDrivenTestType +from _core.constants import FilePermission +from _core.constants import ConfigConst +from _core.exception import ParamError +from _core.logger import platform_logger +from _core.utils import get_filename_extension +from _core.utils import is_config_str +from _core.utils import unique_id + +__all__ = ["TestSetSource", "TestSource", "find_test_descriptors", + "find_testdict_descriptors", "TestDictSource"] + +TestSetSource = namedtuple('TestSetSource', 'set') +TestSource = namedtuple('TestSource', 'source_file source_string config_file ' + 'test_name test_type module_name') + +TEST_TYPE_DICT = {"DEX": DeviceTestType.dex_test, + "HAP": DeviceTestType.hap_test, + "APK": DeviceTestType.hap_test, + "PYT": HostDrivenTestType.device_test, + "JST": DeviceTestType.jsunit_test, + "OHJST": DeviceTestType.oh_jsunit_test, + "CXX": DeviceTestType.cpp_test, + "BIN": DeviceTestType.lite_cpp_test, + "LTPPosix": DeviceTestType.ltp_posix_test} +EXT_TYPE_DICT = {".dex": DeviceTestType.dex_test, + ".hap": DeviceTestType.hap_test, + ".apk": DeviceTestType.hap_test, + ".py": HostDrivenTestType.device_test, + ".js": DeviceTestType.jsunit_test, + ".bin": DeviceTestType.lite_cpp_test, + "default": DeviceTestType.cpp_test} +PY_SUFFIX = ".py" +PYD_SUFFIX = ".pyd" +MODULE_CONFIG_SUFFIX = ".json" +MODULE_INFO_SUFFIX = ".moduleInfo" +MAX_DIR_DEPTH = 6 +LOG = platform_logger("TestSource") + + +def find_test_descriptors(config): + if not config.testfile and not config.testlist and not config.task and \ + not config.testcase and not config.subsystems and \ + not config.parts: + return None + + # get test sources + testcases_dirs = _get_testcases_dirs(config) + test_sources = _get_test_sources(config, testcases_dirs) + LOG.debug("Test sources: %s", test_sources) + + # normalize test sources + test_sources = _normalize_test_sources(testcases_dirs, test_sources, + config) + + # make test descriptors + test_descriptors = _make_test_descriptors_from_testsources(test_sources, + config) + return test_descriptors + + +def _get_testcases_dirs(config): + from xdevice import Variables + # add config.testcases_path and its subfolders + testcases_dirs = [] + if getattr(config, ConfigConst.testcases_path, ""): + testcases_dirs = [config.testcases_path] + _append_subfolders(config.testcases_path, testcases_dirs) + + # add inner testcases dir and its subfolders + inner_testcases_dir = os.path.abspath(os.path.join( + Variables.top_dir, "testcases")) + if getattr(config, ConfigConst.testcases_path, "") and os.path.normcase( + config.testcases_path) != os.path.normcase(inner_testcases_dir): + testcases_dirs.append(inner_testcases_dir) + _append_subfolders(inner_testcases_dir, testcases_dirs) + + # add execution dir and top dir + testcases_dirs.append(Variables.exec_dir) + if os.path.normcase(Variables.exec_dir) != os.path.normcase( + Variables.top_dir): + testcases_dirs.append(Variables.top_dir) + + LOG.debug("Testcases directories: %s", testcases_dirs) + return testcases_dirs + + +def _append_subfolders(testcases_path, testcases_dirs): + for root, dirs, _ in os.walk(testcases_path): + for sub_dir in dirs: + testcases_dirs.append(os.path.abspath(os.path.join(root, sub_dir))) + + +def find_testdict_descriptors(config): + from xdevice import Variables + if getattr(config, ConfigConst.testdict, "") == "": + return None + testdict = config.testdict + test_descriptors = [] + for test_type_key, files in testdict.items(): + for file_name in files: + if not os.path.isabs(file_name): + file_name = os.path.join(Variables.exec_dir, file_name) + if os.path.isfile(file_name) and test_type_key in \ + TestDictSource.test_type.keys(): + desc = _make_test_descriptor(os.path.abspath(file_name), + test_type_key) + if desc is not None: + test_descriptors.append(desc) + if not test_descriptors: + raise ParamError("test source is none", error_no="00110") + return test_descriptors + + +def _append_component_test_source(config, testcases_dir, test_sources): + subsystem_list = config.subsystems if config.subsystems else list() + part_list = config.parts if config.parts else list() + module_info_files = _get_component_info_file(testcases_dir) + result_dict = dict() + for info_file in module_info_files: + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(info_file, flags, modes), "r") as f_handler: + result_dict.update(json.load(f_handler)) + module_name = result_dict.get("module", "") + part_name = result_dict.get("part", "") + subsystem_name = result_dict.get("subsystem", "") + if not module_name or not part_name or not subsystem_name: + continue + module_config_file = \ + os.path.join(os.path.dirname(info_file), module_name) + is_append = True + if subsystem_list or part_list: + if part_name not in part_list and \ + subsystem_name not in subsystem_list: + is_append = False + if is_append: + getattr(config, ConfigConst.component_mapper, dict()).update( + {module_name: (subsystem_name, part_name)}) + test_sources.append(module_config_file) + + +def _get_test_sources(config, testcases_dirs): + test_sources = [] + + # get test sources from testcases_dirs + if not config.testfile and not config.testlist and not config.testcase \ + and not config.subsystems and not config.parts and not \ + getattr(config, ConfigConst.component_base_kit, "") and \ + config.task: + for testcases_dir in testcases_dirs: + _append_module_test_source(testcases_dir, test_sources) + return test_sources + + # get test sources from config.testlist + if getattr(config, ConfigConst.testlist, ""): + for test_source in config.testlist.split(";"): + if test_source.strip(): + test_sources.append(test_source.strip()) + return test_sources + + # get test sources from config.testfile + if getattr(config, ConfigConst.testfile, ""): + test_file = _get_test_file(config, testcases_dirs) + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(test_file, flags, modes), "r") as file_content: + if str(test_file).endswith(".json"): + content = file_content.read() + source_list, case_dict = parse_source_from_data(content) + test_sources.extend(source_list) + config.tf_suite = case_dict + else: + for line in file_content: + if line.strip(): + test_sources.append(line.strip()) + + # get test sources from config.testcase + if getattr(config, ConfigConst.testcase, ""): + for test_source in config.testcase.split(";"): + if test_source.strip(): + test_sources.append(test_source.strip()) + return test_sources + + if getattr(config, ConfigConst.subsystems, []) or \ + getattr(config, ConfigConst.parts, []) or \ + getattr(config, ConfigConst.component_base_kit, ""): + setattr(config, ConfigConst.component_mapper, dict()) + for testcases_dir in testcases_dirs: + _append_component_test_source(config, testcases_dir, test_sources) + return test_sources + return test_sources + + +def _append_module_test_source(testcases_path, test_sources): + if not os.path.isdir(testcases_path): + return + for item in os.listdir(testcases_path): + item_path = os.path.join(testcases_path, item) + if os.path.isfile(item_path) and item_path.endswith( + MODULE_CONFIG_SUFFIX): + test_sources.append(item_path) + + +def _get_test_file(config, testcases_dirs): + if os.path.isabs(config.testfile): + if os.path.exists(config.testfile): + return config.testfile + else: + raise ParamError("test file '%s' not exists" % config.testfile, + error_no="00110") + + for testcases_dir in testcases_dirs: + test_file = os.path.join(testcases_dir, config.testfile) + if os.path.exists(test_file): + return test_file + + raise ParamError("test file '%s' not exists" % config.testfile) + + +def _normalize_test_sources(testcases_dirs, test_sources, config): + norm_test_sources = [] + for test_source in test_sources: + append_result = False + for testcases_dir in testcases_dirs: + # append test source absolute path + append_result = _append_norm_test_source( + norm_test_sources, test_source, testcases_dir, config) + if append_result: + break + + # append test source if no corresponding file founded + if not append_result: + norm_test_sources.append(test_source) + if not norm_test_sources: + raise ParamError("test source not found") + return norm_test_sources + + +def _append_norm_test_source(norm_test_sources, test_source, testcases_dir, + config): + # get norm_test_source + norm_test_source = test_source + if not os.path.isabs(test_source): + norm_test_source = os.path.abspath( + os.path.join(testcases_dir, test_source)) + + # find py or pyd for test case input + if config.testcase and not config.testlist: + if os.path.isfile("%s%s" % (norm_test_source, PY_SUFFIX)): + norm_test_sources.append( + "%s%s" % (norm_test_source, PY_SUFFIX)) + return True + elif os.path.isfile("%s%s" % (norm_test_source, PYD_SUFFIX)): + norm_test_sources.append( + "%s%s" % (norm_test_source, PYD_SUFFIX)) + return True + return False + + # append to norm_test_sources + if os.path.isfile(norm_test_source): + norm_test_sources.append(norm_test_source) + return True + elif os.path.isfile("%s%s" % (norm_test_source, MODULE_CONFIG_SUFFIX)): + norm_test_sources.append("%s%s" % (norm_test_source, + MODULE_CONFIG_SUFFIX)) + return True + return False + + +def _make_test_descriptor(file_path, test_type_key): + from _core.executor.request import Descriptor + if test_type_key is None: + return None + + # get params + filename, _ = get_filename_extension(file_path) + uid = unique_id("TestSource", filename) + test_type = TestDictSource.test_type[test_type_key] + config_file = _get_config_file( + os.path.join(os.path.dirname(file_path), filename)) + + module_name = _parse_module_name(config_file, filename) + # make test descriptor + desc = Descriptor(uuid=uid, name=filename, + source=TestSource(file_path, "", config_file, filename, + test_type, module_name)) + return desc + + +def _get_test_driver(test_source): + try: + from _core.testkit.json_parser import JsonParser + json_config = JsonParser(test_source) + return json_config.get_driver_type() + except ParamError as error: + LOG.error(error, error_no=error.error_no) + return "" + + +def _make_test_descriptors_from_testsources(test_sources, config): + test_descriptors = [] + + for test_source in test_sources: + filename, ext = test_source.split()[0], "str" + if os.path.isfile(test_source): + filename, ext = get_filename_extension(test_source) + + test_driver = config.testdriver + if is_config_str(test_source): + test_driver = _get_test_driver(test_source) + + # get params + config_file = _get_config_file( + os.path.join(os.path.dirname(test_source), filename), ext, config) + test_type = _get_test_type(config_file, test_driver, ext) + if not config_file: + if getattr(config, ConfigConst.testcase, "") and not \ + getattr(config, ConfigConst.testlist): + LOG.debug("Can't find the json file of config") + from xdevice import Scheduler + if Scheduler.device_labels: + config_file, test_type = _generate_config_file( + Scheduler.device_labels, + os.path.join(os.path.dirname(test_source), filename), + ext, test_type) + setattr(Scheduler, "tmp_json", config_file) + LOG.debug("Generate temp json success: %s" % config_file) + desc = _create_descriptor(config_file, filename, test_source, + test_type, config) + if desc: + test_descriptors.append(desc) + + return test_descriptors + + +def _create_descriptor(config_file, filename, test_source, test_type, config): + from xdevice import Scheduler + from _core.executor.request import Descriptor + + error_message = "" + if not test_type: + error_message = "no driver to execute '%s'" % test_source + LOG.error(error_message, error_no="00112") + if Scheduler.mode != ModeType.decc: + return None + + # create Descriptor + uid = unique_id("TestSource", filename) + module_name = _parse_module_name(config_file, filename) + desc = Descriptor(uuid=uid, name=filename, + source=TestSource(test_source, "", config_file, + filename, test_type, module_name)) + if not os.path.isfile(test_source): + if is_config_str(test_source): + desc = Descriptor(uuid=uid, name=filename, + source=TestSource("", test_source, config_file, + filename, test_type, + module_name)) + else: + if config.testcase and not config.testlist: + error_message = "test case '%s' or '%s' not exists" % ( + "%s%s" % (test_source, PY_SUFFIX), "%s%s" % ( + test_source, PYD_SUFFIX)) + error_no = "00103" + else: + error_message = "test source '%s' or '%s' not exists" % ( + test_source, "%s%s" % (test_source, MODULE_CONFIG_SUFFIX)) + error_no = "00102" + if Scheduler.mode != ModeType.decc: + raise ParamError(error_message, error_no=error_no) + + if Scheduler.mode == ModeType.decc and error_message: + Scheduler.report_not_executed(config.report_path, [("", desc)], + error_message) + return None + + return desc + + +def _get_config_file(filename, ext=None, config=None): + config_file = None + if os.path.exists("%s%s" % (filename, MODULE_CONFIG_SUFFIX)): + config_file = "%s%s" % (filename, MODULE_CONFIG_SUFFIX) + return config_file + if ext and os.path.exists("%s%s%s" % (filename, ext, + MODULE_CONFIG_SUFFIX)): + config_file = "%s%s%s" % (filename, ext, MODULE_CONFIG_SUFFIX) + return config_file + if config and getattr(config, "testcase", "") and not getattr( + config, "testlist"): + return _get_testcase_config_file(filename) + + return config_file + + +def _get_testcase_config_file(filename): + depth = 1 + dirname = os.path.dirname(filename) + while dirname and depth < MAX_DIR_DEPTH: + for item in os.listdir(dirname): + item_path = os.path.join(dirname, item) + if os.path.isfile(item_path) and item.endswith( + MODULE_CONFIG_SUFFIX): + return item_path + depth += 1 + dirname = os.path.dirname(dirname) + return None + + +def _get_component_info_file(entry_dir): + module_files = [] + if not os.path.isdir(entry_dir): + return module_files + for item in os.listdir(entry_dir): + item_path = os.path.join(entry_dir, item) + if os.path.isfile(item_path) and item_path.endswith( + MODULE_INFO_SUFFIX): + module_files.append(item_path) + return module_files + + +def _get_test_type(config_file, test_driver, ext): + if test_driver: + return test_driver + + if config_file: + if not os.path.exists(config_file): + LOG.error("Config file '%s' not exists" % config_file, + error_no="00110") + return "" + return _get_test_driver(config_file) + if ext in [".py", ".js", ".dex", ".hap", ".bin"] \ + and ext in TestDictSource.exe_type.keys(): + test_type = TestDictSource.exe_type[ext] + elif ext in [".apk"] and ext in TestDictSource.exe_type.keys(): + test_type = DeviceTestType.hap_test + else: + test_type = DeviceTestType.cpp_test + return test_type + + +def _parse_module_name(config_file, file_name): + if config_file: + return get_filename_extension(config_file)[0] + else: + if "{" in file_name: + return "report" + return file_name + + +def _generate_config_file(device_labels, filename, ext, test_type): + if test_type not in [HostDrivenTestType.device_test]: + test_type = HostDrivenTestType.device_test + top_dict = {"environment": [], "driver": {"type": test_type, + "py_file": "%s%s" % (filename, ext)}} + for label in device_labels: + device_json_list = top_dict.get("environment") + device_json_list.append({"type": "device", "label": label}) + + save_file = os.path.join(os.path.dirname(filename), + "%s.json" % os.path.basename(filename)) + save_file_open = \ + os.open(save_file, os.O_WRONLY | os.O_CREAT, FilePermission.mode_755) + with os.fdopen(save_file_open, "w") as save_handler: + save_handler.write(json.dumps(top_dict, indent=4)) + return save_file, test_type + + +def parse_source_from_data(content): + source_list = list() + data = dict(json.loads(content)) + case_dict = dict() + for item in data.get("suite", list()): + name = item.get("module_name", "") + case_dict.update({name: item}) + source_list.append(name) + return source_list, case_dict + +class TestDictSource: + exe_type = copy.deepcopy(EXT_TYPE_DICT) + test_type = copy.deepcopy(TEST_TYPE_DICT) + + @classmethod + def reset(cls): + cls.test_type = copy.deepcopy(TEST_TYPE_DICT) + cls.exe_type = copy.deepcopy(EXT_TYPE_DICT) + + @classmethod + def clear(cls): + cls.test_type.clear() + cls.exe_type.clear() diff --git a/xdevice/src/xdevice/_core/interface.py b/xdevice/src/xdevice/_core/interface.py new file mode 100644 index 0000000..1085fcf --- /dev/null +++ b/xdevice/src/xdevice/_core/interface.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from abc import ABC +from abc import abstractmethod +from enum import Enum + +__all__ = ["LifeCycle", "IDevice", "IDriver", "IListener", "IShellReceiver", + "IParser", "ITestKit", "IScheduler", "IDeviceManager", "IReporter", + "IFilter"] + + +class LifeCycle(Enum): + TestTask = "TestTask" + TestSuite = "TestSuite" + TestCase = "TestCase" + TestSuites = "TestSuites" + + +def _check_methods(class_info, *methods): + mro = class_info.__mro__ + for method in methods: + for cls in mro: + if method in cls.__dict__: + if cls.__dict__[method] is None: + return NotImplemented + break + else: + return NotImplemented + return True + + +class IDeviceManager(ABC): + """ + Class managing the set of different types of devices for testing + """ + __slots__ = () + support_labels = [] + support_types = [] + + @abstractmethod + def apply_device(self, device_option, timeout=10): + pass + + @abstractmethod + def release_device(self, device): + pass + + @abstractmethod + def reset_device(self, device): + pass + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IDevice: + return _check_methods(class_info, "__serial__") + return NotImplemented + + @abstractmethod + def init_environment(self, environment, user_config_file): + pass + + @abstractmethod + def env_stop(self): + pass + + @abstractmethod + def list_devices(self): + pass + + +class IDevice(ABC): + """ + IDevice provides an reliable and slightly higher level API to access + devices + """ + __slots__ = () + extend_value = {} + env_index = None + + @abstractmethod + def __set_serial__(self, device_sn=""): + pass + + @abstractmethod + def __get_serial__(self): + pass + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IDevice: + return _check_methods(class_info, "__serial__") + return NotImplemented + + @abstractmethod + def get(self, key=None, default=None): + if not key: + return default + value = getattr(self, key, None) + if value: + return value + else: + return self.extend_value.get(key, default) + + +class IDriver(ABC): + """ + A test driver runs the tests and reports results to a listener. + """ + __slots__ = () + + @classmethod + def __check_failed__(cls, msg): + raise ValueError(msg) + + @abstractmethod + def __check_environment__(self, device_options): + """ + Check environment correct or not. + You should return False when check failed. + :param device_options: + """ + + @abstractmethod + def __check_config__(self, config): + """ + Check config correct or not. + You should raise exception when check failed. + :param config: + """ + self.__check_failed__("Not implementation for __check_config__") + + @abstractmethod + def __execute__(self, request): + """ + Execute tests according to the request. + """ + + @classmethod + def __dry_run_execute__(self, request): + """ + Dry run tests according to the request. + """ + pass + + @abstractmethod + def __result__(self): + """ + Return tests execution result + """ + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IDriver: + return _check_methods(class_info, "__check_config__", + "__execute__") + return NotImplemented + + +class IScheduler(ABC): + """ + A scheduler to run jobs parallel. + """ + __slots__ = () + + @abstractmethod + def __discover__(self, args): + """ + Discover tests according to request, and return root TestDescriptor. + """ + + @abstractmethod + def __execute__(self, request): + """ + Execute tests according to the request. + """ + + @classmethod + @abstractmethod + def __allocate_environment__(cls, options, test_driver): + """ + Allocate environment according to the request. + """ + + @classmethod + @abstractmethod + def __free_environment__(cls, environment): + """ + Free environment to the request. + """ + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IScheduler: + return _check_methods(class_info, "__discover__", "__execute__") + return NotImplemented + + +class IListener(ABC): + """ + Listener to be notified of test execution events by TestDriver, as + following sequence: + __started__(TestTask) + __started__(TestSuite) + __started__(TestCase) + [__skipped__(TestCase)] + [__failed__(TestCase)] + __ended__(TestCase) + ... + [__failed__(TestSuite)] + __ended__(TestSuite) + ... + [__failed__(TestTask)] + __ended__(TestTask) + """ + __slots__ = () + + @abstractmethod + def __started__(self, lifecycle, result): + """ + Called when the execution of the TestCase or TestTask has started, + before any test has been executed. + """ + + @abstractmethod + def __ended__(self, lifecycle, result, **kwargs): + """ + Called when the execution of the TestCase or TestTask has finished, + after all tests have been executed. + """ + + @abstractmethod + def __skipped__(self, lifecycle, result): + """ + Called when the execution of the TestCase or TestTask has been skipped. + """ + + @abstractmethod + def __failed__(self, lifecycle, result): + """ + Called when the execution of the TestCase or TestTask has been skipped. + """ + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IListener: + return _check_methods(class_info, "__started__", "__ended__", + "__skipped__", "__failed__") + return NotImplemented + + +class IShellReceiver(ABC): + """ + Read the output from shell out. + """ + __slots__ = () + + @abstractmethod + def __read__(self, output): + pass + + @abstractmethod + def __error__(self, message): + pass + + @abstractmethod + def __done__(self, result_code, message): + pass + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IShellReceiver: + return _check_methods(class_info, "__read__", "__error__", + "__done__") + return NotImplemented + + +class IParser(ABC): + """ + A parser to parse the output of testcases. + """ + __slots__ = () + + @abstractmethod + def __process__(self, lines): + pass + + @abstractmethod + def __done__(self): + pass + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IParser: + return _check_methods(class_info, "__process__", "__done__") + return NotImplemented + + +class ITestKit(ABC): + """ + A test kit running on the host. + """ + __slots__ = () + + @classmethod + def __check_failed__(cls, msg): + raise ValueError(msg) + + @abstractmethod + def __check_config__(self, config): + """ + Check config correct or not. + You should raise exception when check failed. + :param config: + """ + self.__check_failed__("Not implementation for __check_config__") + + @abstractmethod + def __setup__(self, device, **kwargs): + pass + + @abstractmethod + def __teardown__(self, device): + pass + + @classmethod + def __subclasshook__(cls, class_info): + if cls is ITestKit: + return _check_methods(class_info, "__check_config__", "__setup__", + "__teardown__") + return NotImplemented + + +class IReporter(ABC): + """ + A reporter to generate reports + """ + __slots__ = () + + @abstractmethod + def __generate_reports__(self, report_path, **kwargs): + pass + + @classmethod + def __subclasshook__(cls, class_info): + if cls is IReporter: + return _check_methods(class_info, "__generate_reports__") + return NotImplemented + + +class IFilter(ABC): + """ + A filter is used to filter xml node and selector on the manager + """ + __slots__ = () + + @abstractmethod + def __filter_xml_node__(self, node): + pass + + @abstractmethod + def __filter_selector__(self, selector): + pass diff --git a/xdevice/src/xdevice/_core/logger.py b/xdevice/src/xdevice/_core/logger.py new file mode 100644 index 0000000..8e4cc19 --- /dev/null +++ b/xdevice/src/xdevice/_core/logger.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import logging +import sys +import time +import threading +import queue +from logging.handlers import RotatingFileHandler + +from _core.constants import LogType +from _core.plugin import Plugin +from _core.plugin import get_plugin +from _core.exception import ParamError + + +__all__ = ["Log", "platform_logger", "device_logger", "shutdown", + "add_task_file_handler", "remove_task_file_handler", + "change_logger_level", "add_encrypt_file_handler", + "remove_encrypt_file_handler", "LogQueue"] + +_HANDLERS = [] +_LOGGERS = [] +MAX_LOG_LENGTH = 20 * 1024 * 1024 +MAX_ENCRYPT_LOG_LENGTH = 5 * 1024 * 1024 +MAX_LOG_NUMS = 1000 +MAX_LOG_CACHE_SIZE = 10 + + + +class Log: + task_file_handler = None + + def __init__(self): + self.level = logging.INFO + self.handlers = [] + self.loggers = {} + self.task_file_handler = None + self.encrypt_file_handler = None + + def __initial__(self, log_handler_flag, log_file=None, level=None, + log_format=None): + if _LOGGERS: + return + + self.handlers = [] + if log_file and "console" in log_handler_flag: + file_handler = RotatingFileHandler( + log_file, mode="a", maxBytes=MAX_LOG_LENGTH, backupCount=MAX_LOG_NUMS, + encoding="UTF-8") + file_handler.setFormatter(logging.Formatter(log_format)) + self.handlers.append(file_handler) + if "console" in log_handler_flag: + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(logging.Formatter(log_format)) + self.handlers.append(stream_handler) + + if level: + self.level = level + self.loggers = {} + self.task_file_handler = None + _HANDLERS.extend(self.handlers) + + def set_level(self, level): + self.level = level + + def __logger__(self, name=None): + if not name: + return _init_global_logger(name) + elif name in self.loggers: + return self.loggers.get(name) + else: + log = self.loggers.setdefault(name, FrameworkLog(name)) + _LOGGERS.append(log) + log.platform_log.setLevel(self.level) + for handler in self.handlers: + log.platform_log.addHandler(handler) + if self.task_file_handler: + log.add_task_log(self.task_file_handler) + return log + + def add_task_file_handler(self, log_file): + from xdevice import Variables + file_handler = RotatingFileHandler( + log_file, mode="a", maxBytes=MAX_LOG_LENGTH, backupCount=MAX_LOG_NUMS, + encoding="UTF-8") + file_handler.setFormatter(logging.Formatter( + Variables.report_vars.log_format)) + self.task_file_handler = file_handler + for _, log in self.loggers.items(): + log.add_task_log(self.task_file_handler) + + def remove_task_file_handler(self): + if self.task_file_handler is None: + return + for _, log in self.loggers.items(): + log.remove_task_log(self.task_file_handler) + self.task_file_handler.close() + self.task_file_handler = None + + def add_encrypt_file_handler(self, log_file): + from xdevice import Variables + + file_handler = \ + EncryptFileHandler(log_file, mode="ab", + max_bytes=MAX_ENCRYPT_LOG_LENGTH, + backup_count=MAX_LOG_NUMS, encoding="utf-8") + file_handler.setFormatter(logging.Formatter( + Variables.report_vars.log_format)) + self.encrypt_file_handler = file_handler + for _, log in self.loggers.items(): + log.add_encrypt_log(self.encrypt_file_handler) + + def remove_encrypt_file_handler(self): + if self.encrypt_file_handler is None: + return + for _, log in self.loggers.items(): + log.remove_encrypt_log(self.encrypt_file_handler) + self.encrypt_file_handler.close() + self.encrypt_file_handler = None + + +class FrameworkLog: + + def __init__(self, name): + self.name = name + self.platform_log = logging.Logger(name) + self.task_log = None + self.encrypt_log = None + + def set_level(self, level): + # apply to dynamic change logger level, and + # only change the level of platform log + cache = getattr(self.platform_log, "_cache", None) + if cache and isinstance(cache, dict): + cache.clear() + self.platform_log.setLevel(level) + + def add_task_log(self, handler): + if self.task_log: + return + self.task_log = logging.Logger(self.name) + log_level = getattr(sys, "log_level", logging.INFO) if hasattr( + sys, "log_level") else logging.DEBUG + self.task_log.setLevel(log_level) + self.task_log.addHandler(handler) + + def remove_task_log(self, handler): + if not self.task_log: + return + self.task_log.removeHandler(handler) + self.task_log = None + + def add_encrypt_log(self, handler): + if self.encrypt_log: + return + self.encrypt_log = logging.Logger(self.name) + log_level = getattr(sys, "log_level", logging.INFO) if hasattr( + sys, "log_level") else logging.DEBUG + self.encrypt_log.setLevel(log_level) + self.encrypt_log.addHandler(handler) + + def remove_encrypt_log(self, handler): + if not self.encrypt_log: + return + self.encrypt_log.removeHandler(handler) + self.encrypt_log = None + + def info(self, msg, *args, **kwargs): + additional_output = self._get_additional_output(**kwargs) + updated_msg = self._update_msg(additional_output, msg) + self.platform_log.info(updated_msg, *args) + if self.task_log: + self.task_log.info(updated_msg, *args) + if self.encrypt_log: + self.encrypt_log.info(updated_msg, *args) + + def debug(self, msg, *args, **kwargs): + additional_output = self._get_additional_output(**kwargs) + updated_msg = self._update_msg(additional_output, msg) + from _core.report.encrypt import check_pub_key_exist + if not check_pub_key_exist(): + self.platform_log.debug(updated_msg, *args) + if self.task_log: + self.task_log.debug(updated_msg, *args) + else: + if self.encrypt_log: + self.encrypt_log.debug(updated_msg, *args) + + def error(self, msg, *args, **kwargs): + error_no = kwargs.get("error_no", "00000") + additional_output = self._get_additional_output(error_no, **kwargs) + updated_msg = self._update_msg(additional_output, msg) + + self.platform_log.error(updated_msg, *args) + if self.task_log: + self.task_log.error(updated_msg, *args) + if self.encrypt_log: + self.encrypt_log.error(updated_msg, *args) + + def warning(self, msg, *args, **kwargs): + additional_output = self._get_additional_output(**kwargs) + updated_msg = self._update_msg(additional_output, msg) + + self.platform_log.warning(updated_msg, *args) + if self.task_log: + self.task_log.warning(updated_msg, *args) + if self.encrypt_log: + self.encrypt_log.warning(updated_msg, *args) + + def exception(self, msg, *args, **kwargs): + error_no = kwargs.get("error_no", "00000") + exc_info = kwargs.get("exc_info", True) + if exc_info is not True and exc_info is not False: + exc_info = True + additional_output = self._get_additional_output(error_no, **kwargs) + updated_msg = self._update_msg(additional_output, msg) + + self.platform_log.exception(updated_msg, exc_info=exc_info, *args) + if self.task_log: + self.task_log.exception(updated_msg, exc_info=exc_info, *args) + if self.encrypt_log: + self.encrypt_log.exception(updated_msg, exc_info=exc_info, *args) + + @classmethod + def _update_msg(cls, additional_output, msg): + msg = "[%s]" % msg if msg else msg + if msg and additional_output: + msg = "%s [%s]" % (msg, additional_output) + return msg + + def _get_additional_output(self, error_number=None, **kwargs): + dict_str = self._get_dict_str(**kwargs) + if error_number: + additional_output = "ErrorNo=%s" % error_number + else: + return dict_str + + if dict_str: + additional_output = "%s, %s" % (additional_output, dict_str) + return additional_output + + @classmethod + def _get_dict_str(cls, **kwargs): + dict_str = "" + for key, value in kwargs.items(): + if key in ["error_no", "exc_info"]: + continue + dict_str = "%s%s=%s, " % (dict_str, key, value) + if dict_str: + dict_str = dict_str[:-2] + return dict_str + + +def platform_logger(name=None): + plugins = get_plugin(Plugin.LOG, LogType.tool) + for log_plugin in plugins: + if log_plugin.get_plugin_config().enabled: + return log_plugin.__logger__(name) + return _init_global_logger(name) + + +def device_logger(name=None): + plugins = get_plugin(Plugin.LOG, LogType.device) + for log_plugin in plugins: + if log_plugin.get_plugin_config().enabled: + return log_plugin.__logger__(name) + return _init_global_logger(name) + + +def shutdown(): + # logging will be shutdown automatically, when the program exits. + # This function is used by testing. + for log in _LOGGERS: + for handler in log.handlers: + log.removeHandler(handler) + for handler in _HANDLERS: + handler.close() + _HANDLERS.clear() + _LOGGERS.clear() + + +def add_task_file_handler(log_file=None): + if log_file is None: + return + plugins = get_plugin(Plugin.LOG, LogType.tool) + for log_plugin in plugins: + if log_plugin.get_plugin_config().enabled: + log_plugin.add_task_file_handler(log_file) + + +def remove_task_file_handler(): + plugins = get_plugin(Plugin.LOG, LogType.tool) + for log_plugin in plugins: + if log_plugin.get_plugin_config().enabled: + log_plugin.remove_task_file_handler() + + +def add_encrypt_file_handler(log_file=None): + if log_file is None: + return + plugins = get_plugin(Plugin.LOG, LogType.tool) + for log_plugin in plugins: + if log_plugin.get_plugin_config().enabled: + log_plugin.add_encrypt_file_handler(log_file) + + +def remove_encrypt_file_handler(): + plugins = get_plugin(Plugin.LOG, LogType.tool) + for log_plugin in plugins: + if log_plugin.get_plugin_config().enabled: + log_plugin.remove_encrypt_file_handler() + + +def _init_global_logger(name=None): + handler = logging.StreamHandler(sys.stdout) + log_format = \ + "[%(asctime)s] [%(thread)d] [%(name)s] [%(levelname)s] [%(message)s]" + handler.setFormatter(logging.Formatter(log_format)) + log = FrameworkLog(name) + log.platform_log.setLevel(logging.INFO) + log.platform_log.addHandler(handler) + return log + + +def change_logger_level(leve_dict): + level_map = {"debug": logging.DEBUG, "info": logging.INFO} + if "console" in leve_dict.keys(): + level = leve_dict["console"] + if not level: + return + if str(level).lower() in level_map.keys(): + logger_level = level_map.get(str(level).lower(), logging.INFO) + + # change level of loggers which will to be instantiated. + # Actually, it changes the level attribute in ToolLog, + # which will be used when instantiating the FrameLog object. + plugins = get_plugin(Plugin.LOG, LogType.tool) + for log_plugin in plugins: + log_plugin.set_level(logger_level) + # change level of loggers which have instantiated + for logger in _LOGGERS: + if getattr(logger, "setLevel", None): + logger.setLevel(logger_level) + elif getattr(logger, "set_level", None): + logger.set_level(logger_level) + + if "file" in leve_dict.keys(): + level = leve_dict["file"] + if not level: + return + if str(level).lower() in level_map.keys(): + logger_level = level_map.get(str(level).lower(), logging.INFO) + setattr(sys, "log_level", logger_level) + + +class EncryptFileHandler(RotatingFileHandler): + + def __init__(self, filename, mode='ab', max_bytes=0, backup_count=0, + encoding=None, delay=False): + RotatingFileHandler.__init__(self, filename, mode, max_bytes, + backup_count, encoding, delay) + self.mode = mode + self.encrypt_error = None + + def _open(self): + if not self.mode == "ab": + self.mode = "ab" + + # baseFilename is the attribute in FileHandler + base_file_name = getattr(self, "baseFilename", None) + with open(base_file_name, self.mode) as handler: + return handler + + def emit(self, record): + try: + if not self._encrypt_valid(): + return + + # shouldRoller and doRoller is the method in RotatingFileHandler + should_rollover = getattr(self, "shouldRollover", None) + if callable(should_rollover) and should_rollover(record): + self.doRollover() + + # stream is the attribute in StreamHandler + if not getattr(self, "stream", None): + setattr(self, "stream", self._open()) + msg = self.format(record) + stream = getattr(self, "stream", self._open()) + stream.write(msg) + self.flush() + except RecursionError as error: # pylint:disable=undefined-variable + raise error + + def _encrypt_valid(self): + from _core.report.encrypt import check_pub_key_exist + if check_pub_key_exist() and not self.encrypt_error: + return True + + def format(self, record): + """ + Customize the implementation method. If the log format of the + framework changes, update the return value format of the method + in a timely manner. + :param record: logging.LogRecord + :return: bytes + """ + from _core.report.encrypt import do_rsa_encrypt + create_time = "{},{}".format( + time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(record.created)), + "{:0>3d}".format(int("%d" % record.msecs))) + name = record.name + level_name = record.levelname + msg = record.msg + if msg and "%s" in msg: + msg = msg % record.args + info = "[%s] [%s] [%s] [%s] %s%s" \ + % (create_time, threading.currentThread().ident, name, + level_name, msg, "\n") + + try: + return do_rsa_encrypt(info) + except ParamError as error: + error_no_str = \ + "ErrorNo={}".format(getattr(error, "error_no", "00113")) + info = "[%s] [%s] [%s] [%s] [%s] [%s]\n" % ( + create_time, threading.currentThread().ident, + name, "ERROR", error, error_no_str) + self.encrypt_error = bytes(info, "utf-8") + return self.encrypt_error + + +class LogQueue: + log = None + max_size = 0 + queue_info = None + queue_debug = None + queue_error = None + + def __init__(self, log, max_size=MAX_LOG_CACHE_SIZE): + self.log = log + self.max_size = max_size + self.queue_info = queue.Queue(maxsize=self.max_size) + self.queue_debug = queue.Queue(maxsize=self.max_size) + self.queue_error = queue.Queue(maxsize=self.max_size) + + def _put(self, log_queue, log_data, clear): + is_print = False + result_data = "" + if log_queue.full() or clear: + # make sure the last one print + if log_queue.qsize() > 0: + is_print = True + result_data = "{}\n".format(log_queue.get()) + else: + result_data = "" + if log_data != "": + log_queue.put(log_data) + while not log_queue.empty(): + is_print = True + result_data = "{} [{}] {}\n".format(result_data, threading.currentThread().ident, log_queue.get()) + else: + if log_data != "": + log_queue.put(log_data) + return is_print, result_data + + def info(self, log_data, clear=False): + is_print, result_data = self._put(self.queue_info, log_data, clear) + if is_print: + self.log.info(result_data) + + def debug(self, log_data, clear=False): + is_print, result_data = self._put(self.queue_debug, log_data, clear) + if is_print: + self.log.debug(result_data) + + def error(self, log_data, clear=False): + is_print, result_data = self._put(self.queue_error, log_data, clear) + if is_print: + self.log.error(result_data) + + def clear(self): + self.info(log_data="", clear=True) + self.debug(log_data="", clear=True) + self.error(log_data="", clear=True) diff --git a/xdevice/src/xdevice/_core/plugin.py b/xdevice/src/xdevice/_core/plugin.py new file mode 100644 index 0000000..f10c24c --- /dev/null +++ b/xdevice/src/xdevice/_core/plugin.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +from inspect import signature + +from _core.interface import IDriver +from _core.interface import IParser +from _core.interface import IListener +from _core.interface import IScheduler +from _core.interface import IDevice +from _core.interface import ITestKit +from _core.interface import IDeviceManager +from _core.interface import IReporter + +__all__ = ["Config", "Plugin", "get_plugin", "set_plugin_params", + "get_all_plugins", "clear_plugin_cache"] + +# plugins dict +_PLUGINS = dict() +# plugin config name +_DEFAULT_CONFIG_NAME = "_plugin_config_" + + +class Config: + """ + The common configuration + """ + + def __init__(self, params=None): + if params is None: + params = {} + self.update(params) + + def __getitem__(self, item): + return self.__dict__[item] + + def __setitem__(self, key, value): + self.__dict__[key] = value + + def update(self, params): + self.__dict__.update(params) + + def get(self, key, default=""): + return self.__dict__.get(key, default) + + def set(self, key, value): + self.__dict__[key] = value + + +class Plugin(object): + """ + Plugin decorator with parameters. You can decorate one class as following: + @Plugin("type_name"), the default plugin id is the same as TypeName. + @Plugin(type="type_name", id="plugin_id") + """ + SCHEDULER = "scheduler" + DRIVER = "driver" + DEVICE = "device" + LOG = "log" + PARSER = "parser" + LISTENER = "listener" + TEST_KIT = "testkit" + MANAGER = "manager" + REPORTER = "reporter" + + _builtin_plugin = dict({ + SCHEDULER: [IScheduler], + DRIVER: [IDriver], + DEVICE: [IDevice], + PARSER: [IParser], + LISTENER: [IListener], + TEST_KIT: [ITestKit], + MANAGER: [IDeviceManager], + REPORTER: [IReporter] + }) + + def __init__(self, *args, **kwargs): + _param_dict = dict(kwargs) + if len(args) == 1 and type(args[0]) == str: + self.plugin_type = str(args[0]) + self.plugin_id = str(args[0]) + elif "id" in _param_dict.keys() and "type" in _param_dict.keys(): + self.plugin_type = _param_dict["type"] + self.plugin_id = _param_dict["id"] + del _param_dict["type"] + del _param_dict["id"] + else: + raise ValueError( + '@Plugin must be specify type and id attributes. such as ' + '@Plugin("plugin_type") or ' + '@Plugin(type="plugin_type", id="plugin_id")') + self.params = _param_dict + + def __call__(self, cls): + if hasattr(cls, _DEFAULT_CONFIG_NAME): + raise TypeError( + "'{}' attribute is not allowed for plugin {} .".format( + _DEFAULT_CONFIG_NAME, cls.__name__)) + setattr(cls, _DEFAULT_CONFIG_NAME, Config(self.params)) + + init_func = getattr(cls, "__init__", None) + if init_func and type( + init_func).__name__ != "wrapper_descriptor" and len( + signature(init_func).parameters) != 1: + raise TypeError( + "__init__ method must be no arguments for plugin {} .".format( + cls.__name__)) + + if hasattr(cls, "get_plugin_config"): + raise TypeError( + "'{}' method is not allowed for plugin {} .".format( + "get_plugin_config", cls.__name__)) + + def get_plugin_config(obj): + del obj + return getattr(cls, _DEFAULT_CONFIG_NAME) + + setattr(cls, "get_plugin_config", get_plugin_config) + + instance = cls() + interfaces = self._builtin_plugin.get(self.plugin_type, []) + for interface in interfaces: + if not isinstance(instance, interface): + raise TypeError( + "{} plugin must implement {} interface.".format( + cls.__name__, interface)) + + if "xdevice" in str(instance.__class__).lower(): + _PLUGINS.setdefault((self.plugin_type, self.plugin_id), []).append( + instance) + else: + _PLUGINS.setdefault((self.plugin_type, self.plugin_id), []).insert( + 0, instance) + + return cls + + def get_params(self): + return self.params + + def get_builtin_plugin(self): + return self._builtin_plugin + + +def get_plugin(plugin_type, plugin_id=None): + """ + Get plugin instance + :param plugin_type: plugin type + :param plugin_id: plugin id + :return: the instance list of plugin + """ + if plugin_id is None: + plugins = [] + for key in _PLUGINS: + if key[0] != plugin_type: + continue + if not _PLUGINS.get(key): + continue + if key[1] == plugin_type: + plugins.insert(0, _PLUGINS.get(key)[0]) + else: + plugins.append(_PLUGINS.get(key)[0]) + return plugins + + else: + return _PLUGINS.get((plugin_type, plugin_id), []) + + +def set_plugin_params(plugin_type, plugin_id=None, **kwargs): + """ + Set plugin parameters + :param plugin_type: plugin type + :param plugin_id: plugin id + :param kwargs: the parameters for plugin + :return: + """ + if plugin_id is None: + plugin_id = plugin_type + plugins = get_plugin(plugin_type, plugin_id) + if len(plugins) == 0: + raise ValueError("Can not find the plugin %s" % plugin_id) + for plugin in plugins: + params = getattr(plugin, _DEFAULT_CONFIG_NAME) + params.update(kwargs) + + +def get_all_plugins(): + """ + Get all plugins + """ + return dict(_PLUGINS) + + +def clear_plugin_cache(): + """ + Clear all cached plugins + """ + _PLUGINS.clear() diff --git a/xdevice/src/xdevice/_core/report/__init__.py b/xdevice/src/xdevice/_core/report/__init__.py new file mode 100644 index 0000000..f82e31d --- /dev/null +++ b/xdevice/src/xdevice/_core/report/__init__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))))) +sys.path.insert(1, os.path.join(os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))) diff --git a/xdevice/src/xdevice/_core/report/__main__.py b/xdevice/src/xdevice/_core/report/__main__.py new file mode 100644 index 0000000..d6a6b65 --- /dev/null +++ b/xdevice/src/xdevice/_core/report/__main__.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import sys +import time + +from _core.logger import platform_logger +from _core.report.reporter_helper import ExecInfo +from _core.report.reporter_helper import ReportConstant +from _core.report.result_reporter import ResultReporter +from _core.utils import is_python_satisfied + +LOG = platform_logger("ReportMain") + + +def main_report(): + if not is_python_satisfied(): + return + + args = sys.argv + if args is None or len(args) < 2: + report_path = input("report path >>> ") + else: + report_path = args[1] + + exec_dir = os.getcwd() + if not os.path.isabs(report_path): + report_path = os.path.abspath(os.path.join(exec_dir, report_path)) + + if not os.path.exists(report_path): + LOG.error("Report path %s not exists", report_path) + return + + LOG.info("Report path: %s", report_path) + task_info = ExecInfo() + task_info.platform = "None" + task_info.test_type = "Test" + task_info.device_name = "None" + task_info.test_time = time.strftime(ReportConstant.time_format, + time.localtime()) + result_report = ResultReporter() + result_report.__generate_reports__(report_path, task_info=task_info) + + +if __name__ == "__main__": + main_report() diff --git a/xdevice/src/xdevice/_core/report/encrypt.py b/xdevice/src/xdevice/_core/report/encrypt.py new file mode 100644 index 0000000..136df07 --- /dev/null +++ b/xdevice/src/xdevice/_core/report/encrypt.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import hashlib + +from _core.logger import platform_logger +from _core.exception import ParamError +from _core.constants import FilePermission + +__all__ = ["check_pub_key_exist", "do_rsa_encrypt", "do_rsa_decrypt", + "generate_key_file", "get_file_summary"] + +PUBLIC_KEY_FILE = "config/pub.key" +PRIVATE_KEY_FILE = "config/pri.key" +LOG = platform_logger("Encrypt") + + +def check_pub_key_exist(): + from xdevice import Variables + if Variables.report_vars.pub_key_string: + return Variables.report_vars.pub_key_string + + if Variables.report_vars.pub_key_file is not None: + if Variables.report_vars.pub_key_file == "": + return False + if not os.path.exists(Variables.report_vars.pub_key_file): + Variables.report_vars.pub_key_file = None + return False + return True + + pub_key_path = os.path.join(Variables.exec_dir, PUBLIC_KEY_FILE) + if os.path.exists(pub_key_path): + Variables.report_vars.pub_key_file = pub_key_path + return True + + pub_key_path = os.path.join(Variables.top_dir, PUBLIC_KEY_FILE) + if os.path.exists(pub_key_path): + Variables.report_vars.pub_key_file = pub_key_path + else: + Variables.report_vars.pub_key_file = "" + return Variables.report_vars.pub_key_file + + +def do_rsa_encrypt(content): + try: + if not check_pub_key_exist() or not content: + return content + + plain_text = content + if not isinstance(plain_text, bytes): + plain_text = str(content).encode(encoding='utf-8') + + import rsa + from xdevice import Variables + if not Variables.report_vars.pub_key_string: + with open(Variables.report_vars.pub_key_file, + 'rb') as key_content: + Variables.report_vars.pub_key_string = key_content.read() + + if isinstance(Variables.report_vars.pub_key_string, str): + Variables.report_vars.pub_key_string =\ + bytes(Variables.report_vars.pub_key_string, "utf-8") + + public_key = rsa.PublicKey.load_pkcs1_openssl_pem( + Variables.report_vars.pub_key_string) + + max_encrypt_len = int(public_key.n.bit_length() / 8) - 11 + + # encrypt + cipher_text = b"" + for frag in _get_frags(plain_text, max_encrypt_len): + cipher_text_frag = rsa.encrypt(frag, public_key) + cipher_text += cipher_text_frag + return cipher_text + + except (ModuleNotFoundError, ValueError, TypeError, UnicodeError, + Exception) as error: + error_msg = "rsa encryption error occurs, %s" % error.args[0] + raise ParamError(error_msg, error_no="00113") + + +def do_rsa_decrypt(content): + try: + if not check_pub_key_exist() or not content: + return content + + cipher_text = content + if not isinstance(cipher_text, bytes): + cipher_text = str(content).encode() + + import rsa + from xdevice import Variables + pri_key_path = os.path.join(Variables.exec_dir, PRIVATE_KEY_FILE) + if os.path.exists(pri_key_path): + pri_key_file = pri_key_path + else: + pri_key_file = os.path.join(Variables.top_dir, PRIVATE_KEY_FILE) + if not os.path.exists(pri_key_file): + return content + with open(pri_key_file, "rb") as key_content: + # get params + pri_key = rsa.PrivateKey.load_pkcs1(key_content.read()) + max_decrypt_len = int(pri_key.n.bit_length() / 8) + + try: + # decrypt + plain_text = b"" + for frag in _get_frags(cipher_text, max_decrypt_len): + plain_text_frag = rsa.decrypt(frag, pri_key) + plain_text += plain_text_frag + return plain_text.decode(encoding='utf-8') + except rsa.pkcs1.CryptoError as error: + error_msg = "rsa decryption error occurs, %s" % error.args[0] + LOG.error(error_msg, error_no="00114") + return error_msg + + except (ModuleNotFoundError, ValueError, TypeError, UnicodeError) as error: + error_msg = "rsa decryption error occurs, %s" % error.args[0] + LOG.error(error_msg, error_no="00114") + return error_msg + + +def generate_key_file(length=2048): + try: + from rsa import key + + if int(length) not in [1024, 2048, 3072, 4096]: + LOG.error("Length should be 1024, 2048, 3072 or 4096") + return + + pub_key, pri_key = key.newkeys(int(length)) + pub_key_pem = pub_key.save_pkcs1().decode() + pri_key_pem = pri_key.save_pkcs1().decode() + + file_pri_open = os.open("pri.key", os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + file_pub_open = os.open("pub.key", os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + with os.fdopen(file_pri_open, "w") as file_pri, \ + os.fdopen(file_pub_open, "w") as file_pub: + file_pri.write(pri_key_pem) + file_pri.flush() + file_pub.write(pub_key_pem) + file_pub.flush() + except ModuleNotFoundError as _: + return + + +def get_file_summary(src_file, algorithm="sha256", buffer_size=100 * 1024): + if not os.path.exists(src_file): + LOG.error("File '%s' not exists!" % src_file) + return "" + + # if the size of file is large, use this function + def _read_file(_src_file): + while True: + _data = _src_file.read(buffer_size) + if not _data: + break + yield _data + + if hasattr(hashlib, algorithm): + algorithm_object = hashlib.new(algorithm) + try: + with open(file=src_file, mode="rb") as _file: + for data in _read_file(_file): + algorithm_object.update(data) + except ValueError as error: + LOG.error("Read data from '%s' error: %s " % ( + src_file, error.args)) + return "" + return algorithm_object.hexdigest() + else: + LOG.error("The algorithm '%s' not in hashlib!" % algorithm) + return "" + + +def _get_frags(text, max_len): + _text = text + while _text: + if len(_text) > max_len: + frag, _text = _text[:max_len], _text[max_len:] + else: + frag, _text = _text, "" + yield frag diff --git a/xdevice/src/xdevice/_core/report/reporter_helper.py b/xdevice/src/xdevice/_core/report/reporter_helper.py new file mode 100644 index 0000000..63d0a6b --- /dev/null +++ b/xdevice/src/xdevice/_core/report/reporter_helper.py @@ -0,0 +1,1025 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import platform +import time +from ast import literal_eval +from dataclasses import dataclass +from xml.etree import ElementTree + +from _core.logger import platform_logger +from _core.report.encrypt import check_pub_key_exist +from _core.report.encrypt import do_rsa_encrypt +from _core.exception import ParamError +from _core.constants import FilePermission + +LOG = platform_logger("ReporterHelper") + + +@dataclass +class ReportConstant: + # report name constants + summary_data_report = "summary_report.xml" + summary_vision_report = "summary_report.html" + details_vision_report = "details_report.html" + failures_vision_report = "failures_report.html" + task_info_record = "task_info.record" + summary_ini = "summary.ini" + summary_report_hash = "summary_report.hash" + title_name = "title_name" + summary_title = "Summary Report" + details_title = "Details Report" + failures_title = "Failures Report" + + # exec_info constants + platform = "platform" + test_type = "test_type" + device_name = "device_name" + host_info = "host_info" + test_time = "test_time" + log_path = "log_path" + log_path_title = "Log Path" + execute_time = "execute_time" + + # summary constants + product_info = "productinfo" + product_info_ = "product_info" + modules = "modules" + run_modules = "runmodules" + run_modules_ = "run_modules" + name = "name" + time = "time" + total = "total" + tests = "tests" + passed = "passed" + errors = "errors" + disabled = "disabled" + failures = "failures" + blocked = "blocked" + ignored = "ignored" + completed = "completed" + unavailable = "unavailable" + not_run = "notrun" + message = "message" + + # case result constants + module_name = "modulename" + module_name_ = "module_name" + result = "result" + status = "status" + run = "run" + true = "true" + false = "false" + skip = "skip" + disable = "disable" + class_name = "classname" + level = "level" + empty_name = "-" + + # time constants + time_stamp = "timestamp" + start_time = "starttime" + end_time = "endtime" + time_format = "%Y-%m-%d %H:%M:%S" + + # xml tag constants + test_suites = "testsuites" + test_suite = "testsuite" + test_case = "testcase" + + # report title constants + failed = "failed" + error = "error" + color_normal = "color-normal" + color_failed = "color-failed" + color_blocked = "color-blocked" + color_ignored = "color-ignored" + color_unavailable = "color-unavailable" + + +class DataHelper: + LINE_BREAK = "\n" + LINE_BREAK_INDENT = "\n " + INDENT = " " + DATA_REPORT_SUFFIX = ".xml" + + def __init__(self): + pass + + @staticmethod + def parse_data_report(data_report): + if "<" not in data_report and os.path.exists(data_report): + with open(data_report, 'r', encoding='UTF-8', errors="ignore") as \ + file_content: + data_str = file_content.read() + else: + data_str = data_report + + for char_index in range(32): + if char_index in [10, 13]: # chr(10): LF, chr(13): CR + continue + data_str = data_str.replace(chr(char_index), "") + try: + return ElementTree.fromstring(data_str) + except SyntaxError as error: + LOG.error("%s %s", data_report, error.args) + return ElementTree.Element("empty") + + @staticmethod + def set_element_attributes(element, element_attributes): + for key, value in element_attributes.items(): + element.set(key, str(value)) + + @classmethod + def initial_element(cls, tag, tail, text): + element = ElementTree.Element(tag) + element.tail = tail + element.text = text + return element + + def initial_suites_element(self): + return self.initial_element(ReportConstant.test_suites, + self.LINE_BREAK, self.LINE_BREAK_INDENT) + + def initial_suite_element(self): + return self.initial_element(ReportConstant.test_suite, + self.LINE_BREAK_INDENT, + self.LINE_BREAK_INDENT + self.INDENT) + + def initial_case_element(self): + return self.initial_element(ReportConstant.test_case, + self.LINE_BREAK_INDENT + self.INDENT, "") + + @classmethod + def update_suite_result(cls, suite, case): + update_time = round(float(suite.get( + ReportConstant.time, 0)) + float( + case.get(ReportConstant.time, 0)), 3) + suite.set(ReportConstant.time, str(update_time)) + update_tests = str(int(suite.get(ReportConstant.tests, 0))+1) + suite.set(ReportConstant.tests, update_tests) + if case.findall('failure'): + update_failures = str(int(suite.get(ReportConstant.failures, 0))+1) + suite.set(ReportConstant.failures, update_failures) + + @classmethod + def get_summary_result(cls, report_path, file_name, key=None, **kwargs): + reverse = kwargs.get("reverse", False) + file_prefix = kwargs.get("file_prefix", None) + data_reports = cls._get_data_reports(report_path, file_prefix) + if not data_reports: + return + if key: + data_reports.sort(key=key, reverse=reverse) + summary_result = None + need_update_attributes = [ReportConstant.tests, ReportConstant.errors, + ReportConstant.failures, + ReportConstant.disabled, + ReportConstant.unavailable] + for data_report in data_reports: + data_report_element = cls.parse_data_report(data_report) + if not len(list(data_report_element)): + continue + if not summary_result: + summary_result = data_report_element + continue + if not summary_result or not data_report_element: + continue + for data_suite in data_report_element: + for summary_suite in summary_result: + if data_suite.get("name", None) == \ + summary_suite.get("name", None): + for data_case in data_suite: + for summary_case in summary_suite: + if data_case.get("name", None) == \ + summary_case.get("name", None): + break + else: + summary_suite.append(data_case) + DataHelper.update_suite_result(summary_result, + data_case) + DataHelper.update_suite_result(summary_suite, + data_case) + break + else: + summary_result.append(data_suite) + DataHelper._update_attributes(summary_result, data_suite, + need_update_attributes) + if summary_result: + cls.generate_report(summary_result, file_name) + return summary_result + + @classmethod + def _get_data_reports(cls, report_path, file_prefix=None): + if not os.path.isdir(report_path): + return [] + data_reports = [] + for root, _, files in os.walk(report_path): + for file_name in files: + if not file_name.endswith(cls.DATA_REPORT_SUFFIX): + continue + if file_prefix and not file_name.startswith(file_prefix): + continue + data_reports.append(os.path.join(root, file_name)) + return data_reports + + @classmethod + def _update_attributes(cls, summary_element, data_element, + need_update_attributes): + for attribute in need_update_attributes: + updated_value = int(summary_element.get(attribute, 0)) + \ + int(data_element.get(attribute, 0)) + summary_element.set(attribute, str(updated_value)) + # update time + updated_time = round(float(summary_element.get( + ReportConstant.time, 0)) + float( + data_element.get(ReportConstant.time, 0)), 3) + summary_element.set(ReportConstant.time, str(updated_time)) + + @staticmethod + def generate_report(element, file_name): + if check_pub_key_exist(): + plain_text = DataHelper.to_string(element) + try: + cipher_text = do_rsa_encrypt(plain_text) + except ParamError as error: + LOG.error(error, error_no=error.error_no) + cipher_text = b"" + if platform.system() == "Windows": + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_BINARY + else: + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + file_name_open = os.open(file_name, flags, FilePermission.mode_755) + with os.fdopen(file_name_open, "wb") as file_handler: + file_handler.write(cipher_text) + file_handler.flush() + else: + tree = ElementTree.ElementTree(element) + tree.write(file_name, encoding="UTF-8", xml_declaration=True, + short_empty_elements=True) + LOG.info("Generate data report: %s", file_name) + + @staticmethod + def to_string(element): + return str( + ElementTree.tostring(element, encoding='UTF-8', method='xml'), + encoding="UTF-8") + + +@dataclass +class ExecInfo: + keys = [ReportConstant.platform, ReportConstant.test_type, + ReportConstant.device_name, ReportConstant.host_info, + ReportConstant.test_time, ReportConstant.execute_time] + test_type = "" + device_name = "" + host_info = "" + test_time = "" + log_path = "" + platform = "" + execute_time = "" + product_info = dict() + + +class Result: + + def __init__(self): + self.total = 0 + self.passed = 0 + self.failed = 0 + self.blocked = 0 + self.ignored = 0 + self.unavailable = 0 + + def get_total(self): + return self.total + + def get_passed(self): + return self.passed + + +class Summary: + keys = [ReportConstant.modules, ReportConstant.total, + ReportConstant.passed, ReportConstant.failed, + ReportConstant.blocked, ReportConstant.unavailable, + ReportConstant.ignored, ReportConstant.run_modules_] + + def __init__(self): + self.result = Result() + self.modules = None + self.run_modules = 0 + + def get_result(self): + return self.result + + def get_modules(self): + return self.modules + + +class Suite: + keys = [ReportConstant.module_name_, ReportConstant.name, + ReportConstant.total, ReportConstant.passed, + ReportConstant.failed, ReportConstant.blocked, + ReportConstant.ignored, ReportConstant.time] + module_name = ReportConstant.empty_name + name = "" + time = "" + + def __init__(self): + self.message = "" + self.result = Result() + self.cases = [] # need initial to create new object + + def get_cases(self): + return self.cases + + def set_cases(self, element): + if len(element) == 0: + LOG.debug("%s has no testcase", + element.get(ReportConstant.name, "")) + return + + # get case context and add to self.cases + for child in element: + case = Case() + case.module_name = self.module_name + for key, value in child.items(): + setattr(case, key, value) + if len(child) > 0: + if not getattr(case, ReportConstant.result, "") or \ + getattr(case, ReportConstant.result, "") == ReportConstant.completed: + setattr(case, ReportConstant.result, ReportConstant.false) + message = child[0].get(ReportConstant.message, "") + if child[0].text and message != child[0].text: + message = "%s\n%s" % (message, child[0].text) + setattr(case, ReportConstant.message, message) + self.cases.append(case) + self.cases.sort(key=lambda x: ( + x.is_failed(), x.is_blocked(), x.is_unavailable(), x.is_passed()), + reverse=True) + + +class Case: + module_name = ReportConstant.empty_name + name = ReportConstant.empty_name + classname = ReportConstant.empty_name + status = "" + result = "" + message = "" + time = "" + + def is_passed(self): + if self.result == ReportConstant.true and \ + (self.status == ReportConstant.run or self.status == ""): + return True + if self.result == "" and self.status == ReportConstant.run and \ + self.message == "": + return True + return False + + def is_failed(self): + return self.result == ReportConstant.false and \ + (self.status == ReportConstant.run or self.status == "") + + def is_blocked(self): + return self.status in [ReportConstant.blocked, ReportConstant.disable, + ReportConstant.error] + + def is_unavailable(self): + return self.status in [ReportConstant.unavailable] + + def is_ignored(self): + return self.status in [ReportConstant.skip, ReportConstant.not_run] + + def get_result(self): + if self.is_failed(): + return ReportConstant.failed + if self.is_blocked(): + return ReportConstant.blocked + if self.is_unavailable(): + return ReportConstant.unavailable + if self.is_ignored(): + return ReportConstant.ignored + return ReportConstant.passed + + +@dataclass +class ColorType: + keys = [ReportConstant.failed, ReportConstant.blocked, + ReportConstant.ignored, ReportConstant.unavailable] + failed = ReportConstant.color_normal + blocked = ReportConstant.color_normal + ignored = ReportConstant.color_normal + unavailable = ReportConstant.color_normal + + +class VisionHelper: + PLACE_HOLDER = " " + MAX_LENGTH = 50 + + def __init__(self): + from xdevice import Variables + self.summary_element = None + self.template_name = os.path.join(Variables.res_dir, "template", + "report.html") + + def parse_element_data(self, summary_element, report_path, task_info): + self.summary_element = summary_element + exec_info = self._set_exec_info(report_path, task_info) + suites = self._set_suites_info() + summary = self._set_summary_info() + return exec_info, summary, suites + + def _set_exec_info(self, report_path, task_info): + exec_info = ExecInfo() + exec_info.platform = getattr(task_info, ReportConstant.platform, + "None") + exec_info.test_type = getattr(task_info, ReportConstant.test_type, + "Test") + exec_info.device_name = getattr(task_info, ReportConstant.device_name, + "None") + exec_info.host_info = platform.platform() + start_time = self.summary_element.get(ReportConstant.start_time, "") + if not start_time: + start_time = self.summary_element.get("start_time", "") + end_time = self.summary_element.get(ReportConstant.end_time, "") + if not end_time: + end_time = self.summary_element.get("end_time", "") + exec_info.test_time = "%s/ %s" % (start_time, end_time) + start_time = time.mktime(time.strptime( + start_time, ReportConstant.time_format)) + end_time = time.mktime(time.strptime( + end_time, ReportConstant.time_format)) + exec_info.execute_time = self.get_execute_time(round( + end_time - start_time, 3)) + exec_info.log_path = os.path.abspath(os.path.join(report_path, "log")) + + try: + product_info = self.summary_element.get( + ReportConstant.product_info, "") + if product_info: + exec_info.product_info = literal_eval(str(product_info)) + except SyntaxError as error: + LOG.error("Summary report error: %s", error.args) + return exec_info + + @classmethod + def get_execute_time(cls, second_time): + hour, day = 0, 0 + second, minute = second_time % 60, second_time // 60 + if minute > 0: + minute, hour = minute % 60, minute // 60 + if hour > 0: + hour, day = hour % 24, hour // 24 + execute_time = "{}sec".format(str(int(second))) + if minute > 0: + execute_time = "{}min {}".format(str(int(minute)), execute_time) + if hour > 0: + execute_time = "{}hour {}".format(str(int(hour)), execute_time) + if day > 0: + execute_time = "{}day {}".format(str(int(day)), execute_time) + return execute_time + + def _set_summary_info(self): + summary = Summary() + summary.modules = self.summary_element.get( + ReportConstant.modules, 0) + summary.run_modules = self.summary_element.get( + ReportConstant.run_modules, 0) + summary.result.total = int(self.summary_element.get( + ReportConstant.tests, 0)) + summary.result.failed = int( + self.summary_element.get(ReportConstant.failures, 0)) + summary.result.blocked = int( + self.summary_element.get(ReportConstant.errors, 0)) + \ + int(self.summary_element.get(ReportConstant.disabled, 0)) + summary.result.ignored = int( + self.summary_element.get(ReportConstant.ignored, 0)) + summary.result.unavailable = int( + self.summary_element.get(ReportConstant.unavailable, 0)) + summary.result.passed = summary.result.total - summary.result.failed \ + - summary.result.blocked - summary.result.ignored + return summary + + def _set_suites_info(self): + suites = [] + for child in self.summary_element: + suite = Suite() + suite.module_name = child.get(ReportConstant.module_name, + ReportConstant.empty_name) + suite.name = child.get(ReportConstant.name, "") + suite.message = child.get(ReportConstant.message, "") + suite.result.total = int(child.get(ReportConstant.tests)) if \ + child.get(ReportConstant.tests) else 0 + suite.result.failed = int(child.get(ReportConstant.failures)) if \ + child.get(ReportConstant.failures) else 0 + suite.result.unavailable = int(child.get( + ReportConstant.unavailable)) if child.get( + ReportConstant.unavailable) else 0 + errors = int(child.get(ReportConstant.errors)) if child.get( + ReportConstant.errors) else 0 + disabled = int(child.get(ReportConstant.disabled)) if child.get( + ReportConstant.disabled) else 0 + suite.result.ignored = int(child.get(ReportConstant.ignored)) if \ + child.get(ReportConstant.ignored) else 0 + suite.result.blocked = errors + disabled + suite.result.passed = suite.result.total - suite.result.failed - \ + suite.result.blocked - suite.result.ignored + suite.time = child.get(ReportConstant.time, "") + suite.set_cases(child) + suites.append(suite) + suites.sort(key=lambda x: (x.result.failed, x.result.blocked, + x.result.unavailable), reverse=True) + return suites + + def render_data(self, title_name, parsed_data, + render_target=ReportConstant.summary_vision_report): + exec_info, summary, suites = parsed_data + if not os.path.exists(self.template_name): + LOG.error("Template file not exists. {}".format(self.template_name)) + return "" + + with open(self.template_name) as file: + file_context = file.read() + file_context = self._render_key("", ReportConstant.title_name, + title_name, file_context) + file_context = self._render_exec_info(file_context, exec_info) + file_context = self._render_summary(file_context, summary) + if render_target == ReportConstant.summary_vision_report: + file_context = self._render_suites(file_context, suites) + elif render_target == ReportConstant.details_vision_report: + file_context = self._render_cases(file_context, suites) + elif render_target == ReportConstant.failures_vision_report: + file_context = self._render_failure_cases(file_context, suites) + else: + LOG.error("Unsupported vision report type: %s", render_target) + return file_context + + @classmethod + def _render_key(cls, prefix, key, new_str, update_context): + old_str = "" % (prefix, key) + return update_context.replace(old_str, new_str) + + def _render_exec_info(self, file_context, exec_info): + prefix = "exec_info." + for key in ExecInfo.keys: + value = self._get_hidden_style_value(getattr( + exec_info, key, "None")) + file_context = self._render_key(prefix, key, value, file_context) + file_context = self._render_product_info(exec_info, file_context, + prefix) + return file_context + + def _render_product_info(self, exec_info, file_context, prefix): + """Construct product info context and render it to file context + + rendered product info sample: + + key: + value + key: + value + + + Args: + exec_info: dict that used to update file_content + file_context: exist html content + prefix: target replace prefix key + + Returns: + updated file context that includes rendered product info + """ + row_start = True + try: + keys = list(exec_info.product_info.keys()) + except AttributeError as _: + LOG.error("Product info error %s", exec_info.product_info) + keys = [] + + render_value = "" + for key in keys: + value = exec_info.product_info[key] + if row_start: + render_value = "%s\n" % render_value + render_value = "{}{}".format( + render_value, self._get_exec_info_td(key, value, row_start)) + if not row_start: + render_value = "%s\n" % render_value + row_start = not row_start + if not row_start: + render_value = "%s\n" % render_value + file_context = self._render_key(prefix, ReportConstant.product_info_, + render_value, file_context) + return file_context + + def _get_exec_info_td(self, key, value, row_start): + if not value: + value = self.PLACE_HOLDER + if key == ReportConstant.log_path_title and row_start: + exec_info_td = \ + " %s:\n" \ + " %s\n" % \ + (key, value) + return exec_info_td + value = self._get_hidden_style_value(value) + if row_start: + exec_info_td = " %s:\n" \ + " %s\n" % \ + (key, value) + else: + exec_info_td = " %s:\n" \ + " %s\n" % \ + (key, value) + return exec_info_td + + def _get_hidden_style_value(self, value): + if len(value) <= self.MAX_LENGTH: + return value + return "

" % (value, value) + + def _render_summary(self, file_context, summary): + file_context = self._render_data_object(file_context, summary, + "summary.") + + # render color type + color_type = ColorType() + if summary.result.failed != 0: + color_type.failed = ReportConstant.color_failed + if summary.result.blocked != 0: + color_type.blocked = ReportConstant.color_blocked + if summary.result.ignored != 0: + color_type.ignored = ReportConstant.color_ignored + if summary.result.unavailable != 0: + color_type.unavailable = ReportConstant.color_unavailable + return self._render_data_object(file_context, color_type, + "color_type.") + + def _render_data_object(self, file_context, data_object, prefix, + default=None): + """Construct data object context and render it to file context""" + if default is None: + default = self.PLACE_HOLDER + update_context = file_context + for key in getattr(data_object, "keys", []): + if hasattr(Result(), key) and hasattr( + data_object, ReportConstant.result): + result = getattr(data_object, ReportConstant.result, Result()) + new_str = str(getattr(result, key, default)) + else: + new_str = str(getattr(data_object, key, default)) + update_context = self._render_key(prefix, key, new_str, + update_context) + return update_context + + def _render_suites(self, file_context, suites): + """Construct suites context and render it to file context + suite record sample: + + + + + + + + + + + + + + + + + + + + + + + + + + + ... +
Test detail
ModuleTestsuiteTotal TestsPassedFailedBlockedIgnoredTimeOperate
{suite.module_name}{suite.name}{suite.result.total}{suite.result.passed}{suite.result.failed}{suite.result.blocked}{suite.result.ignored}{suite.time} + +
+
+ """ + replace_str = "" + + suites_context = "\n" + suites_context = "%s%s" % (suites_context, self._get_suites_title()) + for index, suite in enumerate(suites): + # construct suite context + suite_name = getattr(suite, "name", self.PLACE_HOLDER) + suite_context = "\n " if index % 2 == 0 else \ + "\n " + for key in Suite.keys: + if hasattr(Result(), key): + result = getattr(suite, ReportConstant.result, Result()) + text = getattr(result, key, self.PLACE_HOLDER) + else: + text = getattr(suite, key, self.PLACE_HOLDER) + suite_context = "{}{}".format( + suite_context, self._add_suite_td_context(key, text)) + if suite.result.total == 0: + href = "%s#%s" % ( + ReportConstant.failures_vision_report, suite_name) + else: + href = "%s#%s" % ( + ReportConstant.details_vision_report, suite_name) + suite_context = "{}{}".format( + suite_context, + "\n\n" % href) + # add suite context to suites context + suites_context = "{}{}".format(suites_context, suite_context) + + suites_context = "%s
" + "
\n" % suites_context + return file_context.replace(replace_str, suites_context) + + @classmethod + def _get_suites_title(cls): + suites_title = "\n" \ + " Test detail\n" \ + "\n" \ + "\n" \ + " Module\n" \ + " Testsuite\n" \ + " Total Tests\n" \ + " Passed\n" \ + " Failed\n" \ + " Blocked\n" \ + " Ignored\n" \ + " Time\n" \ + " Operate\n" \ + "\n" + return suites_title + + @staticmethod + def _add_suite_td_context(style, text): + if style == ReportConstant.name: + style = "test-suite" + td_style_class = "normal %s" % style + return "%s\n " % (td_style_class, str(text)) + + def _render_cases(self, file_context, suites): + """Construct cases context and render it to file context + case table sample: + + + + + + + + + + + + + + + + + + + + + ... +
+ {suite.name}   + + +
ModuleTestsuiteTestcaseTime
Result
{case.module_name}{case.classname}{case.name}{case.time}
+ [] + {case.result/status}[]
+ ... + """ + replace_str = "" + cases_context = "" + for suite in suites: + # construct case context + suite_name = getattr(suite, "name", self.PLACE_HOLDER) + case_context = "\n" + case_context = "{}{}".format(case_context, + self._get_case_title(suite_name)) + for index, case in enumerate(suite.cases): + case_context = "{}{}".format( + case_context, + self._get_case_td_context(index, case, suite_name)) + case_context = "%s
\n" % case_context + + # add case context to cases context + cases_context = "{}{}".format(cases_context, case_context) + return file_context.replace(replace_str, cases_context) + + @classmethod + def _get_case_td_context(cls, index, case, suite_name): + result = case.get_result() + rendered_result = result + if result != ReportConstant.passed and \ + result != ReportConstant.ignored: + rendered_result = "
%s" % \ + (ReportConstant.failures_vision_report, + suite_name, case.name, result) + case_td_context = "\n" if index % 2 == 0 else \ + "\n" + case_td_context = "{}{}".format( + case_td_context, + " %s\n" + " %s\n" + " %s\n" + " %s\n" + " " + "
\n" + " %s\n" + "\n" % (case.module_name, case.classname, case.name, + case.time, result, rendered_result)) + return case_td_context + + @classmethod + def _get_case_title(cls, suite_name): + case_title = \ + "\n" \ + " \n" \ + " %s  \n" \ + " \n" \ + " \n" \ + " \n" \ + "\n" \ + "\n" \ + " Module\n" \ + " Testsuite\n" \ + " Testcase\n" \ + " Time\n" \ + "
\n" \ + " Result\n" \ + "\n" % (suite_name, suite_name, + ReportConstant.summary_vision_report) + return case_title + + def _render_failure_cases(self, file_context, suites): + """Construct failure cases context and render it to file context + failure case table sample: + + + + + + + + + + + + + or + + + + + + ... +
+ {suite.name}   + + +
Test
ResultDetails
+ {suite.module_name}#{suite.name} + {case.module_name}#{case.classname}#{case.name}
{case.result/status}{case.message}
+ ... + """ + replace_str = "" + failure_cases_context = "" + for suite in suites: + if suite.result.total == ( + suite.result.passed + suite.result.ignored) and \ + suite.result.unavailable == 0: + continue + + # construct failure cases context for failure suite + suite_name = getattr(suite, "name", self.PLACE_HOLDER) + case_context = "\n" + case_context = \ + "{}{}".format(case_context, self._get_failure_case_title( + suite_name, suite.result.total)) + if suite.result.total == 0: + case_context = "{}{}".format( + case_context, self._get_failure_case_td_context( + 0, suite, suite_name, ReportConstant.unavailable)) + else: + skipped_num = 0 + for index, case in enumerate(suite.cases): + result = case.get_result() + if result == ReportConstant.passed or \ + result == ReportConstant.ignored: + skipped_num += 1 + continue + case_context = "{}{}".format( + case_context, self._get_failure_case_td_context( + index - skipped_num, case, suite_name, result)) + + case_context = "%s
\n" % case_context + + # add case context to cases context + failure_cases_context = \ + "{}{}".format(failure_cases_context, case_context) + return file_context.replace(replace_str, failure_cases_context) + + @classmethod + def _get_failure_case_td_context(cls, index, case, suite_name, result): + failure_case_td_context = "\n" if index % 2 == 0 else \ + "\n" + if result == ReportConstant.unavailable: + test_context = "%s#%s" % (case.module_name, case.name) + href_id = suite_name + else: + test_context = \ + "%s#%s#%s" % (case.module_name, case.classname, case.name) + href_id = "%s.%s" % (suite_name, case.name) + details_context = case.message + if details_context: + details_context = str(details_context).replace("<", "<"). \ + replace(">", ">").replace("\\r\\n", "
"). \ + replace("\\n", "
").replace("\n", "
"). \ + replace(" ", " ") + failure_case_td_context = "{}{}".format( + failure_case_td_context, + " %s\n" + " " + "
\n" + " %s\n" + " %s\n" + "\n" % + (href_id, test_context, result, result, details_context)) + return failure_case_td_context + + @classmethod + def _get_failure_case_title(cls, suite_name, total): + if total == 0: + href = "%s#summary" % ReportConstant.summary_vision_report + else: + href = "%s#%s" % (ReportConstant.details_vision_report, suite_name) + failure_case_title = \ + "\n" \ + " \n" \ + " %s  \n" \ + " \n" \ + " \n" \ + " \n" \ + "\n" \ + "\n" \ + " Test\n" \ + "
\n" \ + " Result\n" \ + " Details\n" \ + "\n" % (suite_name, suite_name, href) + return failure_case_title + + @staticmethod + def generate_report(summary_vision_path, report_context): + if platform.system() == "Windows": + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_BINARY + else: + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + vision_file_open = os.open(summary_vision_path, flags, + FilePermission.mode_755) + vision_file = os.fdopen(vision_file_open, "wb") + if check_pub_key_exist(): + try: + cipher_text = do_rsa_encrypt(report_context) + except ParamError as error: + LOG.error(error, error_no=error.error_no) + cipher_text = b"" + vision_file.write(cipher_text) + else: + vision_file.write(bytes(report_context, "utf-8", "ignore")) + vision_file.flush() + vision_file.close() + LOG.info("Generate vision report: %s", summary_vision_path) diff --git a/xdevice/src/xdevice/_core/report/result_reporter.py b/xdevice/src/xdevice/_core/report/result_reporter.py new file mode 100644 index 0000000..06ed37b --- /dev/null +++ b/xdevice/src/xdevice/_core/report/result_reporter.py @@ -0,0 +1,673 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import platform +import shutil +import time +import zipfile +from importlib import util +from ast import literal_eval + +from _core.interface import IReporter +from _core.plugin import Plugin +from _core.constants import ModeType +from _core.constants import TestType +from _core.constants import FilePermission +from _core.logger import platform_logger +from _core.exception import ParamError +from _core.utils import get_filename_extension +from _core.report.encrypt import check_pub_key_exist +from _core.report.encrypt import do_rsa_encrypt +from _core.report.encrypt import get_file_summary +from _core.report.reporter_helper import DataHelper +from _core.report.reporter_helper import ExecInfo +from _core.report.reporter_helper import VisionHelper +from _core.report.reporter_helper import ReportConstant + +LOG = platform_logger("ResultReporter") + + +@Plugin(type=Plugin.REPORTER, id=TestType.all) +class ResultReporter(IReporter): + summary_report_result = [] + + def __init__(self): + self.report_path = None + self.task_info = None + self.summary_data_path = None + self.summary_data_str = "" + self.exec_info = None + self.parsed_data = None + self.data_helper = None + self.vision_helper = None + + def __generate_reports__(self, report_path, **kwargs): + LOG.info("") + LOG.info("**************************************************") + LOG.info("************** Start generate reports ************") + LOG.info("**************************************************") + LOG.info("") + + if self._check_params(report_path, **kwargs): + # generate data report + self._generate_data_report() + + # generate vision reports + self._generate_vision_reports() + + # generate task info record + self._generate_task_info_record() + + # generate summary ini + self._generate_summary() + + # copy reports to reports/latest folder + self._copy_report() + + self._transact_all() + + LOG.info("") + LOG.info("**************************************************") + LOG.info("************** Ended generate reports ************") + LOG.info("**************************************************") + LOG.info("") + + def _check_params(self, report_path, **kwargs): + task_info = kwargs.get("task_info", "") + if not report_path: + LOG.error("Report path is wrong", error_no="00440", + ReportPath=report_path) + return False + if not task_info or not isinstance(task_info, ExecInfo): + LOG.error("Task info is wrong", error_no="00441", + TaskInfo=task_info) + return False + + os.makedirs(report_path, exist_ok=True) + self.report_path = report_path + self.task_info = task_info + self.summary_data_path = os.path.join( + self.report_path, ReportConstant.summary_data_report) + self.exec_info = task_info + self.data_helper = DataHelper() + self.vision_helper = VisionHelper() + return True + + def _generate_data_report(self): + # initial element + test_suites_element = self.data_helper.initial_suites_element() + + # update test suites element + update_flag = self._update_test_suites(test_suites_element) + if not update_flag: + return + + # generate report + if not self._check_mode(ModeType.decc): + self.data_helper.generate_report(test_suites_element, + self.summary_data_path) + + # set SuiteReporter.suite_report_result + if not check_pub_key_exist() and not self._check_mode( + ModeType.decc): + return + self.set_summary_report_result( + self.summary_data_path, DataHelper.to_string(test_suites_element)) + + def _update_test_suites(self, test_suites_element): + # initial attributes for test suites element + test_suites_attributes, need_update_attributes = \ + self._init_attributes() + + # get test suite elements that are children of test suites element + modules = dict() + test_suite_elements = [] + for data_report, module_name in self.data_reports: + if data_report.endswith(ReportConstant.summary_data_report): + continue + root = self.data_helper.parse_data_report(data_report) + if module_name == ReportConstant.empty_name: + module_name = self._get_module_name(data_report, root) + total = int(root.get(ReportConstant.tests, 0)) + modules[module_name] = modules.get(module_name, 0) + total + + self._append_product_info(test_suites_attributes, root) + for child in root: + child.tail = self.data_helper.LINE_BREAK_INDENT + if not child.get(ReportConstant.module_name) or child.get( + ReportConstant.module_name) == \ + ReportConstant.empty_name: + child.set(ReportConstant.module_name, module_name) + self._check_tests_and_unavailable(child) + # covert the status of "notrun" to "ignored" + for element in child: + if element.get(ReportConstant.status, "") == \ + ReportConstant.not_run: + ignored = int(child.get(ReportConstant.ignored, 0)) + 1 + child.set(ReportConstant.ignored, "%s" % ignored) + test_suite_elements.append(child) + for update_attribute in need_update_attributes: + update_value = child.get(update_attribute, 0) + if not update_value: + update_value = 0 + test_suites_attributes[update_attribute] += int( + update_value) + + if test_suite_elements: + child = test_suite_elements[-1] + child.tail = self.data_helper.LINE_BREAK + else: + LOG.error("Execute result not exists") + return False + + # set test suites element attributes and children + modules_zero = [module_name for module_name, total in modules.items() + if total == 0] + if modules_zero: + LOG.info("The total tests of %s module is 0", ",".join( + modules_zero)) + test_suites_attributes[ReportConstant.run_modules] = \ + len(modules) - len(modules_zero) + test_suites_attributes[ReportConstant.modules] = len(modules) + self.data_helper.set_element_attributes(test_suites_element, + test_suites_attributes) + test_suites_element.extend(test_suite_elements) + return True + + @classmethod + def _check_tests_and_unavailable(cls, child): + total = child.get(ReportConstant.tests, "0") + unavailable = child.get(ReportConstant.unavailable, "0") + if total and total != "0" and unavailable and \ + unavailable != "0": + child.set(ReportConstant.unavailable, "0") + LOG.warning("%s total: %s, unavailable: %s", child.get( + ReportConstant.name), total, unavailable) + + @classmethod + def _append_product_info(cls, test_suites_attributes, root): + product_info = root.get(ReportConstant.product_info, "") + if not product_info: + return + try: + product_info = literal_eval(str(product_info)) + except SyntaxError as error: + LOG.error("%s %s", root.get(ReportConstant.name, ""), error.args) + product_info = {} + + if not test_suites_attributes[ReportConstant.product_info]: + test_suites_attributes[ReportConstant.product_info] = \ + product_info + return + for key, value in product_info.items(): + exist_value = test_suites_attributes[ + ReportConstant.product_info].get(key, "") + + if not exist_value: + test_suites_attributes[ + ReportConstant.product_info][key] = value + continue + if value in exist_value: + continue + test_suites_attributes[ReportConstant.product_info][key] = \ + "%s,%s" % (exist_value, value) + + @classmethod + def _get_module_name(cls, data_report, root): + # get module name from data report + module_name = get_filename_extension(data_report)[0] + if "report" in module_name or "summary" in module_name or \ + "<" in data_report or ">" in data_report: + module_name = root.get(ReportConstant.name, + ReportConstant.empty_name) + if "report" in module_name or "summary" in module_name: + module_name = ReportConstant.empty_name + return module_name + + def _init_attributes(self): + test_suites_attributes = { + ReportConstant.name: + ReportConstant.summary_data_report.split(".")[0], + ReportConstant.start_time: self.task_info.test_time, + ReportConstant.end_time: time.strftime(ReportConstant.time_format, + time.localtime()), + ReportConstant.errors: 0, ReportConstant.disabled: 0, + ReportConstant.failures: 0, ReportConstant.tests: 0, + ReportConstant.ignored: 0, ReportConstant.unavailable: 0, + ReportConstant.product_info: self.task_info.product_info, + ReportConstant.modules: 0, ReportConstant.run_modules: 0} + need_update_attributes = [ReportConstant.tests, ReportConstant.ignored, + ReportConstant.failures, + ReportConstant.disabled, + ReportConstant.errors, + ReportConstant.unavailable] + return test_suites_attributes, need_update_attributes + + def _generate_vision_reports(self): + if not self._check_mode(ModeType.decc) and not \ + self.summary_data_report_exist: + LOG.error("Summary data report not exists") + return + + if check_pub_key_exist() or self._check_mode(ModeType.decc): + if not self.summary_report_result_exists(): + LOG.error("Summary data report not exists") + return + self.summary_data_str = \ + self.get_result_of_summary_report() + if check_pub_key_exist(): + from xdevice import SuiteReporter + SuiteReporter.clear_report_result() + + # parse data + if self.summary_data_str: + # only in decc mode and pub key, self.summary_data_str is not empty + summary_element_tree = self.data_helper.parse_data_report( + self.summary_data_str) + else: + summary_element_tree = self.data_helper.parse_data_report( + self.summary_data_path) + parsed_data = self.vision_helper.parse_element_data( + summary_element_tree, self.report_path, self.task_info) + self.parsed_data = parsed_data + self.exec_info, summary, _ = parsed_data + + if self._check_mode(ModeType.decc): + return + + LOG.info("Summary result: modules: %s, run modules: %s, total: " + "%s, passed: %s, failed: %s, blocked: %s, ignored: %s, " + "unavailable: %s", summary.modules, summary.run_modules, + summary.result.total, summary.result.passed, + summary.result.failed, summary.result.blocked, + summary.result.ignored, summary.result.unavailable) + LOG.info("Log path: %s", self.exec_info.log_path) + + if summary.result.failed != 0 or summary.result.blocked != 0 or summary.result.unavailable != 0: + from xdevice import Scheduler + Scheduler.is_need_auto_retry = True + + # generate summary vision report + report_generate_flag = self._generate_vision_report( + parsed_data, ReportConstant.summary_title, + ReportConstant.summary_vision_report) + + # generate details vision report + if report_generate_flag and summary.result.total > 0: + self._generate_vision_report( + parsed_data, ReportConstant.details_title, + ReportConstant.details_vision_report) + + # generate failures vision report + if summary.result.total != ( + summary.result.passed + summary.result.ignored) or \ + summary.result.unavailable > 0: + self._generate_vision_report( + parsed_data, ReportConstant.failures_title, + ReportConstant.failures_vision_report) + + def _generate_vision_report(self, parsed_data, title, render_target): + + # render data + report_context = self.vision_helper.render_data( + title, parsed_data, render_target=render_target) + + # generate report + if report_context: + report_path = os.path.join(self.report_path, render_target) + self.vision_helper.generate_report(report_path, report_context) + return True + else: + LOG.error("Failed to generate %s", render_target) + return False + + @property + def summary_data_report_exist(self): + return "<" in self.summary_data_str or \ + os.path.exists(self.summary_data_path) + + @property + def data_reports(self): + if check_pub_key_exist() or self._check_mode(ModeType.decc): + from xdevice import SuiteReporter + suite_reports = SuiteReporter.get_report_result() + if self._check_mode(ModeType.decc): + LOG.debug("Handle history result, data reports length:{}". + format(len(suite_reports))) + SuiteReporter.clear_history_result() + SuiteReporter.append_history_result(suite_reports) + data_reports = [] + for report_path, report_result in suite_reports: + module_name = get_filename_extension(report_path)[0] + data_reports.append((report_result, module_name)) + SuiteReporter.clear_report_result() + return data_reports + + if not os.path.isdir(self.report_path): + return [] + data_reports = [] + result_path = os.path.join(self.report_path, "result") + for root, _, files in os.walk(self.report_path): + for file_name in files: + if not file_name.endswith(self.data_helper.DATA_REPORT_SUFFIX): + continue + module_name = self._find_module_name(result_path, root) + data_reports.append((os.path.join(root, file_name), + module_name)) + return data_reports + + @classmethod + def _find_module_name(cls, result_path, root): + # find module name from directory tree + common_path = os.path.commonpath([result_path, root]) + if os.path.normcase(result_path) != os.path.normcase(common_path) or \ + os.path.normcase(result_path) == os.path.normcase(root): + return ReportConstant.empty_name + + root_dir, module_name = os.path.split(root) + if os.path.normcase(result_path) == os.path.normcase(root_dir): + return ReportConstant.empty_name + root_dir, subsystem_name = os.path.split(root_dir) + while os.path.normcase(result_path) != os.path.normcase(root_dir): + module_name = subsystem_name + root_dir, subsystem_name = os.path.split(root_dir) + return module_name + + def _generate_summary(self): + if not self.summary_data_report_exist or \ + self._check_mode(ModeType.decc): + return + summary_ini_content = \ + "[default]\n" \ + "Platform=%s\n" \ + "Test Type=%s\n" \ + "Device Name=%s\n" \ + "Host Info=%s\n" \ + "Test Start/ End Time=%s\n" \ + "Execution Time=%s\n" % ( + self.exec_info.platform, self.exec_info.test_type, + self.exec_info.device_name, self.exec_info.host_info, + self.exec_info.test_time, self.exec_info.execute_time) + if self.exec_info.product_info: + for key, value in self.exec_info.product_info.items(): + summary_ini_content = "{}{}".format( + summary_ini_content, "%s=%s\n" % (key, value)) + + if not self._check_mode(ModeType.factory): + summary_ini_content = "{}{}".format( + summary_ini_content, "Log Path=%s\n" % self.exec_info.log_path) + + # write summary_ini_content + summary_filepath = os.path.join(self.report_path, + ReportConstant.summary_ini) + + if platform.system() == "Windows": + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_BINARY + else: + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + summary_filepath_open = os.open(summary_filepath, flags, + FilePermission.mode_755) + + with os.fdopen(summary_filepath_open, "wb") as file_handler: + if check_pub_key_exist(): + try: + cipher_text = do_rsa_encrypt(summary_ini_content) + except ParamError as error: + LOG.error(error, error_no=error.error_no) + cipher_text = b"" + file_handler.write(cipher_text) + else: + file_handler.write(bytes(summary_ini_content, 'utf-8')) + file_handler.flush() + LOG.info("Generate summary ini: %s", summary_filepath) + + def _copy_report(self): + from xdevice import Scheduler + if Scheduler.upload_address or self._check_mode(ModeType.decc): + return + + from xdevice import Variables + dst_path = os.path.join(Variables.temp_dir, "latest") + try: + shutil.rmtree(dst_path, ignore_errors=True) + os.makedirs(dst_path, exist_ok=True) + LOG.info("Copy summary files to %s", dst_path) + + # copy reports to reports/latest folder + for report_file in os.listdir(self.report_path): + src_file = os.path.join(self.report_path, report_file) + dst_file = os.path.join(dst_path, report_file) + if os.path.isfile(src_file): + shutil.copyfile(src_file, dst_file) + except OSError as _: + return + + def _compress_report_folder(self): + if self._check_mode(ModeType.decc) or \ + self._check_mode(ModeType.factory): + return + + if not os.path.isdir(self.report_path): + LOG.error("'%s' is not folder!" % self.report_path) + return + + # get file path list + file_path_list = [] + for dir_path, _, file_names in os.walk(self.report_path): + f_path = dir_path.replace(self.report_path, '') + f_path = f_path and f_path + os.sep or '' + for filename in file_names: + file_path_list.append( + (os.path.join(dir_path, filename), f_path + filename)) + + # compress file + zipped_file = "%s.zip" % os.path.join( + self.report_path, os.path.basename(self.report_path)) + zip_object = zipfile.ZipFile(zipped_file, 'w', zipfile.ZIP_DEFLATED, + allowZip64=True) + try: + LOG.info("Executing compress process, please wait...") + long_size_file = [] + for src_path, target_path in file_path_list: + long_size_file.append((src_path, target_path)) + self._write_long_size_file(zip_object, long_size_file) + + LOG.info("Generate zip file: %s", zipped_file) + except zipfile.BadZipFile as bad_error: + LOG.error("Zip report folder error: %s" % bad_error.args) + finally: + zip_object.close() + + # generate hex digest, then save it to summary_report.hash + hash_file = os.path.abspath(os.path.join( + self.report_path, ReportConstant.summary_report_hash)) + hash_file_open = os.open(hash_file, os.O_WRONLY | os.O_CREAT | + os.O_APPEND, FilePermission.mode_755) + with os.fdopen(hash_file_open, "w") as hash_file_handler: + hash_file_handler.write(get_file_summary(zipped_file)) + LOG.info("Generate hash file: %s", hash_file) + hash_file_handler.flush() + return zipped_file + + @classmethod + def _check_mode(cls, mode): + from xdevice import Scheduler + return Scheduler.mode == mode + + def _generate_task_info_record(self): + # under encryption status, don't handle anything directly + if check_pub_key_exist() and not self._check_mode(ModeType.decc): + return + + # get info from command_queue + from xdevice import Scheduler + if not Scheduler.command_queue: + return + _, command, report_path = Scheduler.command_queue[-1] + + # handle parsed data + record = self._parse_record_from_data(command, report_path) + + def encode(content): + # inner function to encode + return ' '.join([bin(ord(c)).replace('0b', '') for c in content]) + + # write into file + import json + record_file = os.path.join(self.report_path, + ReportConstant.task_info_record) + _record_json = json.dumps(record, indent=2) + + with open(file=record_file, mode="wb") as file: + if Scheduler.mode == ModeType.decc: + # under decc, write in encoded text + file.write(bytes(encode(_record_json), encoding="utf-8")) + else: + # others, write in plain text + file.write(bytes(_record_json, encoding="utf-8")) + + LOG.info("Generate record file: %s", record_file) + + def _parse_record_from_data(self, command, report_path): + record = dict() + if self.parsed_data: + _, _, suites = self.parsed_data + unsuccessful = dict() + module_set = set() + for suite in suites: + module_set.add(suite.module_name) + + failed = unsuccessful.get(suite.module_name, []) + # because suite not contains case's some attribute, + # for example, 'module', 'classname', 'name' . so + # if unavailable, only add module's name into list. + if int(suite.result.unavailable) > 0: + failed.append(suite.module_name) + else: + # others, get key attributes join string + for case in suite.get_cases(): + if not case.is_passed(): + failed.append( + "{}#{}".format(case.classname, case.name)) + unsuccessful.update({suite.module_name: failed}) + data_reports = self._get_data_reports(module_set) + record = {"command": command, + "session_id": os.path.split(report_path)[-1], + "report_path": report_path, + "unsuccessful_params": unsuccessful, + "data_reports": data_reports + } + return record + + def _get_data_reports(self, module_set): + data_reports = dict() + if self._check_mode(ModeType.decc): + from xdevice import SuiteReporter + for module_name, report_path, report_result in \ + SuiteReporter.get_history_result_list(): + if module_name in module_set: + data_reports.update({module_name: report_path}) + else: + for report_path, module_name in self.data_reports: + if module_name == ReportConstant.empty_name: + root = self.data_helper.parse_data_report(report_path) + module_name = self._get_module_name(report_path, root) + if module_name in module_set: + data_reports.update({module_name: report_path}) + + return data_reports + + @classmethod + def get_task_info_params(cls, history_path): + # under encryption status, don't handle anything directly + if check_pub_key_exist() and not cls._check_mode(ModeType.decc): + return () + + def decode(content): + return ''.join([chr(i) for i in [int(b, 2) for b in + content.split(' ')]]) + + record_path = os.path.join(history_path, + ReportConstant.task_info_record) + if not os.path.exists(record_path): + LOG.error("%s not exists!", ReportConstant.task_info_record) + return () + + import json + from xdevice import Scheduler + with open(record_path, mode="rb") as file: + if Scheduler.mode == ModeType.decc: + # under decc, read from encoded text + result = json.loads(decode(file.read().decode("utf-8"))) + else: + # others, read from plain text + result = json.loads(file.read()) + standard_length = 5 + if not len(result.keys()) == standard_length: + LOG.error("%s error!", ReportConstant.task_info_record) + return () + + return result + + @classmethod + def set_summary_report_result(cls, summary_data_path, result_xml): + cls.summary_report_result.clear() + cls.summary_report_result.append((summary_data_path, result_xml)) + + @classmethod + def get_result_of_summary_report(cls): + if cls.summary_report_result: + return cls.summary_report_result[0][1] + + @classmethod + def summary_report_result_exists(cls): + return True if cls.summary_report_result else False + + @classmethod + def get_path_of_summary_report(cls): + if cls.summary_report_result: + return cls.summary_report_result[0][0] + + @classmethod + def _write_long_size_file(cls, zip_object, long_size_file): + for filename, arcname in long_size_file: + zip_info = zipfile.ZipInfo.from_file(filename, arcname) + zip_info.compress_type = getattr(zip_object, "compression", + zipfile.ZIP_DEFLATED) + if hasattr(zip_info, "_compresslevel"): + _compress_level = getattr(zip_object, "compresslevel", None) + setattr(zip_info, "_compresslevel", _compress_level) + with open(filename, "rb") as src, \ + zip_object.open(zip_info, "w") as des: + shutil.copyfileobj(src, des, 1024 * 1024 * 8) + + def _transact_all(self): + from xdevice import Variables + tools_dir = os.path.join(Variables.res_dir, "tools", "binder.pyc") + if not os.path.exists(tools_dir): + return + module_spec = util.spec_from_file_location( + "binder", tools_dir) + if not module_spec: + return + module = util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + if hasattr(module, "transact") and callable(module.transact): + module.transact(self, LOG) + del module diff --git a/xdevice/src/xdevice/_core/report/suite_reporter.py b/xdevice/src/xdevice/_core/report/suite_reporter.py new file mode 100644 index 0000000..f818d75 --- /dev/null +++ b/xdevice/src/xdevice/_core/report/suite_reporter.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2020-2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import time +from enum import Enum +from threading import RLock + +from _core.constants import ModeType +from _core.logger import platform_logger +from _core.report.encrypt import check_pub_key_exist +from _core.report.reporter_helper import DataHelper +from _core.report.reporter_helper import ReportConstant + +LOG = platform_logger("SuiteReporter") +SUITE_REPORTER_LOCK = RLock() + + +class ResultCode(Enum): + UNKNOWN = -1010 + BLOCKED = -1 + PASSED = 0 + FAILED = 1 + SKIPPED = 2 + + +class SuiteReporter: + suite_list = [] + suite_report_result = [] + failed_case_list = [] + history_report_result = [] + + def __init__(self, results, report_name, report_path=None, **kwargs): + """ + Create suite report + :param results: [(suite_result, [case_results]), + (suite_result, [case_results]), ...] + :param report_name: suite report name + :param report_path: suite report path + """ + self.results = results + self.data_helper = DataHelper() + self.report_name = report_name + self.report_path = report_path + self.suite_data_path = os.path.join( + self.report_path, "%s%s" % ( + report_name, self.data_helper.DATA_REPORT_SUFFIX)) + self.args = kwargs + from xdevice import Scheduler + if not check_pub_key_exist() and Scheduler.mode != ModeType.decc: + SuiteReporter.suite_report_result.clear() + + def create_empty_report(self): + # create empty data report only for single suite + if len(self.results) != 1: + LOG.error("Can only create one empty data report once") + return + suite_result, _ = self.results[0] + + # initial test suites element + test_suites_element, test_suites_attributes, _ = \ + self._initial_test_suites() + test_suites_attributes[ReportConstant.unavailable] = 1 + self.data_helper.set_element_attributes(test_suites_element, + test_suites_attributes) + + # initial test suite element + test_suite_element, test_suite_attributes = self._initial_test_suite( + suite_result) + test_suite_element.text, test_suite_element.tail = \ + "", self.data_helper.LINE_BREAK + test_suite_attributes[ReportConstant.unavailable] = 1 + test_suite_attributes[ReportConstant.message] = suite_result.stacktrace + + from xdevice import Scheduler + if Scheduler.mode == ModeType.decc: + test_suite_attributes[ReportConstant.result] = ReportConstant.false + self.data_helper.set_element_attributes(test_suite_element, + test_suite_attributes) + + # append test suite element + test_suites_element.append(test_suite_element) + + # generate report + if test_suites_element: + from xdevice import Scheduler + if Scheduler.mode != ModeType.decc: + self.data_helper.generate_report(test_suites_element, + self.suite_data_path) + SuiteReporter.append_report_result(( + self.suite_data_path, self.data_helper.to_string( + test_suites_element))) + + def generate_data_report(self): + # construct test suites element + test_suites_element = self._construct_test_suites() + + # generate report + if test_suites_element: + self.data_helper.generate_report(test_suites_element, + self.suite_data_path) + SuiteReporter.append_report_result(( + self.suite_data_path, self.data_helper.to_string( + test_suites_element))) + + def _construct_test_suites(self): + # initial test suites element + test_suites_element, test_suites_attributes, need_update_attributes = \ + self._initial_test_suites() + + # construct test suite element + for suite_result, case_results in self.results: + test_suite_element, test_suite_attributes = \ + self._construct_test_suite(suite_result, case_results) + + # add test suite element + test_suites_element.append(test_suite_element) + + # update and set test suites element attributes + for need_update_attribute in need_update_attributes: + test_suites_attributes[need_update_attribute] += \ + test_suite_attributes.get(need_update_attribute, 0) + test_suites_attributes[ReportConstant.time] = \ + round(test_suites_attributes[ReportConstant.time], 3) + + if test_suites_element: + test_suite_element = test_suites_element[-1] + test_suite_element.tail = self.data_helper.LINE_BREAK + else: + LOG.error("%s no suite result exists" % self.report_name) + + # set test suites element attributes + self.data_helper.set_element_attributes(test_suites_element, + test_suites_attributes) + return test_suites_element + + def _initial_test_suites(self): + test_suites_element = self.data_helper.initial_suites_element() + test_suites_attributes = {ReportConstant.name: self.report_name, + ReportConstant.time_stamp: time.strftime( + ReportConstant.time_format, + time.localtime()), + ReportConstant.time: 0, + ReportConstant.errors: 0, + ReportConstant.disabled: 0, + ReportConstant.failures: 0, + ReportConstant.tests: 0, + ReportConstant.ignored: 0, + ReportConstant.unavailable: 0, + ReportConstant.product_info: self.args.get( + ReportConstant.product_info_, "")} + if self.args.get(ReportConstant.module_name, ""): + test_suites_attributes[ReportConstant.name] = self.args.get( + ReportConstant.module_name, "") + need_update_attributes = [ReportConstant.time, ReportConstant.errors, + ReportConstant.tests, ReportConstant.ignored, + ReportConstant.disabled, + ReportConstant.failures, + ReportConstant.unavailable] + return test_suites_element, test_suites_attributes, \ + need_update_attributes + + def _construct_test_suite(self, suite_result, case_results): + # initial test suite element + test_suite_element, test_suite_attributes = self._initial_test_suite( + suite_result) + + # get test case elements that are children of test suite element + test_case_elements = [] + for case_result in case_results: + # initial test case element + test_case_element, test_case_attributes = self._initial_test_case( + case_result) + + # update attributes according to case result + self.update_attributes(case_result, test_case_attributes, + test_suite_attributes) + + # set test case attributes and add to test_suite_element + self.data_helper.set_element_attributes(test_case_element, + test_case_attributes) + test_case_elements.append(test_case_element) + test_suite_attributes[ReportConstant.disabled] += max(int( + test_suite_attributes[ReportConstant.tests] - + len(test_case_elements)), 0) + if test_case_elements: + child = test_case_elements[-1] + child.tail = self.data_helper.LINE_BREAK_INDENT + else: + LOG.debug("No case executed") + test_suite_element.extend(test_case_elements) + + # set test suite attributes + self.data_helper.set_element_attributes(test_suite_element, + test_suite_attributes) + return test_suite_element, test_suite_attributes + + @classmethod + def update_attributes(cls, case_result, test_case_attributes, + test_suite_attributes): + if case_result.code == ResultCode.PASSED.value: + test_case_attributes[ReportConstant.status] = ReportConstant.run + test_case_attributes[ReportConstant.result] = ReportConstant.true + test_case_attributes[ReportConstant.message] = "" + elif case_result.code == ResultCode.FAILED.value: + test_case_attributes[ReportConstant.status] = ReportConstant.run + test_case_attributes[ReportConstant.result] = ReportConstant.false + test_suite_attributes[ReportConstant.failures] = \ + test_suite_attributes[ReportConstant.failures] + 1 + elif case_result.code == ResultCode.SKIPPED.value: + test_case_attributes[ReportConstant.status] = ReportConstant.skip + test_case_attributes[ReportConstant.result] = ReportConstant.false + test_suite_attributes[ReportConstant.ignored] = \ + test_suite_attributes[ReportConstant.ignored] + 1 + else: # ResultCode.UNKNOWN.value or other value + test_case_attributes[ReportConstant.status] = \ + ReportConstant.disable + test_case_attributes[ReportConstant.result] = ReportConstant.false + test_suite_attributes[ReportConstant.disabled] = \ + test_suite_attributes[ReportConstant.disabled] + 1 + + def _initial_test_suite(self, suite_result): + test_suite_element = self.data_helper.initial_suite_element() + test_suite_attributes = {ReportConstant.name: suite_result.suite_name, + ReportConstant.time: round(float( + suite_result.run_time) / 1000, 3), + ReportConstant.errors: 0, + ReportConstant.disabled: 0, + ReportConstant.failures: 0, + ReportConstant.ignored: 0, + ReportConstant.tests: suite_result.test_num, + ReportConstant.message: + suite_result.stacktrace + } + if self.args.get(ReportConstant.module_name, ""): + test_suite_attributes[ReportConstant.module_name] = self.args.get( + ReportConstant.module_name, "") + return test_suite_element, test_suite_attributes + + def _initial_test_case(self, case_result): + test_case_element = self.data_helper.initial_case_element() + case_stacktrace = str(case_result.stacktrace) + for char_index in range(32): + if char_index in [10, 13]: # chr(10): LF, chr(13): CR + continue + case_stacktrace = case_stacktrace.replace(chr(char_index), "") + test_case_attributes = {ReportConstant.name: case_result.test_name, + ReportConstant.status: "", + ReportConstant.time: round(float( + case_result.run_time) / 1000, 3), + ReportConstant.class_name: + case_result.test_class, + ReportConstant.result: "", + ReportConstant.level: 1, + ReportConstant.message: case_stacktrace} + return test_case_element, test_case_attributes + + @classmethod + def clear_report_result(cls): + with SUITE_REPORTER_LOCK: + LOG.debug("Clear report result") + cls.suite_report_result.clear() + + @classmethod + def clear_failed_case_list(cls): + with SUITE_REPORTER_LOCK: + LOG.debug("Clear failed case list") + cls.failed_case_list.clear() + + @classmethod + def append_report_result(cls, report_result): + with SUITE_REPORTER_LOCK: + if not isinstance(report_result, tuple) or len(report_result) != 2: + LOG.error("Report result should be a tuple with length 2") + return + data_path = report_result[0] + for index, exist_result in enumerate(cls.suite_report_result): + if exist_result[0] == data_path: + LOG.debug("Data report %s generate again", data_path) + cls.suite_report_result[index] = report_result + return + cls.suite_report_result.append(report_result) + cls._upload_case_result(report_result[1]) + + @classmethod + def _upload_case_result(cls, result_str): + from xdevice import Scheduler + if Scheduler.mode != ModeType.decc: + return + element = DataHelper.parse_data_report(result_str) + if len(element) == 0: + LOG.debug("%s is error", result_str) + return + element = element[0] + result, error_msg = Scheduler.get_script_result(element) + case_name = element.get(ReportConstant.name, "") + try: + from agent.decc import Handler + LOG.info("Upload case result to decc") + Handler.upload_case_result(case_name, result, error_msg) + except ModuleNotFoundError as error: + from xdevice import Scheduler + if Scheduler.mode == ModeType.decc: + LOG.error("Module not found %s", error.args) + + @classmethod + def get_report_result(cls): + with SUITE_REPORTER_LOCK: + LOG.debug("Get report result, length is {}". + format(len(cls.suite_report_result))) + return SuiteReporter.suite_report_result + + @classmethod + def set_suite_list(cls, suite_list): + LOG.debug("Set suite list, length is {}".format(len(suite_list))) + cls.suite_list = suite_list + + @classmethod + def get_suite_list(cls): + with SUITE_REPORTER_LOCK: + LOG.debug("Get suite list, length is {}". + format(len(cls.suite_list))) + return SuiteReporter.suite_list + + @classmethod + def get_failed_case_list(cls): + with SUITE_REPORTER_LOCK: + LOG.debug("Get failed case list, length is {}". + format(len(cls.failed_case_list))) + return SuiteReporter.failed_case_list + + @classmethod + def append_history_result(cls, suite_reports): + from _core.utils import get_filename_extension + with SUITE_REPORTER_LOCK: + LOG.debug("Append history result,suite reports length is {}". + format(len(suite_reports))) + for report_path, report_result in suite_reports: + module_name = get_filename_extension(report_path)[0] + cls.history_report_result. \ + append((module_name, report_path, report_result)) + + @classmethod + def clear_history_result(cls): + with SUITE_REPORTER_LOCK: + LOG.debug("Clear history result") + cls.history_report_result.clear() + + @classmethod + def get_history_result_by_module(cls, name): + with SUITE_REPORTER_LOCK: + LOG.debug("Get history result by module,module_name:{}". + format(name)) + for module_name, report_path, report_result in \ + cls.history_report_result: + if name == module_name: + return report_path, report_result + return "", "" + + @classmethod + def get_history_result_list(cls): + with SUITE_REPORTER_LOCK: + LOG.debug("Get history result list,length is {}". + format(len(cls.history_report_result))) + return cls.history_report_result diff --git a/xdevice/src/xdevice/_core/resource/config/user_config.xml b/xdevice/src/xdevice/_core/resource/config/user_config.xml new file mode 100644 index 0000000..8973025 --- /dev/null +++ b/xdevice/src/xdevice/_core/resource/config/user_config.xml @@ -0,0 +1,63 @@ + + + + + + + + cmd + 115200 + 8 + 1 + 20 + + + + deploy + 115200 + + + + + + cmd + 115200 + 8 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + INFO + \ No newline at end of file diff --git a/xdevice/src/xdevice/_core/resource/template/report.html b/xdevice/src/xdevice/_core/resource/template/report.html new file mode 100644 index 0000000..749170d --- /dev/null +++ b/xdevice/src/xdevice/_core/resource/template/report.html @@ -0,0 +1,471 @@ + + + + + <!--{title_name}--> + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
Test Summary
Platform:Test Type:
Device Name:Host Info:
Test Start/ End Time:Execution Time:
+ + + + + + + + + + + + + + + + + + + + + + +
ModulesRun ModulesTotal TestsPassedFailedBlockedIgnoredUnavailable
+ + + + +
+ + \ No newline at end of file diff --git a/xdevice/src/xdevice/_core/resource/version.txt b/xdevice/src/xdevice/_core/resource/version.txt new file mode 100644 index 0000000..ccdadd2 --- /dev/null +++ b/xdevice/src/xdevice/_core/resource/version.txt @@ -0,0 +1 @@ +xDevice-v2.11.0.1091 \ No newline at end of file diff --git a/xdevice/src/xdevice/_core/testkit/__init__.py b/xdevice/src/xdevice/_core/testkit/__init__.py new file mode 100644 index 0000000..f1b275b --- /dev/null +++ b/xdevice/src/xdevice/_core/testkit/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# diff --git a/xdevice/src/xdevice/_core/testkit/json_parser.py b/xdevice/src/xdevice/_core/testkit/json_parser.py new file mode 100644 index 0000000..382da6c --- /dev/null +++ b/xdevice/src/xdevice/_core/testkit/json_parser.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import json +import os +import stat +from _core.exception import ParamError +from _core.logger import platform_logger +from _core.plugin import Config + +__all__ = ["JsonParser"] +LOG = platform_logger("JsonParser") + + +class JsonParser: + """ + This class parses json files or string, sample: + { + "description": "Config for lite cpp test cases", + "environment": [ + { + "type": "device", + "label": "ipcamera" + } + ], + "kits": [ + { + "type": "MountKit", + "nfs": "NfsServer", + "bin_file": "CppTestLite/KvStoreTest.bin" + } + ], + "driver": { + "type": "CppTestLite", + "xml-output": false, + "rerun": false + } + } + """ + + def __init__(self, path_or_content): + """Instantiate the class using the manifest file denoted by path or + content + """ + self.config = Config() + self._do_parse(path_or_content) + + def _do_parse(self, path_or_content): + try: + if path_or_content.find("{") != -1: + json_content = json.loads( + path_or_content, encoding="utf-8") + else: + if not os.path.exists(path_or_content): + raise ParamError("The json file {} does not exist".format( + path_or_content), error_no="00110") + + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(path_or_content, flags, modes), + "r") as file_content: + json_content = json.load(file_content) + except (TypeError, ValueError, AttributeError) as error: + raise ParamError("json file error: %s %s" % ( + path_or_content, error), error_no="00111") + self._check_config(json_content) + # set self.config + self.config = Config() + self.config.description = json_content.get("description", "") + self.config.kits = json_content.get("kits", []) + self.config.environment = json_content.get("environment", []) + self.config.driver = json_content.get("driver", {}) + + def _check_config(self, json_content): + for kit in json_content.get("kits", []): + self._check_type_key_exist("kits", kit) + for device in json_content.get("environment", []): + self._check_type_key_exist("environment", device) + if json_content.get("driver", {}): + self._check_type_key_exist("driver", json_content.get("driver")) + + @classmethod + def _check_type_key_exist(cls, key, value): + if not isinstance(value, dict): + raise ParamError("%s under %s should be dict" % (value, key)) + if "type" not in value.keys(): + raise ParamError("'type' key not exists in %s under %s" % ( + value, key)) + + def get_config(self): + return self.config + + def get_description(self): + return getattr(self.config, "description", "") + + def get_kits(self): + return getattr(self.config, "kits", []) + + def get_environment(self): + return getattr(self.config, "environment", []) + + def get_driver(self): + return getattr(self.config, "driver", {}) + + def get_driver_type(self): + driver = getattr(self.config, "driver", {}) + return driver.get("type", "") if driver else "" diff --git a/xdevice/src/xdevice/_core/testkit/kit.py b/xdevice/src/xdevice/_core/testkit/kit.py new file mode 100644 index 0000000..f3fccbe --- /dev/null +++ b/xdevice/src/xdevice/_core/testkit/kit.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import os +import re +import stat +import json +import time +import platform +import subprocess +import signal +from threading import Timer + +from _core.utils import get_file_absolute_path +from _core.logger import platform_logger +from _core.exception import ParamError +from _core.constants import DeviceTestType +from _core.constants import FilePermission +from _core.constants import DeviceConnectorType + +LOG = platform_logger("Kit") + +TARGET_SDK_VERSION = 22 + +__all__ = ["get_app_name_by_tool", "junit_para_parse", "gtest_para_parse", + "get_install_args", "reset_junit_para", "remount", "disable_keyguard", + "timeout_callback", "unlock_screen", "unlock_device", "get_class"] + + +def remount(device): + device.enable_hdc_root() + cmd = "target mount" \ + if device.usb_type == DeviceConnectorType.hdc else "remount" + device.connector_command(cmd) + device.execute_shell_command("remount") + device.execute_shell_command("mount -o rw,remount /cust") + device.execute_shell_command("mount -o rw,remount /product") + device.execute_shell_command("mount -o rw,remount /hw_product") + device.execute_shell_command("mount -o rw,remount /version") + device.execute_shell_command("mount -o rw,remount /%s" % "system") + + +def get_class(junit_paras, prefix_char, para_name): + if not junit_paras.get(para_name): + return "" + + result = "" + if prefix_char == "-e": + result = " %s class " % prefix_char + elif prefix_char == "--": + result = " %sclass " % prefix_char + elif prefix_char == "-s": + result = " %s class " % prefix_char + test_items = [] + for test in junit_paras.get(para_name): + test_item = test.split("#") + if len(test_item) == 1 or len(test_item) == 2: + test_item = "%s" % test + test_items.append(test_item) + elif len(test_item) == 3: + test_item = "%s#%s" % (test_item[1], test_item[2]) + test_items.append(test_item) + else: + raise ParamError("The parameter %s %s is error" % ( + prefix_char, para_name)) + if not result: + LOG.debug("There is unsolved prefix char: %s ." % prefix_char) + return result + ",".join(test_items) + + +def junit_para_parse(device, junit_paras, prefix_char="-e"): + """To parse the para of junit + Args: + device: the device running + junit_paras: the para dict of junit + prefix_char: the prefix char of parsed cmd + Returns: + the new para using in a command like -e testFile xxx + -e coverage true... + """ + ret_str = [] + path = "/%s/%s/%s" % ("data", "local", "ajur") + include_file = "%s/%s" % (path, "includes.txt") + exclude_file = "%s/%s" % (path, "excludes.txt") + + if not isinstance(junit_paras, dict): + LOG.warning("The para of junit is not the dict format as required") + return "" + # Disable screen keyguard + disable_key_guard = junit_paras.get('disable-keyguard') + if not disable_key_guard or disable_key_guard[0].lower() != 'false': + disable_keyguard(device) + + for para_name in junit_paras.keys(): + path = "/%s/%s/%s/" % ("data", "local", "ajur") + if para_name.strip() == 'test-file-include-filter': + for file_name in junit_paras[para_name]: + device.push_file(file_name, include_file) + device.execute_shell_command( + 'chown -R shell:shell %s' % path) + ret_str.append(" ".join([prefix_char, 'testFile', include_file])) + elif para_name.strip() == "test-file-exclude-filter": + for file_name in junit_paras[para_name]: + device.push_file(file_name, exclude_file) + device.execute_shell_command( + 'chown -R shell:shell %s' % path) + ret_str.append(" ".join([prefix_char, 'notTestFile', + exclude_file])) + elif para_name.strip() == "test" or para_name.strip() == "class": + result = get_class(junit_paras, prefix_char, para_name.strip()) + ret_str.append(result) + elif para_name.strip() == "include-annotation": + ret_str.append(" ".join([prefix_char, "annotation", + ",".join(junit_paras[para_name])])) + elif para_name.strip() == "exclude-annotation": + ret_str.append(" ".join([prefix_char, "notAnnotation", + ",".join(junit_paras[para_name])])) + else: + ret_str.append(" ".join([prefix_char, para_name, + ",".join(junit_paras[para_name])])) + + return " ".join(ret_str) + + +def get_include_tests(para_datas, test_types, runner): + case_list = [] + if test_types == "class": + case_list = para_datas + else: + for case_file in para_datas: + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(case_file, flags, modes), "r") as file_desc: + case_list.extend(file_desc.read().splitlines()) + runner.add_instrumentation_arg("gtest_filter", ":".join(case_list).replace("#", ".")) + + +def get_all_test_include(para_datas, test_types, runner, request): + case_list = [] + if test_types == "notClass": + case_list = para_datas + else: + if para_datas: + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(para_datas[0], flags, modes), "r") as file_handler: + json_data = json.load(file_handler) + exclude_list = json_data.get(DeviceTestType.cpp_test, []) + for exclude in exclude_list: + if request.get_module_name() in exclude: + temp = exclude.get(request.get_module_name()) + case_list.extend(temp) + runner.add_instrumentation_arg("gtest_filter", "{}{}".format("-", ":".join(case_list)).replace("#", ".")) + + +def gtest_para_parse(gtest_paras, runner, request): + """To parse the para of gtest + Args: + gtest_paras: the para dict of gtest + Returns: + the new para using in gtest + """ + if not isinstance(gtest_paras, dict): + LOG.warning("The para of gtest is not the dict format as required") + return "" + + for para in gtest_paras.keys(): + test_types = para.strip() + para_datas = gtest_paras.get(para) + if test_types in ["test-file-include-filter", "class"]: + get_include_tests(para_datas, test_types, runner) + elif test_types in ["all-test-file-exclude-filter", "notClass"]: + get_all_test_include(para_datas, test_types, runner, request) + return "" + + +def reset_junit_para(junit_para_str, prefix_char="-e", ignore_keys=None): + if not ignore_keys and not isinstance(ignore_keys, list): + ignore_keys = ["class", "test"] + lines = junit_para_str.split("%s " % prefix_char) + normal_lines = [] + for line in lines: + line = line.strip() + if line: + items = line.split() + if items[0].strip() in ignore_keys: + continue + normal_lines.append("{} {}".format(prefix_char, line)) + return " ".join(normal_lines) + + +def get_install_args(device, app_name, original_args=None): + """To obtain all the args of app install + Args: + original_args: the argus configure in .config file + device : the device will be installed app + app_name : the name of the app which will be installed + Returns: + All the args + """ + if original_args is None: + original_args = [] + new_args = original_args[:] + try: + sdk_version = device.get_property("ro.build.version.sdk") + if int(sdk_version) > TARGET_SDK_VERSION: + new_args.append("-g") + except TypeError as type_error: + LOG.error("Obtain the sdk version failed with exception {}".format( + type_error)) + except ValueError as value_error: + LOG.error("Obtain the sdk version failed with exception {}".format( + value_error)) + if app_name.endswith(".apex"): + new_args.append("--apex") + return " ".join(new_args) + + +def get_app_name_by_tool(app_path, paths): + """To obtain the app name by using tool + Args: + app_path: the path of app + paths: + Returns: + The Pkg Name if found else None + """ + rex = "^package:\\s+name='(.*?)'.*$" + aapt_tool_name = "aapt.exe" if os.name == "nt" else "aapt" + if app_path: + proc_timer = None + try: + tool_file = get_file_absolute_path(os.path.join( + "tools", aapt_tool_name), paths) + LOG.debug("Aapt file is %s" % tool_file) + + if platform.system() == "Linux" or platform.system() == "Darwin": + if not oct(os.stat(tool_file).st_mode)[-3:] == "755": + os.chmod(tool_file, FilePermission.mode_755) + + cmd = [tool_file, "dump", "badging", app_path] + timeout = 300 + LOG.info("Execute command %s with %s" % (" ".join(cmd), timeout)) + + sub_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + proc_timer = Timer(timeout, timeout_callback, [sub_process]) + proc_timer.start() + # The package name must be return in first line + output = sub_process.stdout.readline() + error = sub_process.stderr.readline() + LOG.debug("The output of aapt is {}".format(output)) + if error: + LOG.debug("The error of aapt is {}".format(error)) + if output: + pkg_match = re.match(rex, output.decode("utf8", 'ignore')) + if pkg_match is not None: + LOG.info( + "Obtain the app name {} successfully by using " + "aapt".format(pkg_match.group(1))) + return pkg_match.group(1) + return None + except (FileNotFoundError, ParamError) as error: + LOG.debug("Aapt error: %s", error.args) + return None + finally: + if proc_timer: + proc_timer.cancel() + else: + LOG.error("get_app_name_by_tool error.") + return None + + +def timeout_callback(proc): + try: + LOG.error("Error: execute command timeout.") + LOG.error(proc.pid) + if platform.system() != "Windows": + os.killpg(proc.pid, signal.SIGKILL) + else: + subprocess.call( + ["C:\\Windows\\System32\\taskkill", "/F", "/T", "/PID", + str(proc.pid)], shell=False) + except (FileNotFoundError, KeyboardInterrupt, AttributeError) as error: + LOG.exception("Timeout callback exception: %s" % error, exc_info=False) + + +def disable_keyguard(device): + unlock_screen(device) + unlock_device(device) + + +def unlock_screen(device): + device.execute_shell_command("svc power stayon true") + time.sleep(1) + + +def unlock_device(device): + device.execute_shell_command("input keyevent 82") + time.sleep(1) + device.execute_shell_command("wm dismiss-keyguard") + time.sleep(1) diff --git a/xdevice/src/xdevice/_core/utils.py b/xdevice/src/xdevice/_core/utils.py new file mode 100644 index 0000000..fe6e0a2 --- /dev/null +++ b/xdevice/src/xdevice/_core/utils.py @@ -0,0 +1,738 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# + +import copy +import os +import socket +import sys +import time +import platform +import argparse +import subprocess +import signal +import uuid +import json +import stat +from datetime import timezone +from datetime import timedelta +from datetime import datetime +from tempfile import NamedTemporaryFile + +from _core.executor.listener import SuiteResult +from _core.driver.parser_lite import ShellHandler +from _core.exception import ParamError +from _core.exception import ExecuteTerminate +from _core.logger import platform_logger +from _core.report.suite_reporter import SuiteReporter +from _core.plugin import get_plugin +from _core.plugin import Plugin +from _core.constants import ModeType +from _core.constants import ConfigConst + +LOG = platform_logger("Utils") + + +def get_filename_extension(file_path): + _, fullname = os.path.split(file_path) + filename, ext = os.path.splitext(fullname) + return filename, ext + + +def unique_id(type_name, value): + return "{}_{}_{:0>8}".format(type_name, value, + str(uuid.uuid1()).split("-")[0]) + + +def start_standing_subprocess(cmd, pipe=subprocess.PIPE, return_result=False): + """Starts a non-blocking subprocess that is going to continue running after + this function returns. + + A subprocess group is actually started by setting sid, so we can kill all + the processes spun out from the subprocess when stopping it. This is + necessary in case users pass in pipe commands. + + Args: + cmd: Command to start the subprocess with. + pipe: pipe to get execution result + return_result: return execution result or not + + Returns: + The subprocess that got started. + """ + sys_type = platform.system() + process = subprocess.Popen(cmd, stdout=pipe, shell=False, + preexec_fn=None if sys_type == "Windows" + else os.setsid) + if not return_result: + return process + else: + rev = process.stdout.read() + return rev.decode("utf-8").strip() + + +def stop_standing_subprocess(process): + """Stops a subprocess started by start_standing_subprocess. + + Catches and ignores the PermissionError which only happens on Macs. + + Args: + process: Subprocess to terminate. + """ + try: + sys_type = platform.system() + signal_value = signal.SIGINT if sys_type == "Windows" \ + else signal.SIGTERM + os.kill(process.pid, signal_value) + except (PermissionError, AttributeError, FileNotFoundError, # pylint:disable=undefined-variable + SystemError) as error: + LOG.error("Stop standing subprocess error '%s'" % error) + + +def get_decode(stream): + if not isinstance(stream, str) and not isinstance(stream, bytes): + ret = str(stream) + else: + try: + ret = stream.decode("utf-8", errors="ignore") + except (ValueError, AttributeError, TypeError) as _: + ret = str(stream) + return ret + + +def is_proc_running(pid, name=None): + if hasattr(sys, ConfigConst.env_pool_cache) and getattr(sys, ConfigConst.env_pool_cache, False): + return True + if platform.system() == "Windows": + pid = "{}.exe".format(pid) + proc_sub = subprocess.Popen(["C:\\Windows\\System32\\tasklist"], + stdout=subprocess.PIPE, + shell=False) + proc = subprocess.Popen(["C:\\Windows\\System32\\findstr", "/B", "%s" % pid], + stdin=proc_sub.stdout, + stdout=subprocess.PIPE, shell=False) + elif platform.system() == "Linux": + # /bin/ps -ef | /bin/grep -v grep | /bin/grep -w pid + proc_sub = subprocess.Popen(["/bin/ps", "-ef"], + stdout=subprocess.PIPE, + shell=False) + proc_v_sub = subprocess.Popen(["/bin/grep", "-v", "grep"], + stdin=proc_sub.stdout, + stdout=subprocess.PIPE, + shell=False) + proc = subprocess.Popen(["/bin/grep", "-w", "%s" % pid], + stdin=proc_v_sub.stdout, + stdout=subprocess.PIPE, shell=False) + elif platform.system() == "Darwin": + proc_sub = subprocess.Popen(["/bin/ps", "-ef"], + stdout=subprocess.PIPE, + shell=False) + proc_v_sub = subprocess.Popen(["/usr/bin/grep", "-v", "grep"], + stdin=proc_sub.stdout, + stdout=subprocess.PIPE, + shell=False) + proc = subprocess.Popen(["/usr/bin/grep", "-w", "%s" % pid], + stdin=proc_v_sub.stdout, + stdout=subprocess.PIPE, shell=False) + else: + raise Exception("Unknown system environment") + + (out, _) = proc.communicate(timeout=60) + out = get_decode(out).strip() + LOG.debug("Check %s proc running output: %s", pid, out) + if out == "": + return False + else: + return True if name is None else out.find(name) != -1 + + +def exec_cmd(cmd, timeout=5 * 60, error_print=True, join_result=False, redirect=False): + """ + Executes commands in a new shell. Directing stderr to PIPE. + + This is fastboot's own exe_cmd because of its peculiar way of writing + non-error info to stderr. + + Args: + cmd: A sequence of commands and arguments. + timeout: timeout for exe cmd. + error_print: print error output or not. + join_result: join error and out + redirect: redirect output + Returns: + The output of the command run. + """ + # PIPE本身可容纳的量比较小,所以程序会卡死,所以一大堆内容输出过来的时候,会导致PIPE不足够处理这些内容,因此需要将输出内容定位到其他地方,例如临时文件等 + import tempfile + out_temp = tempfile.SpooledTemporaryFile(max_size=10 * 1000) + fileno = out_temp.fileno() + + sys_type = platform.system() + if sys_type == "Linux" or sys_type == "Darwin": + if redirect: + proc = subprocess.Popen(cmd, stdout=fileno, + stderr=fileno, shell=False, + preexec_fn=os.setsid) + else: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, shell=False, + preexec_fn=os.setsid) + else: + if redirect: + proc = subprocess.Popen(cmd, stdout=fileno, + stderr=fileno, shell=False) + else: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, shell=False) + try: + (out, err) = proc.communicate(timeout=timeout) + err = get_decode(err).strip() + out = get_decode(out).strip() + if err and error_print: + LOG.exception(err, exc_info=False) + if join_result: + return "%s\n %s" % (out, err) if err else out + else: + return err if err else out + + except (TimeoutError, KeyboardInterrupt, AttributeError, ValueError, # pylint:disable=undefined-variable + EOFError, IOError) as _: + sys_type = platform.system() + if sys_type == "Linux" or sys_type == "Darwin": + os.killpg(proc.pid, signal.SIGTERM) + else: + os.kill(proc.pid, signal.SIGINT) + raise + + +def create_dir(path): + """Creates a directory if it does not exist already. + + Args: + path: The path of the directory to create. + """ + full_path = os.path.abspath(os.path.expanduser(path)) + if not os.path.exists(full_path): + os.makedirs(full_path, exist_ok=True) + + +def get_config_value(key, config_dict, is_list=True, default=None): + """Get corresponding values for key in config_dict + + Args: + key: target key in config_dict + config_dict: dictionary that store values + is_list: decide return values is list type or not + default: if key not in config_dict, default value will be returned + + Returns: + corresponding values for key + """ + if not isinstance(config_dict, dict): + return default + + value = config_dict.get(key, None) + if isinstance(value, bool): + return value + + if value is None: + if default is not None: + return default + return [] if is_list else "" + + if isinstance(value, list): + return value if is_list else value[0] + return [value] if is_list else value + + +def get_file_absolute_path(input_name, paths=None, alt_dir=None): + """Find absolute path for input_name + + Args: + input_name: the target file to search + paths: path list for searching input_name + alt_dir: extra dir that appended to paths + + Returns: + absolute path for input_name + """ + LOG.debug("Input name:{}, paths:{}, alt dir:{}". + format(input_name, paths, alt_dir)) + input_name = str(input_name) + abs_paths = set(paths) if paths else set() + _update_paths(abs_paths) + + _inputs = [input_name] + if input_name.startswith("resource/"): + _inputs.append(input_name.replace("resource/", "", 1)) + elif input_name.startswith("testcases/"): + _inputs.append(input_name.replace("testcases/", "", 1)) + elif input_name.startswith("resource\\"): + _inputs.append(input_name.replace("resource\\", "", 1)) + elif input_name.startswith("testcases\\"): + _inputs.append(input_name.replace("testcases\\", "", 1)) + + for _input in _inputs: + for path in abs_paths: + if alt_dir: + file_path = os.path.join(path, alt_dir, _input) + if os.path.exists(file_path): + return os.path.abspath(file_path) + + file_path = os.path.join(path, _input) + if os.path.exists(file_path): + return os.path.abspath(file_path) + + err_msg = "The file {} does not exist".format(input_name) + if check_mode(ModeType.decc): + LOG.error(err_msg, error_no="00109") + err_msg = "Load Error[00109]" + + if alt_dir: + LOG.debug("Alt dir is %s" % alt_dir) + LOG.debug("Paths is:") + for path in abs_paths: + LOG.debug(path) + raise ParamError(err_msg, error_no="00109") + + +def _update_paths(paths): + from xdevice import Variables + resource_dir = "resource" + testcases_dir = "testcases" + + need_add_path = set() + for path in paths: + if not os.path.exists(path): + continue + head, tail = os.path.split(path) + if not tail: + head, tail = os.path.split(head) + if tail in [resource_dir, testcases_dir]: + need_add_path.add(head) + paths.update(need_add_path) + + inner_dir = os.path.abspath(os.path.join(Variables.exec_dir, + testcases_dir)) + top_inner_dir = os.path.abspath(os.path.join(Variables.top_dir, + testcases_dir)) + res_dir = os.path.abspath(os.path.join(Variables.exec_dir, resource_dir)) + top_res_dir = os.path.abspath(os.path.join(Variables.top_dir, + resource_dir)) + paths.update([inner_dir, res_dir, top_inner_dir, top_res_dir, + Variables.exec_dir, Variables.top_dir]) + + +def modify_props(device, local_prop_file, target_prop_file, new_props): + """To change the props if need + Args: + device: the device to modify props + local_prop_file : the local file to save the old props + target_prop_file : the target prop file to change + new_props : the new props + Returns: + True : prop file changed + False : prop file no need to change + """ + is_changed = False + device.pull_file(target_prop_file, local_prop_file) + old_props = {} + changed_prop_key = [] + lines = [] + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(local_prop_file, flags, modes), "r") as old_file: + lines = old_file.readlines() + if lines: + lines[-1] = lines[-1] + '\n' + for line in lines: + line = line.strip() + if not line.startswith("#") and line.find("=") > 0: + key_value = line.split("=") + if len(key_value) == 2: + old_props[line.split("=")[0]] = line.split("=")[1] + + for key, value in new_props.items(): + if key not in old_props.keys(): + lines.append("".join([key, "=", value, '\n'])) + is_changed = True + elif old_props.get(key) != value: + changed_prop_key.append(key) + is_changed = True + + if is_changed: + local_temp_prop_file = NamedTemporaryFile(mode='w', prefix='build', + suffix='.tmp', delete=False) + for index, line in enumerate(lines): + if not line.startswith("#") and line.find("=") > 0: + key = line.split("=")[0] + if key in changed_prop_key: + lines[index] = "".join([key, "=", new_props[key], '\n']) + local_temp_prop_file.writelines(lines) + local_temp_prop_file.close() + device.push_file(local_temp_prop_file.name, target_prop_file) + device.execute_shell_command(" ".join(["chmod 644", target_prop_file])) + LOG.info("Changed the system property as required successfully") + os.remove(local_temp_prop_file.name) + + return is_changed + + +def get_device_log_file(report_path, serial=None, log_name="device_log", + device_name="", module_name=None): + from xdevice import Variables + if module_name: + log_path = os.path.join(report_path, Variables.report_vars.log_dir, module_name) + else: + log_path = os.path.join(report_path, Variables.report_vars.log_dir) + os.makedirs(log_path, exist_ok=True) + + serial = serial or time.time_ns() + if device_name: + serial = "%s_%s" % (device_name, serial) + device_file_name = "{}_{}.log".format(log_name, str(serial).replace( + ":", "_")) + device_log_file = os.path.join(log_path, device_file_name) + LOG.info("Generate device log file: %s", device_log_file) + return device_log_file + + +def check_result_report(report_root_dir, report_file, error_message="", + report_name="", module_name=""): + """ + Check whether report_file exits or not. If report_file is not exist, + create empty report with error_message under report_root_dir + """ + + if os.path.exists(report_file): + return report_file + report_dir = os.path.dirname(report_file) + if os.path.isabs(report_dir): + result_dir = report_dir + else: + result_dir = os.path.join(report_root_dir, "result", report_dir) + os.makedirs(result_dir, exist_ok=True) + if check_mode(ModeType.decc): + LOG.error("Report not exist, create empty report") + else: + LOG.error("Report %s not exist, create empty report under %s" % ( + report_file, result_dir)) + + suite_name = report_name + if not suite_name: + suite_name, _ = get_filename_extension(report_file) + suite_result = SuiteResult() + suite_result.suite_name = suite_name + suite_result.stacktrace = error_message + if module_name: + suite_name = module_name + suite_reporter = SuiteReporter([(suite_result, [])], suite_name, + result_dir, modulename=module_name) + suite_reporter.create_empty_report() + return "%s.xml" % os.path.join(result_dir, suite_name) + + +def get_sub_path(test_suite_path): + pattern = "%stests%s" % (os.sep, os.sep) + file_dir = os.path.dirname(test_suite_path) + pos = file_dir.find(pattern) + if -1 == pos: + return "" + + sub_path = file_dir[pos + len(pattern):] + pos = sub_path.find(os.sep) + if -1 == pos: + return "" + return sub_path[pos + len(os.sep):] + + +def is_config_str(content): + return True if "{" in content and "}" in content else False + + +def is_python_satisfied(): + mini_version = (3, 7, 0) + if sys.version_info > mini_version: + return True + LOG.error("Please use python {} or higher version to start project".format(mini_version)) + return False + + +def get_version(): + from xdevice import Variables + ver = '' + ver_file_path = os.path.join(Variables.res_dir, 'version.txt') + if not os.path.isfile(ver_file_path): + return ver + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(ver_file_path, flags, modes), + "rb") as ver_file: + content_list = ver_file.read().decode("utf-8").split("\n") + for line in content_list: + if line.strip() and "-v" in line: + ver = line.strip().split('-')[1] + ver = ver.split(':')[0][1:] + break + + return ver + + +def get_instance_name(instance): + return instance.__class__.__name__ + + +def convert_ip(origin_ip): + addr = origin_ip.strip().split(".") + if len(addr) == 4: + return "{}.{}.{}.{}".format( + addr[0], '*' * len(addr[1]), '*' * len(addr[2]), addr[-1]) + else: + return origin_ip + + +def convert_port(port): + _port = str(port) + if len(_port) >= 2: + return "{}{}{}".format(_port[0], "*" * (len(_port) - 2), _port[-1]) + else: + return "*{}".format(_port[-1]) + + +def convert_serial(serial): + if serial.startswith("local_"): + return serial + elif serial.startswith("remote_"): + return "remote_{}_{}".format(convert_ip(serial.split("_")[1]), + convert_port(serial.split("_")[-1])) + else: + length = len(serial) // 3 + return "{}{}{}".format( + serial[0:length], "*" * (len(serial) - length * 2), serial[-length:]) + + +def get_shell_handler(request, parser_type): + suite_name = request.root.source.test_name + parsers = get_plugin(Plugin.PARSER, parser_type) + if parsers: + parsers = parsers[:1] + parser_instances = [] + for listener in request.listeners: + listener.device_sn = request.config.environment.devices[0].device_sn + for parser in parsers: + parser_instance = parser.__class__() + parser_instance.suite_name = suite_name + parser_instance.listeners = request.listeners + parser_instances.append(parser_instance) + handler = ShellHandler(parser_instances) + return handler + + +def get_kit_instances(json_config, resource_path="", testcases_path=""): + from _core.testkit.json_parser import JsonParser + kit_instances = [] + + # check input param + if not isinstance(json_config, JsonParser): + return kit_instances + + # get kit instances + for kit in json_config.config.kits: + kit["paths"] = [resource_path, testcases_path] + kit_type = kit.get("type", "") + device_name = kit.get("device_name", None) + if get_plugin(plugin_type=Plugin.TEST_KIT, plugin_id=kit_type): + test_kit = \ + get_plugin(plugin_type=Plugin.TEST_KIT, plugin_id=kit_type)[0] + test_kit_instance = test_kit.__class__() + test_kit_instance.__check_config__(kit) + setattr(test_kit_instance, "device_name", device_name) + kit_instances.append(test_kit_instance) + else: + raise ParamError("kit %s not exists" % kit_type, error_no="00107") + return kit_instances + + +def check_device_name(device, kit, step="setup"): + kit_device_name = getattr(kit, "device_name", None) + device_name = device.get("name") + if kit_device_name and device_name and \ + kit_device_name != device_name: + return False + if kit_device_name and device_name: + LOG.debug("Do kit:%s %s for device:%s", + kit.__class__.__name__, step, device_name) + else: + LOG.debug("Do kit:%s %s", kit.__class__.__name__, step) + return True + + +def check_device_env_index(device, kit): + if not hasattr(device, "env_index"): + return True + kit_device_index_list = getattr(kit, "env_index_list", None) + env_index = device.get("env_index") + if kit_device_index_list and env_index and \ + len(kit_device_index_list) > 0 and env_index not in kit_device_index_list: + return False + return True + + +def check_path_legal(path): + if path and " " in path: + return "\"%s\"" % path + return path + + +def get_local_ip(): + try: + sys_type = platform.system() + if sys_type == "Windows": + _list = socket.gethostbyname_ex(socket.gethostname()) + _list = _list[2] + for ip_add in _list: + if ip_add.startswith("10."): + return ip_add + + return socket.gethostbyname(socket.getfqdn(socket.gethostname())) + elif sys_type == "Darwin": + hostname = socket.getfqdn(socket.gethostname()) + return socket.gethostbyname(hostname) + elif sys_type == "Linux": + real_ip = "/%s/%s" % ("hostip", "realip") + if os.path.exists(real_ip): + srw = None + try: + import codecs + srw = codecs.open(real_ip, "r", "utf-8") + lines = srw.readlines() + local_ip = str(lines[0]).strip() + except (IOError, ValueError) as error_message: + LOG.error(error_message) + local_ip = "127.0.0.1" + finally: + if srw is not None: + srw.close() + else: + local_ip = "127.0.0.1" + return local_ip + else: + return "127.0.0.1" + except Exception as error: + LOG.debug("Get local ip error: %s, skip!" % error) + return "127.0.0.1" + + +class SplicingAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, " ".join(values)) + + +def get_test_component_version(config): + if check_mode(ModeType.decc): + return "" + + try: + paths = [config.resource_path, config.testcases_path] + test_file = get_file_absolute_path("test_component.json", paths) + flags = os.O_RDONLY + modes = stat.S_IWUSR | stat.S_IRUSR + with os.fdopen(os.open(test_file, flags, modes), "r") as file_content: + json_content = json.load(file_content) + version = json_content.get("version", "") + return version + except (ParamError, ValueError) as error: + LOG.error("The exception {} happened when get version".format(error)) + return "" + + +def check_mode(mode): + from xdevice import Scheduler + return Scheduler.mode == mode + + +def do_module_kit_setup(request, kits): + for device in request.get_devices(): + setattr(device, ConfigConst.module_kits, []) + + from xdevice import Scheduler + for kit in kits: + run_flag = False + for device in request.get_devices(): + if not Scheduler.is_execute: + raise ExecuteTerminate() + if not check_device_env_index(device, kit): + continue + if check_device_name(device, kit): + run_flag = True + kit_copy = copy.deepcopy(kit) + module_kits = getattr(device, ConfigConst.module_kits) + module_kits.append(kit_copy) + kit_copy.__setup__(device, request=request) + if not run_flag: + kit_device_name = getattr(kit, "device_name", None) + error_msg = "device name '%s' of '%s' not exist" % ( + kit_device_name, kit.__class__.__name__) + LOG.error(error_msg, error_no="00108") + raise ParamError(error_msg, error_no="00108") + + +def do_module_kit_teardown(request): + for device in request.get_devices(): + for kit in getattr(device, ConfigConst.module_kits, []): + if check_device_name(device, kit, step="teardown"): + kit.__teardown__(device) + setattr(device, ConfigConst.module_kits, []) + + +def get_cst_time(): + cn_tz = timezone(timedelta(hours=8), + name='Asia/ShangHai') + return datetime.now(tz=cn_tz) + + +def get_delta_time_ms(start_time): + end_time = get_cst_time() + delta = round(float((end_time - start_time).total_seconds()) * 1000, 3) + return delta + + +def get_device_proc_pid(device, proc_name, double_check=False): + if not hasattr(device, "execute_shell_command") or \ + not hasattr(device, "log") or \ + not hasattr(device, "get_recover_state"): + return "" + if not device.get_recover_state(): + return "" + cmd = 'ps -ef | grep %s' % proc_name + proc_running = device.execute_shell_command(cmd).strip() + proc_running = proc_running.split("\n") + for data in proc_running: + if proc_name in data and "grep" not in data: + device.log.debug('{} running status:{}'.format(proc_name, data)) + data = data.split() + return data[1] + if double_check: + cmd = 'ps -A | grep %s' % proc_name + proc_running = device.execute_shell_command(cmd).strip() + proc_running = proc_running.split("\n") + for data in proc_running: + if proc_name in data: + device.log.debug('{} running status double_check:{}'.format(proc_name, data)) + data = data.split() + return data[0] + return "" diff --git a/xdevice/src/xdevice/variables.py b/xdevice/src/xdevice/variables.py new file mode 100644 index 0000000..e0016ea --- /dev/null +++ b/xdevice/src/xdevice/variables.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +# +# Copyright (c) 2022 Huawei Device Co., Ltd. +# 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. +# +import getpass +import os +import platform +import sys +import tempfile +from dataclasses import dataclass + +__all__ = ["Variables"] + +SRC_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) +MODULES_DIR = os.path.abspath(os.path.dirname(__file__)) +TOP_DIR = os.path.abspath( + os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) +sys.path.insert(0, SRC_DIR) +sys.path.insert(1, MODULES_DIR) +sys.path.insert(2, TOP_DIR) + + +@dataclass +class ReportVariables: + report_dir = "" + log_dir = "" + log_format = "" + log_level = "" + log_handler = "" + pub_key_file = None + pub_key_string = "" + + +@dataclass +class Variables: + modules_dir = "" + top_dir = "" + res_dir = "" + exec_dir = "" + temp_dir = "" + report_vars = ReportVariables() + task_name = "" + source_code_rootpath = "" + + +def _init_global_config(): + import logging + + from _core.common import get_source_code_rootpath + + Variables.modules_dir = MODULES_DIR + Variables.top_dir = TOP_DIR + Variables.res_dir = os.path.abspath(os.path.join( + MODULES_DIR, "_core", "resource")) + + # create xdevice temp folder + Variables.temp_dir = os.path.join(_get_temp_dir(), "xdevice_data") + if not os.path.exists(Variables.temp_dir): + os.makedirs(Variables.temp_dir) + + # set report variables + Variables.report_vars.log_dir = "log" + Variables.report_vars.report_dir = "reports" + Variables.report_vars.log_format = \ + "[%(asctime)s] [%(thread)d] [%(name)s] [%(levelname)s] %(message)s" + Variables.report_vars.log_level = logging.INFO + Variables.report_vars.log_handler = "console, file" + + # set execution directory + if not Variables.exec_dir: + current_exec_dir = os.path.abspath(os.getcwd()) + try: + common_path = os.path.commonpath([Variables.top_dir, + current_exec_dir]) + if os.path.normcase(common_path) == os.path.normcase( + Variables.top_dir): + Variables.exec_dir = common_path + else: + Variables.exec_dir = current_exec_dir + except (ValueError, AttributeError) as _: + Variables.exec_dir = current_exec_dir + Variables.source_code_rootpath = get_source_code_rootpath( + Variables.top_dir) + _init_logger() + + +def _get_temp_dir(): + name = platform.system() + if name == "Linux": + return os.path.join(tempfile.gettempdir(), getpass.getuser()) + else: + return tempfile.gettempdir() + + +def _init_logger(): + import time + from _core.constants import LogType + from _core.logger import Log + from _core.plugin import Plugin + from _core.plugin import get_plugin + + tool_logger_plugin = get_plugin(Plugin.LOG, LogType.tool) + if tool_logger_plugin: + return + + @Plugin(type=Plugin.LOG, id=LogType.tool, enabled=True) + class ToolLog(Log): + @classmethod + def get_plugin_type(cls): + return Plugin.LOG + + @classmethod + def get_plugin_id(cls): + return LogType.tool + + tool_log_file = None + if Variables.exec_dir and os.path.normcase( + Variables.exec_dir) == os.path.normcase(Variables.top_dir): + host_log_path = os.path.join(Variables.exec_dir, + Variables.report_vars.report_dir, + Variables.report_vars.log_dir) + os.makedirs(host_log_path, exist_ok=True) + time_str = time.strftime('%Y-%m-%d-%H-%M-%S', time.localtime()) + tool_file_name = "platform_log_{}.log".format(time_str) + tool_log_file = os.path.join(host_log_path, tool_file_name) + + tool_logger_plugin = get_plugin(Plugin.LOG, LogType.tool)[0] or ToolLog() + tool_logger_plugin.__initial__(Variables.report_vars.log_handler, + tool_log_file, + Variables.report_vars.log_level, + Variables.report_vars.log_format) + + +def _iter_module_plugins(packages): + import importlib + import pkgutil + for package in packages: + pkg_path = getattr(package, "__path__", "") + pkg_name = getattr(package, "__name__", "") + if not pkg_name or not pkg_path: + continue + + _iter_modules = pkgutil.iter_modules(pkg_path, "%s%s" % ( + pkg_name, ".")) + for _, name, _ in _iter_modules: + importlib.import_module(name) + + +def _load_internal_plugins(): + import _core.driver + import _core.testkit + import _core.environment + import _core.executor + _iter_module_plugins([_core.driver, _core.testkit, _core.environment, + _core.executor]) + + +_init_global_config() +_load_internal_plugins() + +del _init_global_config +del _init_logger +del _load_internal_plugins +del _iter_module_plugins -- Gitee From a3854b0363bf365b15c3a11f0e7d7fd383af14e2 Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Mon, 9 Oct 2023 11:36:35 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dxdevice=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/aosp/constants.py | 2 +- xdevice/plugins/aosp/environment/device.py | 31 ++++--- xdevice/plugins/aosp/environment/dmlib.py | 81 ++++++++++--------- .../plugins/aosp/managers/manager_device.py | 10 ++- xdevice/plugins/aosp/testkit/kit.py | 13 +-- xdevice/plugins/ios/environment/device.py | 12 +-- xdevice/plugins/ios/environment/dmlib.py | 10 +-- .../plugins/ios/managers/manager_device.py | 12 +-- xdevice/plugins/ios/testkit/kit.py | 28 +++---- .../plugins/ohos/src/ohos/drivers/arkuix.py | 9 ++- 10 files changed, 106 insertions(+), 102 deletions(-) diff --git a/xdevice/plugins/aosp/constants.py b/xdevice/plugins/aosp/constants.py index a7785db..6d9cff3 100644 --- a/xdevice/plugins/aosp/constants.py +++ b/xdevice/plugins/aosp/constants.py @@ -30,7 +30,7 @@ class UsbConst: shell = "adb shell" server_port = "ANDROID_ADB_SERVER_PORT" kill_server = "adb kill-server" - start_server = "adb start_server" + start_server = "adb start-server" reboot = "adb reboot" diff --git a/xdevice/plugins/aosp/environment/device.py b/xdevice/plugins/aosp/environment/device.py index f321951..4ca83a8 100644 --- a/xdevice/plugins/aosp/environment/device.py +++ b/xdevice/plugins/aosp/environment/device.py @@ -89,7 +89,7 @@ def perform_device_action(func): cmd = ["hdc", "reset"] self.log.info("re-execute hdc reset") else: - cmd = [UsbConst.connector_type, "start-server"] + cmd = [UsbConst.connector, "start-server"] self.log.info("re-execute {}".format(cmd)) exec_cmd(cmd) callback_to_outer(self, "error:{}, prepare to recover".format(error)) @@ -192,7 +192,7 @@ class DeviceAosp(IDevice): return False LOG.debug("Wait device {} to recover".format(self.device_sn)) - result = self.device_state_monitor.wait_for_device_available() + result = self.device_state_monitor.wait_for_device_available(self.reboot_timeout) if result: self.device_log_collector.restart_catch_device_log() return result @@ -212,11 +212,11 @@ class DeviceAosp(IDevice): LOG.debug(stdout) return stdout - @staticmethod + @perform_device_action def connector_command(self, command, **kwargs): timeout = int(kwargs.get("timeout", TIMEOUT)) / 1000 - error_print = bool(kwargs.get("error_print"), True) - join_result = bool(kwargs.get("join_result"), False) + error_print = bool(kwargs.get("error_print", True)) + join_result = bool(kwargs.get("join_result", False)) timeout_msg = '' if timeout == 300.0 else " with timeout {}s".format(timeout) if self.usb_type == DeviceConnectorType.hdc: LOG.debug("{} execute command hdc {}{}".format(convert_serial(self.device_sn), command, timeout_msg)) @@ -233,7 +233,7 @@ class DeviceAosp(IDevice): cmd.extend(command) else: command = command.strip() - command.extend(command.split(" ")) + cmd.extend(command.split(" ")) result = exec_cmd(cmd, timeout, error_print, join_result) if not result: return result @@ -252,7 +252,7 @@ class DeviceAosp(IDevice): return AdbHelper.execute_shell_command(self, command, timeout=timeout, receiver=receiver, **kwargs) def execute_shell_cmd_background(self, command, timeout=TIMEOUT, receiver=None): - status = AdbHelper.execute_shell_command(self, command, timeout, receiver=receiver) + status = AdbHelper.execute_shell_command(self, command, timeout=timeout, receiver=receiver) self.wait_for_device_not_available(DEFAULT_UNAVAILABLE_TIMEOUT) self.device_state_monitor.wait_for_device_available(BACKGROUND_TIME) cmd = "target mount" if self.usb_type == DeviceConnectorType.hdc else "remount" @@ -347,6 +347,13 @@ class DeviceAosp(IDevice): return True return False + def is_file_exist(self, file_path): + file_path = check_path_legal(file_path) + output = self.execute_shell_command("ls {}".format(file_path)) + if output and "No such file or directory" not in output: + return True + return False + def get_recover_result(self, retry=RETRY_ATTEMPTS): command = "getprop dev.bootcomplete" try: @@ -409,7 +416,7 @@ class DeviceLogCollector: def restart_catch_device_log(self): if len(self.hilog_file_address) != len(self.log_file_address): - self.device.log.warinng("hilog address not equals to log address.") + self.device.log.warning("hilog address not equals to log address.") return from xdevice import FilePermission for index, _ in enumerate(self.log_file_address): @@ -506,7 +513,7 @@ class DeviceLogCollector: else: log_path = "{}/log/crash_log_{}/".format(self.device.get_device_report_path(), task_name) if not os.path.exists(log_path): - os.mkdir(log_path) + os.makedirs(log_path) self.device.pull_file("/data/log/faultlog/faultlogger", log_path) def clear_crash_log(self): @@ -524,13 +531,13 @@ class DeviceLogCollector: if log_file_address and log_file_address in self.log_file_address: self.log_file_address.remove(log_file_address) if hilog_file_address and hilog_file_address in self.hilog_file_address: - self.log_file_address.remove(hilog_file_address) + self.hilog_file_address.remove(hilog_file_address) def pull_extra_log_files(self, task_name, module_name, dirs: str): if dirs is None: return - dirs_list = dirs.strip(";") - for dir_path in dirs_list: + dir_list = dirs.split(";") + for dir_path in dir_list: extra_log_path = "{}/log/{}/{}_extra_log/".format(self.device.get_device_report_path(), module_name, task_name) self.device.pull_file(dir_path, extra_log_path) diff --git a/xdevice/plugins/aosp/environment/dmlib.py b/xdevice/plugins/aosp/environment/dmlib.py index b7467cf..50fd0d5 100644 --- a/xdevice/plugins/aosp/environment/dmlib.py +++ b/xdevice/plugins/aosp/environment/dmlib.py @@ -146,6 +146,7 @@ class AdbMonitor: connector_name = UsbConst.connector else: LOG.error("AdbMonitor can't find HDC or ADB, init device environment failed!") + return if not is_proc_running(connector_name, HDC_NAME): port = DEFAULT_PORT self.start_adb(connector=connector_name, local_port=self.channel.setdefault("port", port)) @@ -192,14 +193,14 @@ class AdbMonitor: except (socket.error, socket.gaierror, socket.timeout) as _: LOG.error("AdbMonitor close socket exception") AdbMonitor.MONITOR_MAP.clear() - LOG.debug("AdbMonitor {} monitor stop!".format(AdbMonitor.CONNECTOR_NAME)) + LOG.debug("AdbMonitor {} monitor stop!".format(AdbHelper.CONNECTOR_NAME)) LOG.debug("AdbMonitor map is {}".format(AdbMonitor.MONITOR_MAP)) def loop_monitor(self): """ Monitors the devices. This connects to the Debug Bridge """ - LOG.debug("current connector name is {}".format(AdbMonitor.CONNECTOR_NAME)) + LOG.debug("current connector name is {}".format(AdbHelper.CONNECTOR_NAME)) while not self.is_stop: try: if self.main_adb_connection is None: @@ -216,7 +217,7 @@ class AdbMonitor: LOG.debug("AdbMonitor Connection attempts: {}".format(str(self.connection_attempt))) time.sleep(2) else: - LOG.debug("AdbMonitor Connection to {} for device monitoring, main_adb_connection is {}".format( + LOG.debug("AdbMonitor Connected to {} for device monitoring, main_adb_connection is {}".format( AdbHelper.CONNECTOR_NAME, self.main_adb_connection)) self.track_devices() except (HdcError, Exception) as _: @@ -232,21 +233,24 @@ class AdbMonitor: AdbHelper.CONNECTOR_NAME, AdbHelper.CONNECTOR_NAME, self.main_adb_connection)) def device_list_monitoring(self): - request = AdbHelper.form_adb_requset("host:track-devices") + request = AdbHelper.form_adb_request("host:track-devices") AdbHelper.write(self.main_adb_connection, request) resp = AdbHelper.read_adb_response(self.main_adb_connection) if not resp.okay: - LOG.error("AdbMonitor execute command success:send device_list monitoring request") + LOG.error("AdbMonitor adb rejected shell command") + raise Exception(resp.message) + else: + LOG.debug("AdbMonitor execute command success:send device_list monitoring request") return True - def process_incoming_target_data(self, length): + def process_incoming_device_data(self, length): local_array_list = [] if length > 0: data_buf = AdbHelper.read(self.main_adb_connection, length) data_str = AdbHelper.reply_to_string(data_buf) lines = data_str.split("\n") for line in lines: - items = line.strip().split("\n") + items = line.strip().split("\t") if len(items) != 2: continue device_instance = self._get_device_instance(items, DeviceOsType.aosp) @@ -259,11 +263,10 @@ class AdbMonitor: device_instance.__set_serial__(items[0]) device_instance.host = self.channel.get("host") device_instance.port = self.channel.get("port") - if self.changed: - LOG.debug("Dmlib get device instance {} {} {}".format - (device_instance.device_sn, - device_instance.host, device_instance.port)) - device_instance.device_state = DeviceState.get_state(items[3]) + LOG.debug("Dmlib get device instance {} {} {}".format + (device_instance.device_sn, + device_instance.host, device_instance.port)) + device_instance.device_state = DeviceState.get_state(items[1]) return device_instance def update_devices(self, param_array_list): @@ -280,8 +283,8 @@ class AdbMonitor: local_device2.device_state: local_device1.device_state = local_device2.device_state self.server.device_changed(local_device1) - param_array_list.remove(local_device2) - break + param_array_list.remove(local_device2) + break if k == 0: self.devices.remove(local_device1) @@ -323,7 +326,7 @@ class AdbMonitor: LOG.debug("AdbMonitor {}".format(UsbConst.start_server)) exec_cmd([UsbConst.connector, "start-server"], error_print=False) - def track_device(self): + def track_devices(self): if self.main_adb_connection and not self.monitoring: self.monitoring = self.device_list_monitoring() if self.monitoring is True: @@ -333,7 +336,7 @@ class AdbMonitor: len_str = AdbHelper.reply_to_string(len_buf) length = int(len_str, HEXADECIMAL_NUMBER) if length >= 0: - self.process_incoming_target_data(length) + self.process_incoming_device_data(length) self.server.monitor_lock.release() @@ -361,8 +364,8 @@ class SyncService: resp = AdbHelper.read_adb_response(self.sock) if not resp.okay: - self.device.log.error("Got unhappy response form HDC sync req: {}".format(resp.message)) - raise HdcError("Got unhappy response form HDC sync req: {}".format(resp.message)) + self.device.log.error("Got unhappy response from HDC sync req: {}".format(resp.message)) + raise HdcError("Got unhappy response from HDC sync req: {}".format(resp.message)) def close(self): if self.sock is not None: @@ -452,7 +455,7 @@ class SyncService: length = self.swap32bit_from_array(pull_result, 0) self.device.log.debug("do_pull_file: %s" % str(length)) else: - raise IndexError(str(index_error)) from index_error + raise index_error if length > SYNC_DATA_MAX: raise HdcError("Receiving too much data.") @@ -504,8 +507,12 @@ class SyncService: """ mode = self.read_mode(remote) self.device.log.debug("Remote file %s mode is %d" % (remote, mode)) - self.device.log.debug("%s execute command: hdc push %s %s" % ( - convert_serial(self.device.device_sn), local, remote)) + if self.device.usb_type == DeviceConnectorType.hdc: + self.device.log.debug("%s execute command: hdc push %s %s" % ( + convert_serial(self.device.device_sn), local, remote)) + else: + self.device.log.debug("%s execute command: %s push %s %s" % ( + convert_serial(self.device.device_sn), UsbConst.push, local, remote)) if str(mode).startswith("168"): remote = "%s/%s" % (remote, os.path.basename(local)) @@ -712,16 +719,16 @@ class AdbHelper: @staticmethod def pull_file(device, remote, local, is_create=False, timeout=DEFAULT_TIMEOUT): if device.usb_type == DeviceConnectorType.hdc: - device.log.info("{} execute command: {} file recv {} {}".format( + device.log.info("{} execute command: {} file recv {} to {}".format( convert_serial(device.device_sn), AdbHelper.CONNECTOR_NAME, remote, local)) else: - device.log.info("{} execute command: {} {} {} {}".format( - convert_serial(device.device_sn), AdbHelper.CONNECTOR_NAME, UsbConst.pull, remote, local)) + device.log.info("{} execute command: {} {} to {}".format( + convert_serial(device.device_sn), UsbConst.pull, remote, local)) service = None try: service = SyncService(device, host=device.host, port=device.port) service.open_sync(timeout) - service.pull(remote, local, is_create=is_create) + service.pull_file(remote, local, is_create=is_create) finally: if service is not None: service.close() @@ -729,7 +736,7 @@ class AdbHelper: @staticmethod def _install_remote_package(device, remote_file_path, command): receiver = CollectingOutputReceiver() - cmd = "bm install %s %s" % (command.strip(), remote_file_path) + cmd = "pm install %s %s" % (command.strip(), remote_file_path) AdbHelper.execute_shell_command(device, cmd, INSTALL_TIMEOUT, receiver) return receiver.output @@ -757,7 +764,7 @@ class AdbHelper: @staticmethod def uninstall_package(device, package_name): receiver = CollectingOutputReceiver() - command = "bm uninstall -n %s " % package_name + command = "pm uninstall %s " % package_name device.log.info("%s %s" % (convert_serial(device.device_sn), command)) AdbHelper.execute_shell_command(device, command, INSTALL_TIMEOUT, receiver) @@ -818,14 +825,14 @@ class AdbHelper: AdbHelper.set_device(device, sock) request = AdbHelper.form_adb_request("shell:{}".format(command)) AdbHelper.write(sock, request) - resp = AdbHelper.read_adb_response() + resp = AdbHelper.read_adb_response(sock) if not resp.okay: device.log.error( "[AdbHelper] {} rejected shell command ({}): {}".format(AdbHelper.CONNECTOR_NAME, command, resp.message)) raise HdcCommandRejectedException(resp.message) - data = sock.recv(SYNC_DATA_MAX) + data = sock.recv(SOCK_DATA_MAX) while data != b'': ret = AdbHelper.reply_to_string(data) if ret: @@ -835,7 +842,7 @@ class AdbHelper: LOG.debug(ret) if not Scheduler.is_execute: raise ExecuteTerminate() - data = AdbHelper.read(sock, SYNC_DATA_MAX) + data = AdbHelper.read(sock, SOCK_DATA_MAX) return resp except socket.timeout as error: device.log.error("ShellCommandUnresponsiveException: {} shell {} timeout[{}S]".format( @@ -876,7 +883,7 @@ class AdbHelper: @staticmethod def read_adb_response(sock, read_diag_string=False): """ - Reads the response from HDC after a command. + Reads the response from ADB after a command. Args: ------------ @@ -974,14 +981,14 @@ class AdbHelper: sock = None adb_connection = AdbMonitor.MONITOR_MAP.get(host, "127.0.0.1") while host not in AdbMonitor.MONITOR_MAP or \ - adb_connection.main_hdc_connection is None: - LOG.debug("Host: %s, port: %s, HdcMonitor map is %s" % ( + adb_connection.main_adb_connection is None: + LOG.debug("Host: %s, port: %s, AdbMonitor map is %s" % ( host, port, AdbMonitor.MONITOR_MAP)) if host in AdbMonitor.MONITOR_MAP: - LOG.debug("Monitor main hdc connection is %s" % - adb_connection.main_hdc_connection) + LOG.debug("Monitor main {} connection is {}".format(AdbHelper.CONNECTOR_NAME, + adb_connection.main_hdc_connection)) if time.time() > end: - raise HdcError("Cannot detect HDC monitor!") + raise HdcError("Cannot detect {} monitor!".format(AdbHelper.CONNECTOR_NAME)) time.sleep(2) try: @@ -1121,7 +1128,7 @@ def process_command_ret(ret, receiver): receiver.__done__() except Exception as error: LOG.exception("Error generating log report.", exc_info=False) - raise ReportException() from error + raise error if ret != "" and not receiver: lines = ret.split("\n") diff --git a/xdevice/plugins/aosp/managers/manager_device.py b/xdevice/plugins/aosp/managers/manager_device.py index e4165d6..c2e3684 100644 --- a/xdevice/plugins/aosp/managers/manager_device.py +++ b/xdevice/plugins/aosp/managers/manager_device.py @@ -114,6 +114,7 @@ class ManagerAospDevice(IDeviceManager, IFilter): if device.device_sn == idevice.device_sn and \ device.device_os_type == idevice.device_os_type: return device + return None finally: LOG.debug("Find: release list con lock") self.list_con.release() @@ -127,12 +128,11 @@ class ManagerAospDevice(IDeviceManager, IFilter): if device: return device LOG.debug("Wait for available device founded") - self.wait_times += 3 + self.wait_times += 1 if self.wait_times > timeout: self.lock_con.wait(timeout) else: self.lock_con.wait(self.wait_times) - LOG.debug("Wait for available device founded") return self.allocate_device_option(device_option) finally: LOG.debug("Apply device: release lock con lock") @@ -201,6 +201,7 @@ class ManagerAospDevice(IDeviceManager, IFilter): if device.device_sn == device_sn and \ device.device_os_type == device_os_type: return device + return None def append_device_by_sort(self, device_instance): if (not self.global_device_filter or @@ -252,8 +253,7 @@ class ManagerAospDevice(IDeviceManager, IFilter): device_instance.device_state) device_instance.device_state_monitor = \ DeviceStateMonitor(device_instance) - if idevice.device_state == DeviceState.ONLINE or \ - idevice.device_state == DeviceState.CONNECTED: + if idevice.device_state == DeviceState.ONLINE: device_instance.get_device_type() self.append_device_by_sort(device_instance) device = device_instance @@ -266,6 +266,7 @@ class ManagerAospDevice(IDeviceManager, IFilter): except HdcCommandRejectedException as hcr_error: LOG.debug("{} occurs error. Reason:{}".format (idevice.device_sn, hcr_error)) + return None finally: LOG.debug("Find or create: release list con lock") self.list_con.release() @@ -331,6 +332,7 @@ class ManagerAospDevice(IDeviceManager, IFilter): device.label if device.label else 'None', device.host, device.port)) self.device_connector.monitor_lock.release() + return "" def __filter_selector__(self, selector): if isinstance(selector, DeviceSelector): diff --git a/xdevice/plugins/aosp/testkit/kit.py b/xdevice/plugins/aosp/testkit/kit.py index 25d36d7..c019d32 100644 --- a/xdevice/plugins/aosp/testkit/kit.py +++ b/xdevice/plugins/aosp/testkit/kit.py @@ -16,8 +16,6 @@ # limitations under the License. # -from dataclasses import dataclass - from xdevice import AppInstallError from xdevice import ITestKit from xdevice import Plugin @@ -36,11 +34,6 @@ LOG = platform_logger("Kit") __all__ = ["ApkInstallKit"] -@dataclass -class Props: - trying_remove_maximum_times = 3 - - @Plugin(type=Plugin.TEST_KIT, id=CKit.install) class ApkInstallKit(ITestKit): def __init__(self): @@ -61,7 +54,7 @@ class ApkInstallKit(ITestKit): self.alt_dir = get_config_value('alt-dir', options, False) if self.alt_dir and self.alt_dir.startswith("resource/"): self.alt_dir = self.alt_dir[len("resource/"):] - self.ex_args = get_config_value('install-arg', options, False) + self.ex_args = get_config_value('install-arg', options) self.installed_app = set() self.paths = get_config_value('paths', options) self.is_pri_app = get_config_value('install-as-privapp', options, False, default=False) @@ -104,7 +97,7 @@ class ApkInstallKit(ITestKit): if self.app_list_name and len(self.app_list_name) > 0: for app_name in self.app_list_name: result = device.uninstall_package(app_name) - if result and (result.startwith("Success") or "successfully" in result): + if result and (result.startswith("Success") or "successfully" in result): LOG.debug("uninstalling package Success. result is {}".format(result)) else: LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) @@ -113,7 +106,7 @@ class ApkInstallKit(ITestKit): app_name = get_app_name_by_tool(app, [RES_PATH]) if app_name: result = device.uninstall_package(app_name) - if result and (result.startwith("Success") or "successfully" in result): + if result and (result.startswith("Success") or "successfully" in result): LOG.debug("uninstalling package Success. result is {}".format(result)) else: LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) diff --git a/xdevice/plugins/ios/environment/device.py b/xdevice/plugins/ios/environment/device.py index e326a32..a6b4fec 100644 --- a/xdevice/plugins/ios/environment/device.py +++ b/xdevice/plugins/ios/environment/device.py @@ -38,7 +38,7 @@ from xdevice import stop_standing_subprocess from xdevice import Platform from ios.environment.dmlib import IosHelper -from ios.constants.dmlib import UsbConst +from ios.constants import UsbConst __all__ = ["DeviceIos"] TIMEOUT = 300 * 1000 @@ -121,7 +121,7 @@ class DeviceIos(IDevice): _device_log_collector = None model_dict = { 'default': ProductForm.phone, - 'tablet': ProductForm.tablet + 'tablet': ProductForm.tablet, } def __init__(self): @@ -187,7 +187,7 @@ class DeviceIos(IDevice): return False LOG.debug("Wait device {} to recover".format(self.device_sn)) - result = self.device_state_monitor.wait_for_device_available() + result = self.device_state_monitor.wait_for_device_available(self.reboot_timeout) if result: self.device_log_collector.restart_catch_device_log() return result @@ -235,7 +235,7 @@ class DeviceIos(IDevice): command = [] if package_name: - command.extend(["--bundle", package_name]) + command.extend(["--bundle_id", package_name]) else: command.append("-f") if pathlib.Path(remote).is_dir(): @@ -245,7 +245,7 @@ class DeviceIos(IDevice): is_create = kwargs.get("is_create", False) timeout = kwargs.get("timeout", TIMEOUT) - return IosHelper.push_file(self, command, is_create=is_create, timeout=timeout) + return IosHelper.pull_file(self, command, is_create=is_create, timeout=timeout) def take_picture(self, name): pass @@ -298,7 +298,7 @@ class DeviceLogCollector: def stop_restart_catch_device_log(self): # when device free stop restart log proc - for _, proc in enumerate(self.restart_proc): + for _, proc in enumerate(self.restart_log_proc): self.stop_catch_device_log(proc) self.restart_log_proc.clear() self.log_file_address.clear() diff --git a/xdevice/plugins/ios/environment/dmlib.py b/xdevice/plugins/ios/environment/dmlib.py index 4545ab3..d6c3274 100644 --- a/xdevice/plugins/ios/environment/dmlib.py +++ b/xdevice/plugins/ios/environment/dmlib.py @@ -99,14 +99,14 @@ class IosMonitor: except Exception as _: LOG.error("IosMonitor close socket exception") IosMonitor.MONITOR_MAP.clear() - LOG.debug("IosMonitor {} monitor stop!".format(IosMonitor.CONNECTOR_NAME)) + LOG.debug("IosMonitor {} monitor stop!".format(IosHelper.CONNECTOR_NAME)) LOG.debug("IosMonitor map is {}".format(IosMonitor.MONITOR_MAP)) def loop_monitor(self): """ Monitors the devices. This connects to the Debug Bridge """ - LOG.debug("current connector name is {}".format(IosMonitor.CONNECTOR_NAME)) + LOG.debug("current connector name is {}".format(IosHelper.CONNECTOR_NAME)) while not self.is_stop: self.list_targets() time.sleep(1) @@ -162,10 +162,8 @@ class IosMonitor: device_instance.host = self.channel.get("host") device_instance.port = self.channel.get("port") if self.changed: - LOG.debug("Dmlib get device instance {} {} {}".format - (device_instance.device_sn, - device_instance.host, device_instance.port)) - device_instance.device_state = DeviceState.get_state(items[3]) + LOG.debug("Dmlib get device instance {}".format(device_instance.device_sn)) + device_instance.device_state = DeviceState.get_state("device") return device_instance diff --git a/xdevice/plugins/ios/managers/manager_device.py b/xdevice/plugins/ios/managers/manager_device.py index 04f6a62..8c0c879 100644 --- a/xdevice/plugins/ios/managers/manager_device.py +++ b/xdevice/plugins/ios/managers/manager_device.py @@ -73,7 +73,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): def _start_device_monitor(self, environment="", user_config_file=""): self.managed_device_listener = ManagedDeviceListener(self) device = UserConfigManager( - config_file=user_config_file, env=environment).get_device( + config_file=user_config_file, env=environment).get_ios_device( "environment/device") if device: try: @@ -113,6 +113,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): if device.device_sn == idevice.device_sn and \ device.device_os_type == idevice.device_os_type: return device + return None finally: LOG.debug("Find: release list con lock") self.list_con.release() @@ -131,7 +132,6 @@ class ManagerIosDevice(IDeviceManager, IFilter): self.lock_con.wait(timeout) else: self.lock_con.wait(self.wait_times) - LOG.debug("Wait for available device founded") return self.allocate_device_option(device_option) finally: LOG.debug("Apply device: release lock con lock") @@ -148,7 +148,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): return None try: allocated_device = None - LOG.debug("Require device label is: %s" % device_option.label) + LOG.debug("Require device label is: {}".format(device_option.label)) for device in self.devices_list: if device_option.matches(device): self.handle_device_event(device, @@ -200,6 +200,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): if device.device_sn == device_sn and \ device.device_os_type == device_os_type: return device + return None def append_device_by_sort(self, device_instance): if (not self.global_device_filter or @@ -248,8 +249,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): device_instance.device_state) device_instance.device_state_monitor = \ DeviceStateMonitor(device_instance) - if idevice.device_state == DeviceState.ONLINE or \ - idevice.device_state == DeviceState.CONNECTED: + if idevice.device_state == DeviceState.ONLINE: device_instance.get_device_type() self.append_device_by_sort(device_instance) device = device_instance @@ -262,6 +262,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): except HdcCommandRejectedException as hcr_error: LOG.debug("{} occurs error. Reason:{}".format (idevice.device_sn, hcr_error)) + return None finally: LOG.debug("Find or create: release list con lock") self.list_con.release() @@ -327,6 +328,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): device.label if device.label else 'None', device.host, device.port)) self.device_connector.monitor_lock.release() + return "" @staticmethod def convert_sn(device_sn): diff --git a/xdevice/plugins/ios/testkit/kit.py b/xdevice/plugins/ios/testkit/kit.py index 77cd7cb..dd30998 100644 --- a/xdevice/plugins/ios/testkit/kit.py +++ b/xdevice/plugins/ios/testkit/kit.py @@ -19,7 +19,6 @@ import os import plistlib import re -from dataclasses import dataclass from xdevice import AppInstallError from xdevice import FilePermission @@ -38,11 +37,6 @@ LOG = platform_logger("Kit") __all__ = ["IosAppInstallKit", "IosPushKit", "IosShellKit"] -@dataclass -class Props: - trying_remove_maximum_times = 3 - - @Plugin(type=Plugin.TEST_KIT, id=CKit.ios_app_install) class IosAppInstallKit(ITestKit): def __init__(self): @@ -215,26 +209,26 @@ class IosPushKit(ITestKit): for file_name in self.pushed_file: LOG.debug("Trying to remove file {}".format(file_name)) file_name = file_name.replace("\\", "/") - bundle_id = file_name[1].strip().split("/")[1] + bundle_id = file_name.strip().split("/")[1] if "." in bundle_id: - dst = file_name[1].strip().replace("/" + bundle_id + "/", "") + dst = file_name.strip().replace("/" + bundle_id + "/", "") else: bundle_id = None - dst = file_name[1].strip() + dst = file_name.strip() if bundle_id: command = ["--bundle_id", bundle_id, "--rm", dst] else: command = ["-f", "-R", dst] - for _ in range(Props.trying_remove_maximum_times): + for _ in range(3): ret = device.execute_shell_command(command) if "Error" not in ret: LOG.debug( "Removed file {} successfully".format(file_name)) break - else: - LOG.error("Failed to remove file {}".format(file_name)) + else: + LOG.error("Failed to remove file {}".format(file_name)) @Plugin(type=Plugin.TEST_KIT, id=CKit.ios_shell) @@ -262,9 +256,9 @@ class IosShellKit(ITestKit): LOG.debug("ShellKit teardown: device:{}".format(device.device_sn)) if len(self.tear_down_command) == 0: LOG.info("No teardown_command to run, skipping!") - else: - for command in self.tear_down_command: - run_command(device, command) + return + for command in self.tear_down_command: + run_command(device, command) def get_app_name(app): @@ -284,8 +278,8 @@ def run_command(device, command): LOG.debug("The command:{} is running".format(command)) stdout = None if command.strip() == "reboot": - device.reboor() + device.reboot() else: stdout = device.execute_shell_command(command) - LOG.error("Run command result: {}".format(stdout if stdout else "")) + LOG.debug("Run command result: {}".format(stdout if stdout else "")) return stdout diff --git a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py index 0ed6348..09f1d25 100644 --- a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py +++ b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py @@ -51,7 +51,7 @@ __all__ = ["ARKUIXJSUnitTestDriver"] LOG = platform_logger("ARKUIX") -TIME_OUT = 300 * 10000 +TIME_OUT = 300 * 1000 @Plugin(type=Plugin.DRIVER, id=DeviceTestType.arkuix_jsunit_test) @@ -101,7 +101,7 @@ class ARKUIXJSUnitTestDriver(IDriver): LOG.debug("Test case file path: {}".format(suite_file)) self.config.device.set_device_report_path(request.config.report_path) self.config.device.device_log_collector.clear_crash_log() - log_level = self.config.device_log.get(ConfigConst.tag_enable, "DEBUG") + log_level = self.config.device_log.get(ConfigConst.tag_loglevel, "DEBUG") if self.config.device.device_os_type == DeviceOsType.ios: self.device_log = get_device_log_file(request.config.report_path, "{}_{}".format(request.config.device.__get_serial__(), @@ -150,11 +150,11 @@ class ARKUIXJSUnitTestDriver(IDriver): "Error: Test cases don't exist {}.".format(config_file), error_no="00102") json_config = JsonParser(config_file) + self.runner = ARKUIXJSUnitTestRunner(self.config) self.kits = get_kit_instances(json_config, self.config.resource_path, self.config.testcases_path) self._get_driver_config(json_config) - self.runner = ARKUIXJSUnitTestRunner(self.config) do_module_kit_setup(request, self.kits) self.runner.suites_name = request.get_module_name() if hasattr(self.config, "history_report_path") and self.config.testargs.get("test"): @@ -162,7 +162,7 @@ class ARKUIXJSUnitTestDriver(IDriver): else: if self.rerun: self.runner.retry_times = self.runner.MAX_RETRY_TIMES - # execute test case + # execute test case app_file = self.config.testargs.get("app_file") if app_file: self._do_test_run(listener=request.listeners, path=app_file) @@ -223,6 +223,7 @@ class ARKUIXJSUnitTestDriver(IDriver): def _handle_logs(self, request): serial = "crash_log_{}_{}".format(str(self.config.device.__get_serial__()), time.time_ns()) log_tar_file_name = "{}".format(str(serial).replace(":", "_")) + self.config.device.device_log_collector.start_get_crash_log(log_tar_file_name) if self.config.device.device_os_type == DeviceOsType.ios: self.config.device.device_log_collector.remove_log_address(self.device_log) else: -- Gitee From 96fabd0780349b590c9dd894c0ff1cfb76df10dd Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Mon, 9 Oct 2023 16:43:08 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dxdevice=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8bug2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/aosp/constants.py | 2 +- xdevice/plugins/aosp/environment/device.py | 4 + .../xdevice/_core/config/config_manager.py | 100 +++++++++--------- xdevice/src/xdevice/_core/testkit/kit.py | 3 +- 4 files changed, 54 insertions(+), 55 deletions(-) diff --git a/xdevice/plugins/aosp/constants.py b/xdevice/plugins/aosp/constants.py index 6d9cff3..90aba37 100644 --- a/xdevice/plugins/aosp/constants.py +++ b/xdevice/plugins/aosp/constants.py @@ -36,4 +36,4 @@ class UsbConst: @dataclass class CKit: - install = "ApKInstallKit" + install = "ApkInstallKit" diff --git a/xdevice/plugins/aosp/environment/device.py b/xdevice/plugins/aosp/environment/device.py index 4ca83a8..edf624c 100644 --- a/xdevice/plugins/aosp/environment/device.py +++ b/xdevice/plugins/aosp/environment/device.py @@ -393,6 +393,10 @@ class DeviceAosp(IDevice): def get_device_report_path(self): return self._device_report_path + @classmethod + def check_recover_result(cls, recover_result): + return "1" == recover_result + def take_picture(self, name): pass diff --git a/xdevice/src/xdevice/_core/config/config_manager.py b/xdevice/src/xdevice/_core/config/config_manager.py index 1f85510..3e36766 100644 --- a/xdevice/src/xdevice/_core/config/config_manager.py +++ b/xdevice/src/xdevice/_core/config/config_manager.py @@ -245,56 +245,52 @@ class UserConfigManager(object): return data_dic return self.get_device(target_name) + def get_testcases_dir(self): + from xdevice import Variables + testcases_dir = self.get_testcases_dir_config() + if testcases_dir: + if os.path.isabs(testcases_dir): + return testcases_dir + return os.path.abspath(os.path.join(Variables.exec_dir, + testcases_dir)) -def get_testcases_dir(self): - from xdevice import Variables - testcases_dir = self.get_testcases_dir_config() - if testcases_dir: - if os.path.isabs(testcases_dir): - return testcases_dir - return os.path.abspath(os.path.join(Variables.exec_dir, - testcases_dir)) - - return os.path.abspath(os.path.join(Variables.exec_dir, "testcases")) - - -def get_resource_path(self): - from xdevice import Variables - data_dic = self.get_user_config_list("resource") - if "dir" in data_dic.keys(): - resource_dir = data_dic.get("dir", "") - if resource_dir: - if os.path.isabs(resource_dir): - return resource_dir - return os.path.abspath( - os.path.join(Variables.exec_dir, resource_dir)) - - return os.path.abspath( - os.path.join(Variables.exec_dir, "resource")) - - -def get_log_level(self): - data_dic = {} - node = self.config_content.find("loglevel") - if node is not None: - if node.find("console") is None and node.find("file") is None: - # neither loglevel/console nor loglevel/file exists - data_dic.update({"console": str(node.text).strip()}) - else: - for child in node: - data_dic.update({child.tag: str(child.text).strip()}) - return data_dic - - -def get_device_log_status(self): - data_dic = {} - node = self.config_content.find("devicelog") - if node is not None: - if node.find(ConfigConst.tag_enable) is not None \ - or node.find(ConfigConst.tag_dir) is not None: - for child in node: - data_dic.update({child.tag: str(child.text).strip()}) - else: - data_dic.update({ConfigConst.tag_enable: str(node.text).strip()}) - data_dic.update({ConfigConst.tag_dir: None}) - return data_dic + return os.path.abspath(os.path.join(Variables.exec_dir, "testcases")) + + def get_resource_path(self): + from xdevice import Variables + data_dic = self.get_user_config_list("resource") + if "dir" in data_dic.keys(): + resource_dir = data_dic.get("dir", "") + if resource_dir: + if os.path.isabs(resource_dir): + return resource_dir + return os.path.abspath( + os.path.join(Variables.exec_dir, resource_dir)) + + return os.path.abspath( + os.path.join(Variables.exec_dir, "resource")) + + def get_log_level(self): + data_dic = {} + node = self.config_content.find("loglevel") + if node is not None: + if node.find("console") is None and node.find("file") is None: + # neither loglevel/console nor loglevel/file exists + data_dic.update({"console": str(node.text).strip()}) + else: + for child in node: + data_dic.update({child.tag: str(child.text).strip()}) + return data_dic + + def get_device_log_status(self): + data_dic = {} + node = self.config_content.find("devicelog") + if node is not None: + if node.find(ConfigConst.tag_enable) is not None \ + or node.find(ConfigConst.tag_dir) is not None: + for child in node: + data_dic.update({child.tag: str(child.text).strip()}) + else: + data_dic.update({ConfigConst.tag_enable: str(node.text).strip()}) + data_dic.update({ConfigConst.tag_dir: None}) + return data_dic diff --git a/xdevice/src/xdevice/_core/testkit/kit.py b/xdevice/src/xdevice/_core/testkit/kit.py index f3fccbe..b8ee897 100644 --- a/xdevice/src/xdevice/_core/testkit/kit.py +++ b/xdevice/src/xdevice/_core/testkit/kit.py @@ -244,8 +244,7 @@ def get_app_name_by_tool(app_path, paths): if app_path: proc_timer = None try: - tool_file = get_file_absolute_path(os.path.join( - "tools", aapt_tool_name), paths) + tool_file = get_file_absolute_path(aapt_tool_name, paths) LOG.debug("Aapt file is %s" % tool_file) if platform.system() == "Linux" or platform.system() == "Darwin": -- Gitee From 192960424ca61e913c98dfba2cbfbd19b8015a76 Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Mon, 9 Oct 2023 17:34:04 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dxdevice=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8bug3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/ohos/src/ohos/drivers/arkuix.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py index 09f1d25..8495575 100644 --- a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py +++ b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py @@ -137,7 +137,7 @@ class ARKUIXJSUnitTestDriver(IDriver): raise exception finally: try: - self._handle_logs(request) + self._handle_logs() finally: self.result = check_result_report( request.config.report_path, self.result, self.error_message) @@ -220,7 +220,7 @@ class ARKUIXJSUnitTestDriver(IDriver): self.runner.run(listener, path) self.runner.notify_finished() - def _handle_logs(self, request): + def _handle_logs(self): serial = "crash_log_{}_{}".format(str(self.config.device.__get_serial__()), time.time_ns()) log_tar_file_name = "{}".format(str(serial).replace(":", "_")) self.config.device.device_log_collector.start_get_crash_log(log_tar_file_name) @@ -311,7 +311,8 @@ class ARKUIXJSUnitTestRunner: @staticmethod def kill_proc(proc, timeout, stop_event): - end_time = time.time() + timeout + # test-timeout单位用的是毫秒 + end_time = time.time() + int(timeout / 1000) while time.time() < end_time and not stop_event.is_set(): time.sleep(1) if proc.poll() is None: -- Gitee From 8bd59cf96a6c7ab3a096859f6e7da6f7a213253b Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Tue, 10 Oct 2023 10:41:41 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dxdevice=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8bug4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/aosp/managers/manager_device.py | 2 +- xdevice/plugins/ios/managers/manager_device.py | 2 +- xdevice/plugins/ohos/src/ohos/drivers/arkuix.py | 4 ++-- xdevice/src/xdevice/_core/testkit/kit.py | 7 ++++++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/xdevice/plugins/aosp/managers/manager_device.py b/xdevice/plugins/aosp/managers/manager_device.py index c2e3684..f3bbd22 100644 --- a/xdevice/plugins/aosp/managers/manager_device.py +++ b/xdevice/plugins/aosp/managers/manager_device.py @@ -341,7 +341,7 @@ class ManagerAospDevice(IDeviceManager, IFilter): def __filter_xml_node__(self, node): if isinstance(node, DeviceNode): - if UsbConst.connector_type in node.get_connectors(): + if UsbConst.connector in node.get_connectors(): return True return False diff --git a/xdevice/plugins/ios/managers/manager_device.py b/xdevice/plugins/ios/managers/manager_device.py index 8c0c879..e3e7a17 100644 --- a/xdevice/plugins/ios/managers/manager_device.py +++ b/xdevice/plugins/ios/managers/manager_device.py @@ -342,7 +342,7 @@ class ManagerIosDevice(IDeviceManager, IFilter): def __filter_xml_node__(self, node): if isinstance(node, DeviceNode): - if UsbConst.connector_type in node.get_connectors(): + if UsbConst.connector in node.get_connectors(): return True return False diff --git a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py index 8495575..04e330e 100644 --- a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py +++ b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py @@ -352,9 +352,9 @@ class ARKUIXJSUnitTestRunner: def _get_run_command(self, device): if device.device_os_type == DeviceOsType.ios: - test_product = "app" + test_product = "ios" else: test_product = "apk" command = ["ace", "test", test_product, "--b", self.config.bundle_name, "--m", self.config.module_name, - "--unittest", "OpenHarmonyTestRunner"] + "--unittest", "OpenHarmonyTestRunner", "--skipInstall"] return command diff --git a/xdevice/src/xdevice/_core/testkit/kit.py b/xdevice/src/xdevice/_core/testkit/kit.py index b8ee897..4ed321d 100644 --- a/xdevice/src/xdevice/_core/testkit/kit.py +++ b/xdevice/src/xdevice/_core/testkit/kit.py @@ -240,7 +240,12 @@ def get_app_name_by_tool(app_path, paths): The Pkg Name if found else None """ rex = "^package:\\s+name='(.*?)'.*$" - aapt_tool_name = "aapt.exe" if os.name == "nt" else "aapt" + if platform.system() == "Windows": + aapt_tool_name = "aapt.exe" + elif platform.system() == "Linux": + aapt_tool_name = "aapt" + else: + aapt_tool_name = "aapt_mac" if app_path: proc_timer = None try: -- Gitee From 47a95380cacb1f400e6d636d29980c396fc73733 Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Tue, 24 Oct 2023 14:51:26 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dxdevice=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8bug5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/aosp/environment/dmlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xdevice/plugins/aosp/environment/dmlib.py b/xdevice/plugins/aosp/environment/dmlib.py index 50fd0d5..6c93b77 100644 --- a/xdevice/plugins/aosp/environment/dmlib.py +++ b/xdevice/plugins/aosp/environment/dmlib.py @@ -187,7 +187,7 @@ class AdbMonitor: try: monitor.is_stop = True if monitor.main_adb_connection is not None: - monitor.main_adb_connection.shutdowm(2) + monitor.main_adb_connection.shutdown(2) monitor.main_adb_connection.close() monitor.main_adb_connection = None except (socket.error, socket.gaierror, socket.timeout) as _: -- Gitee From c623d56b062c0063fd98092228984a09c5aa3bc3 Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Tue, 24 Oct 2023 16:42:23 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dxdevice=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8bug6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/aosp/__init__.py | 3 --- xdevice/plugins/aosp/testkit/kit.py | 25 ++++++++++++++++++++---- xdevice/src/xdevice/_core/testkit/kit.py | 4 +--- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/xdevice/plugins/aosp/__init__.py b/xdevice/plugins/aosp/__init__.py index 6088560..8b13789 100644 --- a/xdevice/plugins/aosp/__init__.py +++ b/xdevice/plugins/aosp/__init__.py @@ -1,4 +1 @@ -import os -ROOT_PATH = os.path.abspath(os.path.dirname(__file__)) -RES_PATH = os.path.join(ROOT_PATH, "res") diff --git a/xdevice/plugins/aosp/testkit/kit.py b/xdevice/plugins/aosp/testkit/kit.py index c019d32..44b36eb 100644 --- a/xdevice/plugins/aosp/testkit/kit.py +++ b/xdevice/plugins/aosp/testkit/kit.py @@ -15,7 +15,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # - +import os from xdevice import AppInstallError from xdevice import ITestKit from xdevice import Plugin @@ -27,9 +27,9 @@ from xdevice import get_install_args from xdevice import get_app_name_by_tool from aosp.constants import CKit -from aosp import RES_PATH LOG = platform_logger("Kit") +AAPT_PATH = None __all__ = ["ApkInstallKit"] @@ -86,7 +86,7 @@ class ApkInstallKit(ITestKit): # arkuix跨平台测试需要一个入口文件来启动,通过查找app包名获取文件路径 if request.root.source.test_type == DeviceTestType.arkuix_jsunit_test: for app in self.installed_app: - app_name = get_app_name_by_tool(app, [RES_PATH]) + app_name = get_app_name_by_tool(app, [get_aapt_path()]) if app_name == request.config.bundle_name: request.config.testargs.update({"app_file": app}) break @@ -103,7 +103,7 @@ class ApkInstallKit(ITestKit): LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) else: for app in self.installed_app: - app_name = get_app_name_by_tool(app, [RES_PATH]) + app_name = get_app_name_by_tool(app, [get_aapt_path()]) if app_name: result = device.uninstall_package(app_name) if result and (result.startswith("Success") or "successfully" in result): @@ -112,3 +112,20 @@ class ApkInstallKit(ITestKit): LOG.warning("Error uninstalling package {} {}".format(device.__get_serial__(), result)) else: LOG.warning("Can't find app name for {}".format(app)) + + +def get_aapt_path(): + global AAPT_PATH + if AAPT_PATH: + return AAPT_PATH + sdk = os.environ.get("ANDROID_HOME") + if not sdk: + raise EnvironmentError("Can't not find android sdk, please check!") + build_path = os.path.join(sdk, "build-tools") + aapt_name = "aapt.exe" if os.name == "nt" else "aapt" + for tool_dir in os.listdir(build_path): + aapt_path = os.path.join(build_path, tool_dir, aapt_name) + if os.path.exists(aapt_path): + AAPT_PATH = os.path.join(build_path, tool_dir) + return AAPT_PATH + return None diff --git a/xdevice/src/xdevice/_core/testkit/kit.py b/xdevice/src/xdevice/_core/testkit/kit.py index 4ed321d..99d3362 100644 --- a/xdevice/src/xdevice/_core/testkit/kit.py +++ b/xdevice/src/xdevice/_core/testkit/kit.py @@ -242,10 +242,8 @@ def get_app_name_by_tool(app_path, paths): rex = "^package:\\s+name='(.*?)'.*$" if platform.system() == "Windows": aapt_tool_name = "aapt.exe" - elif platform.system() == "Linux": - aapt_tool_name = "aapt" else: - aapt_tool_name = "aapt_mac" + aapt_tool_name = "aapt" if app_path: proc_timer = None try: -- Gitee From 5be9d79146b8ef75f2d96b39e5b1489a0b639d17 Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Tue, 24 Oct 2023 19:43:19 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dxdevice=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8bug7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/ohos/src/ohos/drivers/arkuix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py index 04e330e..bd33d64 100644 --- a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py +++ b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py @@ -284,7 +284,7 @@ class ARKUIXJSUnitTestRunner: timeout_thread.start() while True: output = proc.stdout.readline() - if b'Test failed' in output: + if b'Test failed' in output or b'test failed' in output: raise ExecuteTerminate(error_msg="Test failed!") if output == b'' and proc.poll() is not None: break -- Gitee From e08ea85be8302bbe0a3b8dd92cb6fad271a0cf4c Mon Sep 17 00:00:00 2001 From: deveco_xdevice Date: Thu, 9 Nov 2023 16:54:30 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E6=B7=BB=E5=8A=A0acetools=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=89=93=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: deveco_xdevice --- xdevice/plugins/ohos/src/ohos/drivers/arkuix.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py index bd33d64..248a3d0 100644 --- a/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py +++ b/xdevice/plugins/ohos/src/ohos/drivers/arkuix.py @@ -169,6 +169,12 @@ class ARKUIXJSUnitTestDriver(IDriver): else: LOG.error("Not find ace test app file!") raise ExecuteTerminate(error_msg="Not find ace test app file!") + except Exception as exception: + self.error_message = exception + if not getattr(exception, "error_no", ""): + setattr(exception, "error_no", "03409") + LOG.exception(self.error_message, exc_info=True, error_no="03409") + raise exception finally: do_module_kit_teardown(request) if self.runner.coverage_data: @@ -284,6 +290,8 @@ class ARKUIXJSUnitTestRunner: timeout_thread.start() while True: output = proc.stdout.readline() + if output and b'OHOS_REPORT' not in output: + LOG.info(output) if b'Test failed' in output or b'test failed' in output: raise ExecuteTerminate(error_msg="Test failed!") if output == b'' and proc.poll() is not None: -- Gitee