From fc84a51be7da7c3bf63c6f80fbef172a7fffb9d8 Mon Sep 17 00:00:00 2001 From: tonglei Date: Thu, 22 Jul 2021 19:32:18 +0800 Subject: [PATCH] up to sweet --- .github/FUNDING.yml | 12 - FAQ.md | 102 ---- FUNDING.yml | 12 - LICENSE | 373 ------------- README.md | 61 +-- example/report.zip | Bin 3818 -> 0 bytes example/sweetest_example.zip | Bin 84428 -> 0 bytes {sweetest/sweetest => sweet}/lib/__init__.py | 0 {sweetest/sweetest => sweet}/lib/c.py | 0 .../sweetest => sweet}/lib/http_handle.py | 0 {sweetest/sweetest => sweet}/lib/u.py | 0 sweet/modules/__init__.py | 8 + sweet/modules/db.py | 214 ++++++++ sweet/modules/file.py | 190 +++++++ sweet/modules/http.py | 261 ++++++++++ sweet/modules/mobile/__init__.py | 2 + sweet/modules/mobile/app.py | 482 +++++++++++++++++ sweet/modules/mobile/config.py | 58 +++ sweet/modules/mobile/locator.py | 70 +++ sweet/modules/mobile/window.py | 17 + sweet/modules/web/__init__.py | 2 + sweet/modules/web/app.py | 421 +++++++++++++++ sweet/modules/web/config.py | 41 ++ sweet/modules/web/locator.py | 70 +++ sweet/modules/web/window.py | 98 ++++ sweetest/Junit/Baidu-Report.xml | 9 - sweetest/data/Baidu-baidu.csv | 3 - sweetest/element/Baidu-Elements.xlsx | Bin 13212 -> 0 bytes sweetest/log/20180423.log | 0 .../report/Baidu-Report@20180307_144216.xlsx | Bin 6813 -> 0 bytes sweetest/start.py | 47 -- sweetest/sweetest/__init__.py | 27 - sweetest/sweetest/autotest.py | 146 ------ sweetest/sweetest/config.py | 210 -------- sweetest/sweetest/data.py | 115 ---- sweetest/sweetest/database.py | 105 ---- sweetest/sweetest/elements.py | 81 --- sweetest/sweetest/globals.py | 156 ------ sweetest/sweetest/junit.py | 184 ------- sweetest/sweetest/keywords/__init__.py | 0 sweetest/sweetest/keywords/common.py | 200 ------- sweetest/sweetest/keywords/files.py | 211 -------- sweetest/sweetest/keywords/http.py | 255 --------- sweetest/sweetest/keywords/mobile.py | 486 ----------------- sweetest/sweetest/keywords/web.py | 382 -------------- sweetest/sweetest/keywords/windows.py | 222 -------- sweetest/sweetest/locator.py | 75 --- sweetest/sweetest/log.py | 49 -- sweetest/sweetest/parse.py | 68 --- sweetest/sweetest/report.py | 235 --------- sweetest/sweetest/snapshot.py | 295 ----------- sweetest/sweetest/testcase.py | 283 ---------- sweetest/sweetest/testsuite.py | 232 --------- sweetest/sweetest/utility.py | 492 ------------------ sweetest/sweetest/windows.py | 148 ------ sweetest/testcase/Baidu-TestCase.xlsx | Bin 12536 -> 0 bytes sweetest/testcase/Echo-TestCase.xlsx | Bin 12021 -> 0 bytes sweetest/testcase/Notepad-TestCase.xlsx | Bin 11446 -> 0 bytes 58 files changed, 1937 insertions(+), 5273 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 FAQ.md delete mode 100644 FUNDING.yml delete mode 100644 LICENSE delete mode 100644 example/report.zip delete mode 100644 example/sweetest_example.zip rename {sweetest/sweetest => sweet}/lib/__init__.py (100%) rename {sweetest/sweetest => sweet}/lib/c.py (100%) rename {sweetest/sweetest => sweet}/lib/http_handle.py (100%) rename {sweetest/sweetest => sweet}/lib/u.py (100%) create mode 100644 sweet/modules/__init__.py create mode 100644 sweet/modules/db.py create mode 100644 sweet/modules/file.py create mode 100644 sweet/modules/http.py create mode 100644 sweet/modules/mobile/__init__.py create mode 100644 sweet/modules/mobile/app.py create mode 100644 sweet/modules/mobile/config.py create mode 100644 sweet/modules/mobile/locator.py create mode 100644 sweet/modules/mobile/window.py create mode 100644 sweet/modules/web/__init__.py create mode 100644 sweet/modules/web/app.py create mode 100644 sweet/modules/web/config.py create mode 100644 sweet/modules/web/locator.py create mode 100644 sweet/modules/web/window.py delete mode 100644 sweetest/Junit/Baidu-Report.xml delete mode 100644 sweetest/data/Baidu-baidu.csv delete mode 100644 sweetest/element/Baidu-Elements.xlsx delete mode 100644 sweetest/log/20180423.log delete mode 100644 sweetest/report/Baidu-Report@20180307_144216.xlsx delete mode 100644 sweetest/start.py delete mode 100644 sweetest/sweetest/__init__.py delete mode 100644 sweetest/sweetest/autotest.py delete mode 100644 sweetest/sweetest/config.py delete mode 100644 sweetest/sweetest/data.py delete mode 100644 sweetest/sweetest/database.py delete mode 100644 sweetest/sweetest/elements.py delete mode 100644 sweetest/sweetest/globals.py delete mode 100644 sweetest/sweetest/junit.py delete mode 100644 sweetest/sweetest/keywords/__init__.py delete mode 100644 sweetest/sweetest/keywords/common.py delete mode 100644 sweetest/sweetest/keywords/files.py delete mode 100644 sweetest/sweetest/keywords/http.py delete mode 100644 sweetest/sweetest/keywords/mobile.py delete mode 100644 sweetest/sweetest/keywords/web.py delete mode 100644 sweetest/sweetest/keywords/windows.py delete mode 100644 sweetest/sweetest/locator.py delete mode 100644 sweetest/sweetest/log.py delete mode 100644 sweetest/sweetest/parse.py delete mode 100644 sweetest/sweetest/report.py delete mode 100644 sweetest/sweetest/snapshot.py delete mode 100644 sweetest/sweetest/testcase.py delete mode 100644 sweetest/sweetest/testsuite.py delete mode 100644 sweetest/sweetest/utility.py delete mode 100644 sweetest/sweetest/windows.py delete mode 100644 sweetest/testcase/Baidu-TestCase.xlsx delete mode 100644 sweetest/testcase/Echo-TestCase.xlsx delete mode 100644 sweetest/testcase/Notepad-TestCase.xlsx diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index c2672dd..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,12 +0,0 @@ -# These are supported funding model platforms - -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -custom: ['https://sweeter.io/#/sponsor'] diff --git a/FAQ.md b/FAQ.md deleted file mode 100644 index cf631cf..0000000 --- a/FAQ.md +++ /dev/null @@ -1,102 +0,0 @@ -# Sweetest 常见问题汇总(持续更新...) - - -## 安装配置 - - -### 1. 是否支持 Python2.7? - -答:不支持。 - -Sweetest 仅支持 Python3.6 或以上,原因如下: - -1) 框架中使用了有序字典等特性; -2) 人生苦短,我用新版 :) - - -### 2. 安装后,无法正常启动浏览器? - -答:请检查是否正确配置了 chromedriver: - -要求和 Chrome 版本匹配;并且把路径添加到环境变量的 path 中。 - - -### 3. 直接从 github 上下载源码能跑起来吗? - -答:不能。 - -因为没有安装依赖库;建议使用 pip 安装,pip 会自动把依赖库也一起安装。 - - -### 4. 是否支持 Mac、Linux? - -答:支持。 - -但是无法使用 sweetest 来创建示例文件夹,需要自行下载示例并解压; - -下载地址: - -另外,Mac 或 Linux 上的 chromedriver 在功能和稳定性上**可能**存在问题,建议还是在 Windows 上运行比较可靠。 - - -## 支持范围 - - -### 1. 除了 Chrome,是否支持 IE, Firefox, Safari 等浏览器? - -答:支持。 - -Sweetest 底层是 Selenium 接口,按如下操作即可: - -1) 配置好对应的浏览器驱动; -2) 在启动脚本里配置对应浏览器,如下: - -``` -desired_caps = {'platformName': 'Desktop', 'browserName': 'Ie'} -``` - - -### 2. 支持 Android App 测试吗? - -答:支持。 - -Sweetest 在移动端测试上底层使用的是 Appium,需要配置好 Appium 环境; - -目前,已经在 OPPO R9s 上测试通过。 - - -### 3. 支持 iOS App 测试吗? - -答:不支持。 - -虽然底层的 Appium 支持 iOS,但是经过我们在 iPhone 6p 上测试,响应速度非常慢,经常卡死; - -后续,我们会考虑使用其他框架作为底层来支持 iOS 测试,如 ATX。 - - -### 4. 支持小程序测试吗? - -答:支持 Android 上的小程序测试。 - -我们的在示例中有测试音乐台小程序的用例。 - - -### 5. 支持 http 接口测试吗? - -答:支持。 - -详情见示例中的用例。 - - -### 6. 支持数据库操作吗? - -答:支持。 - -详情见示例中的用例。 - - -## 使用及功能 - -### 1. 我写了一个 setup 用例,为什么没有执行? - -答:setup 是在普通用例之前执行的,如果没有普通用例就不会执行。 diff --git a/FUNDING.yml b/FUNDING.yml deleted file mode 100644 index d14e214..0000000 --- a/FUNDING.yml +++ /dev/null @@ -1,12 +0,0 @@ -# These are supported funding model platforms - -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -custom: ['https://sweeter.io/docs/_media/sponsor.jpeg'] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a612ad9..0000000 --- a/LICENSE +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index 843cf19..defc957 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,6 @@ ![sweetest](https://sweeter.io/docs/_media/sweeter.png) -# sweetest -## 介绍 - -Sweetest 是一款小而美的自动化测试解决方案,同时支持 Web UI,Http 接口,DB 操作测试,Android/iOS App 测试,小程序测试,Windows GUI 测试,文件操作;由于开始只支持 Web UI 测试,名字取自 Selenium,Web UI,Excel,Element, Test 含义。 -特点: - -1. 简单快速,轻松上手 -2. 无需编码能力 -3. 在 Excel 中以文本编写测试用例 -4. 维护成本低 -5. 支持千、万级别的用例规模 -6. 拥抱变化,支持敏捷 - -## 安装 - -### 环境要求 - -- 系统要求:Windows -- Python 版本:**3.6+** -- 浏览器:Chrome -- Chrome 驱动: [chromedriver](https://npm.taobao.org/mirrors/chromedriver) (需和 Chrome 版本匹配,并配置环境变量,[参考这里配置](https://segmentfault.com/a/1190000013940356)) - -### 安装 sweetest - -```bash -pip install sweetest -``` - -### 升级 sweetest - -```bash -pip install -U sweetest -``` - -### 快速体验 - -打开 cmd 命令窗口,切换到某个目录,如:D:\\Autotest - -```bash -sweetest -cd sweetest_example -python start.py -``` - -![install](https://sweeter.io/docs/_snapshot/install.png) - -OK,如果一切顺利的话,sweetest 已经跑起来了 - -> 详细文档:https://sweeter.io/#/sweetest/ - -## 加入我们 - -QQ 交流群:**941761748** -> (验证码:python) 注意首字母小写 - -微信公众号:**喜文测试** - -![QQ2](https://sweeter.io/docs/_media/QQ.png)![WeChat](https://sweeter.io/docs/_media/WeChat.png) +Sweetest 已全面升级为 Sweet,请访问: +官网:https://sweeter.io +GitHub:https://github.com/sweeterio \ No newline at end of file diff --git a/example/report.zip b/example/report.zip deleted file mode 100644 index 65d7823b768907995cb6303ead89e528ce7ccab7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3818 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 diff --git a/example/sweetest_example.zip b/example/sweetest_example.zip deleted file mode 100644 index 05b86ed83abdb8adfe4fa3743c49a994c62ae49c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 84428 zcmdqHQ?M{F6D4?U+qP}nwr$(CZR5MPZQHhOYyR1pshQoHoqgXtbW*8w`c!r2RFXOh z(!d}n0ROcGXlBX%UxWW{(EigqyPKN2m^!=Yn|c`9I@p-fn;5zn(kuLT9|(Zvut%Bk zasq0;|E$dd1OOoYfAtYEv@~(0HTtiX&e+*4dQPr}W{zHpay62Ef@VVU@joHl%u-1@X-mWlo~Y|^ZSgKv5Ia_ZlxO>NECaea8>{PD%{ z;p_3`?f$!a8>>58`;(uR$Hh2-gK-8A>i{0w4m7;&XK**j;AWV{#W?*R(>{Oj_aEO3 z5uEvR@WB~R|GyaC_5A_=&s@zYWy`)8p**Vs0RSWc0{~$B?{Z~oY++C5;AyL@D8H$I z;zxeKzik4K4r_QP?W5rm>AkVEn-+@DF-s;Osoj=nD!D*NV2UNmE}DRa+6WQ_X=jl6 zjp1#-MgBK9TdF{)nZ?p}mUqX!=RSMUNkW$R9f=S+4uzCtsi-tRB`bQoH_%T)V18^6 zz(1Ez1%GW7ew%({_U2f=035rMhI2Gt+g$y%7@Xz$qTS{u=)C1tcW_SYmeV%9r`Nk; zzg4zphIvkLcKL5bJ=Z6`sp*xpCuhoF1#jkV-3vTH;4G%|x%Lo4{krUdgXhJH8#fmX zuKOF@l^R2W+^E8WDviVf@<81ho9K0T=@BuBy`Sb$r7=w|+XSx6JT^cdSp!*)O+>Tk(D(J80+LbN{b_t9k;AiQl8B@N zll%569W4`HLJAQmS^Knms=QU4^haDzZe~Z612!S(-oGnaHU8iorz-C1dU7K^_n`z4y6TT~2Tz z{3-IhMB?M7f)E};YonY1xM%qPQHuZJs{aSqVg7GR(Z%PaH@K@SeE*hiAoz-?UAlfB0Rkz9 zU|?rw2R#qutpFPGUgDLL-TUk`&fUMyy_dC9{Oz)G!+G}8Ox5+p2Gx_o!sb80@c;l4 zfV+{wCVUA10tEhZoS*^VE9h^}_pJ{0tx66qOaNI-9_rhOr$WO@roaS%euMw&9w;Rj z3bSE(QF5+P+1jW3Z!0S!ktr_z;xM0p1fBw+*avr!(HagypoD^P7#BGbYyHOFLh@PC z$a*-P@O+c#c^5-~ae-N^Y|%;&m><5YNWnOLD^Agrdnu7{XGV3WQsw|bOg(+AOa=|Y zWkTqLblvOBOBIZwp^nxdQ8S}1fxr|IRWwZD_PgX*L(kdY8>~$>OA|W`P*~haw|~cX zxPC%yT5?vQLI>~94>~z7IOf%s5-g%~&0?(+B>J1-N}>o`Aoymmfn3blu9>Oi7UsXM z(O^MtK7Xa?7R`Oy=7jZZ`K_8+n)ni2G(*vuWc+9j>@^oSupxFX?HV0CGyR$LtMXa6 zVXU|hzHdkQd`5P(Q00w>&zC^mr2twelol8`Z2$`D&96y5JxB(y=;MjG5Nof`VL~43 zvfDqt>0sT+dqc&a7Z8>SiscStu}Pq!E6*GCC6Ria-0&%4!3R$8AiJ`XG||lWr*`tj z;S-%ei0|)F(&FP408;%yXjiA)pfkz}bp4|wOUepZof_(a8ymmZ|Hw7|g7M!|7z&q% z!nXa1mZ4{rC9}H0hsdof6MiCPr5&_ zZjMmVLHyYrV`N5(4OOp`41LIyBlGr)wQkuVwX0W!eOX>=ClN7iJ+8!q>N`!GFIwL*@ zKj}N1+(x&deGr z2(0BSt|PrA87_RMyNDhZCt6YnG(&4ae}w3KGj6{-snG0E?kl%5F3m(9CmAi0zo|%+ zMR$mwiA#m$eIPexeK5hr4BHZ4s`cN6e|d=}$^9_of)H!%bZU_S5H^Kg1^Ln(Xtgn>Bf_mpzdYx^}R zeHDt4q!MFc%FU1k+RFV?0$gfpXG#r~S=~*TlTj6+T&R=qJx9N}F#plQq6Spekkz2y zFdD1bkAUOw#K%W_kB~=Z2HqI0QZ#Pxh?snnJisuRPZoQ1(V@slswg(0=uOD1-(zm( zMW@o(&AZ7X4-L!c9TvUbXYEOt<;-B|k_2XF>$`L&6~}rVetLY*OCL}}Nn-A9N;!bD z3o|*V)TY)l@x7!AuVR3%-ywo9$vrNok}D+r@>summ7!N6FYN9-z;u}+OAlI%Vdq9eSRWm`rLqTAPZ zUTKURmhuc&w|bl~V2Z(%uo!wxe6xU+A%Q{WXfhEj7VbDOx==Sy)YnfGtY_-NE0t#l5xE+p=)= z^kI=**&2)|y|)ciuN*Uph8cTjb3amH_NYUL3VK$j#@hb8NBOPY0zw~$!+%)=(u%dE zyupQLW!8&jI=mt0D+QxvJ!qA2@%k*obE(yr9qHbVz=foMZ1dxnz@C;o0N0)6q?lgB z4sBn)dA%%hYRIn$1Q8A(;0K*HK#hzJ?ewyiZWS)AKYx3kQAuxg@UPLzx1%7i@9l&@lt+F5dFUDiVaY_nnorTRU7-3PZqEqN4{r!;i_$Iqfhr8I zKECki`D{y`k*&sJa~!uO`gL?gnCa3$vYEkb8@+Tn6;>udp)RF z=-)ZFbVV=HT^y5U)T}?!FiLAL?`|Ygl=tY0zK1Uv2(?ZnHPbh5u?vJE)%>PMqqX0W zb|_(Ed!HnB21z6I^fQ_PZTK+klFuP0mV7S$kxCl{gwSgGSX)SG)T@`ytEiZ2vqEFClJccc=AoX7 zFgY>0z!i~Aw;~$uJl#@d1IE$kujMwM(|8rRa1y^rr0}h1TP@jSOq2aE=C+?zxsk8L zJp=fi+XE=9coA5kaz0QRbtBXTNmQ*)z0jMm>{b?R6Ri82Qr-~UyYo%q#WA87fNx#qxo(l@kE9@9x!*Zw_C9ErP)$0sO9QZ$fgm-Hbx{++K@@ z+#D@x4HKol+?6XP)l!z}!UP1th_sSOxK=}-t7bmJSQe{uBI|~JOtQ^d=H?a0 zhRr*XVZhq>)ljq;wyw*DMhmMIJl!a?meL|X5A4V2XvL&W2Sc} z)E)G5zLo_un}z*f-E-x%Yy#pR+i)v&b|Bak^iAo=sWVTn6tLC-N~%ze<3;aPE3l0H zP_)%xBR}|@PVr@U%z9#xH`i%<*b}5S+rHkQTqgB1Z9}WjA7y$Xi#fu*7Z;LOmVSj_ zES&%~G_=}&{~1}oy!yYjX%doF>c(tXZ6uf%g4=^!4*V#Z+F9v^@(y5AuP%_T93=43 z-nUx9*7NO6r>ylu$v$K%GOtjqIhHLL%udTNbJdR-@5qiLQPjO(C^n!bg`5c(X>Y8Q zq$HhTfgCtECDn4O6<)u1{W~L77$L`;@z)}o5b~{gS(Jt2G9>ITX5w}&wbD-Hf3o^F z27rdFJK;kZd*(uLp14O$=E#JN-;Se19ZcWBN#f>!|LEbi>-g3`i-gcO3>%G9@p30vPKnhIhkXn*Nz3E zy&aQSn4#L2&gdG;mlrGBy9Gs8s4stns$aAP0&KWgPQ}#XGEo~}h)Wh4y`c~?oJ&wK z2r4o)p1uzb2JU)qkF5gx#@Ev=2Ry+jHSPFLbosR6XTxzrAdtiLCJ=B6C5){*?Gcg}3M|uu zF3oI^h3q^1V%pgdg;uJsbI;(>%}W*UV7uGcQXPsDW*iOACh)E{c z-u0?VYx$dLigANtxnKKL_a!2~ib(-L{0e6&@c$~SMW=Rypd+v0-Un(#Ym8R=$?pw0 zj9Ij*RF)X~w8+s{xBdOTvTYx?7u{SL=rvVFhS9eg8QJeT=!)8VqaNsKJwi&iJ>rUv zJ1=WH*A6ehaxTX_b@2|ExBmhvHb1A=#tQjd4 zG8%@$P%(FS9@ztH6@Uh263HGJNi>TkA#d4OXpSLXdG2qFghkWtqswGVP<(*B_ z*wwzO*wRKTfTVYaP%?HpCJ>sskcamRCMgW$fqhX-h_;i?#c9VD3egE+Hm8GqeNi8_+ zNIa&-^`1>eVYFRt5O_o6szeJqrF$E5eb5fP1;oxDxot$m9>JE-p$PFsF-k;2f<{89 zsZ}58iH{jpEUSP~T&^5y#+;8Kamx*_P0`_PzFb(dxcx&DX6^mps@{2%ctmf<_tGw? zlKm;_ZgyT}t&z*aKH0a;rKPjESnM;TARg3dmBmM22KF3Y%!G8-wn=knVGSwJ2o%z9 zjx~pv`YfD^)ZEd^JRtBLOA2d|LcJY=YER&h@zTPE= z-kQ^Ye6uPPn=a(j{tP0{5gfc4Bvf6@G@PLv$ylZQ>YWxKBN`uQ_)?ZE2k;h#9K~og z$?)@Jl}GiLx<_!B&8P_+O$Gl=>wb(OJPO=OyBpTHoLPM6e)j%p^Eg^c!s$7LY-0i+ zB=;npwQ7&)m!LM}Zr#a(b)yv{*RHY}t0kr)4ZBn_J3^U5{#qrOurGcr5?2FSiLJnt zQF4evtrR=i4F+yx7SXK)@l%4p3;U{Lum(8EpKVTyublCGD{q3DUNlv<+}x=-MiOHC5N}DBV#PvznbCRN&J`?0KLnq z=$rCpJecM_qG2FD<+*~;sIz2^fWG$SO)&bA>gap1ctSnr#XvWUq6RB}!3~YUCdQp% zQCsYqir^aF>9xdor4}5CK(XqmeQY5AP$DNeeIb4{)@?Vnn^4J`%8k#0Jxai!hvf$m zHB9=~2%br}v0x5BSW}={Z)4Ibr#rnw9oNGrU4=hSE;A`JJi&kfqxXOSDgL~e?OvOj-##Q6E2?ffrvtYFz#YjQYzokw8=kA; zien%`n@!G$^_a>1mnWMiM(9(WS)IYxpz-Z+*b7uIJDD3gO?m?~oNAI)4;nPRlBasp z0dVO%{I=SC-0vBW3FMqy6SPwk4m*~-)G*7E%Xu zr_EjmQQeH_%~KUN+L$TNKH3j_bRnf&Jn2UAzFo@o8t`Esc{>5WI+S;G6Jf30R2x5B8Quqn>chAYc|h8Y4h`GLd_G z2VQzQL5x0g&w4wJVz&f(oH5+NMugbAb>Zfrc`UcSKgrzUl9UutPwWyGShEO%p}Eyk zX!C#sp=OWSOo&nW7kdW(4sOedk_xkTgz(x+0NYB&>#F8WMKPikCHLxTV!WtIs1`!M z69Kr6L%&>{%|^5RWLY^jkV1<&iB4S|q%#-C1#cGDr>S=`1G*005 zTG~|DTBd7-o*6Fv_}IuhK52M4dh88jSYVLqwZ!Un*ug?|hm~zJ0~>vPu29AYKc=(G z1h;D)MDZ{5A4_m=jvQSyQ7b59L{11avp)(DWQ4Ulp$l|ouXmu9m+qbI6R(PJ2&dVLITRuJ97dz#6c2BBk$N(Ck$>kMQAInY zDgmYl3{EJ;0Ry>jbGYdm;jrDRS*2V_p_b!gdp2_p%oHI1<$ z8iCtv>61tUf$GyEf|8TRj?iH>tA3BVB!N$zJ+YnJjgg$h5K^26OXvF~N5(})FDK+D zdfO|7S0-u$6c*dc0qY#|eB}bRAcThncG{jUFT*Wep4jOrF}bkDZeR%SF=DVs;yN zrquKJ9tU<6r{9OFlGe!;O(}5K&^m#&DV>1aoOR>F9I6iJ;aJ?|J+id*%zH9-n*X*h zeS3ZVA92XG-! zciS#`$xofT^R1SGB281?W|gDNh~s)Y3)Fens{)Hl>cXo7-Dy>lhDewbMAY zJqwu9AC>za?_ZY0Xr%u#c7yf@y76p#4?Y@A^Yo^y`SAuV_M&>G#RXQzZteyvpniLX z!;sxML0Vh%Kn7NKE!|yU*+BL3yU z0>B6X0RH-Taeut%Cg<5z{vD6Kur~Uk$O9!?mrno@JQBrKKc^>^rrpU!ju5C=M&CeH zFWS+u8@<%sZft^SO1?9}fw-l$P7_U~$9_lFJ<6f%tX+a><>+%iB|E{d@3BHfRg3fQ zs_Qgb*~_rjAoCyfbOF_f)RpDy({H=-*yyI>wwPm;qVj1EM{HPfrs~f*6-e^*QSG;3 zk8x9->Q5q7YO`i)y4h@Cc4nT)rL#GKQlkGW5rNHIh8TVBf2!4b)i(8Y&`z_`&_e*n zdriPxt=6~T-PLG^E;vbCHb=x!zinyrz1OaWmnQkJTCDf;JY@Im)ZTD&J~durT5WRs zD&R`@8&>=<^oP7yUnU{j(ibDiqb#vAwf@su>-zcHSwINHuY|6@Ke z7T?m?i@!W^n47S_9SpoIv z((s<1OH7SDriA$Jw`H$d8vC($y@GWx*ni4vorj-MZ`kVEUVHqWg@bY`A0VVh;{biL z|6T*SOABivxvhim6icHTT-E+JJ!{RYJ`nGA1^*O|AH&2)|60=?*lyn@59{eZ$s}t7 z?>u*ne$@jybho1`C=1G#rgoOn^3Om4yxiz-`Es_Z_a2av01^5kIV$nt>1B@lOfhE4 zT$96iBiOGziCB@X?F^-qS4`N?Ncmr0*)PrMBKo#^|7bfKj=!=rLy?taA8VA*6vWGIT;0t6tQD1ZZY_t|x)sI^oYZ#!+6_TzG)g)G@m0G?7tunFMYN!EEyupu7_28g-J|l zOYlm%+aB4+!IR!NO2OSo73UfI6dtPUAa-)WvI0Rw{RQQy`L$g=LDJ=*@!eKR#`=A2 ziOJIDe()a($1{sILB;emt)VZbAbo8s6fy$UJ;?F+EW3;jMl0B|`be1~V05P%j8s2e z@YYy{#k5a2Y=>OU+}hR=cMbljcrOIR#D*#9Q}g(PTeH#qZf2ZaAO**_13;vwa%dPI z&VL2AVo>h320*`8le?%s(LyqZT_w4?hxKXYF}i4t7czo|I$cSRZLtyoYm{~``I=z8 z+myr4)lyU)hlO{z?D1wyN_fIiwJ{gRX$avp|Dd0{-5uk{v_catz`Q0FWM~ZD!OKCm zX3Fp7$z?G{9BlWE7Q6+QGG)z+2(2^^VvgP;-F()dEKIJUCAfRa(*C4M5QJN1@bU?= z@f|aAX(G~7qnr z$TC*XQ|n!@l}YBEggO-d^AA_%_y8wt`D!@?Z}%K>+>CQb8eLxZ&or$3A1RLMj^xEU z@hD>qC!Yg3%mX=E+Bm}Lo!}Y7G&1>QL>=G7(k2NVmf+1LU;;lZ#B_W)aRTfqQ%Xs; zF_`FOgh74Z=KQ~LSn;(h6=1b#-tPZA8$DF zWfp`l^D9IbNuY*RltA1kw-kITKhKA-VL40hf*j1&yoQoy-YK^0Ylb1YLP$9&?yT6Q zfe1_&2IMaI)ZmWrv$_t-hy%w|2^J&oOGzenszw7M=IOaNXT1b6&gJYK(LN_wyk~Ns zPqEzG0)kgTK@XxkrPr;T#G1s<1GJk#)XuunRlRcrrhg1FGOv61wV2?b*et_H^-}|o z`BrFS&>wAYG(f`)%k#UD=1e{FdA&tKfg3*$Ki=FdgKt8C2_8SVYbu|C&E3+FxwU)U z!Vf&Jbcz>Vn>c>$6o77|P#UWzN?I}~wofnxZRCQeml@&Av(O9_FUb59h@*-d%}a7% zWcc_!>A#q&TvxshcX77`3^rhrkOJ9Q8h8)H6b6(o1}V-mEkzS1Tx=gpr3;}nz>jbC zJ@Dms=e}T`%4*;TQEGGJREL#^u=17Rh(U>SWPm5&z4!_}!aReT%8slhl@S zTS-UAT*zQv+R(8ko3 zK&4~!w6&JW&VKvXafW%xIUwId_0Cf0`x;tK%hk2^$Wr@^9qe#^Y4xhG_@O@?^1OIyRF*45+V z!I%IMJ7RF?mIsDPdy#`jErVY3C}n^h<52_k=S!PdO0F}GF3KT046PLRV=>%amI5Y? z-%&+vYMCBecT^O2045`8@+luN^p_^bG7M$umHu(FN-Bs6J8>An(l9S7;iU3&3SeK# z1>`)t$rb#!nM3>E=?K|t$CVu%g^H6S&O(3lX1(5|v~!R}FhXh?ycF<4uafh4#D<&k zk4u~DG#e9Xke2-z8kJKm`X_U!&jd)_P=%$5Mg&514hkq&4sBcEi^u~or+290+ydQp|-ab$qvv>$=c~go^=v0V>FVY+=8C#O05~&82nRY1~5}Tlq zzG_|8RO8nZ`Mzja^1Y^M4)1l?LcxwoD)}J9v+hD+lUD+uK6dUOmVbD}L-1e8)g*la zmx7=>uutBu4}Vil@}GiXM4AUIicg56GhTmYNBX^kQ|}co`s^x}Fd6XUe*$}~r~)tS z#D6p;Sg#fBtKgd1bid@n=v@9%l%i4nG{wvaEvTy58L&XVTa;)>;43z;d?dp1+gTEy zHY+2)CjIJhNnEXA?(#678&LAL$S&xdk3(x-9dZz@0r|VopXpxmLBVv{qGb@;$2gD3NP_3kkN}{64wL-@$ghN&cx|>E?T5d|SX>JdD$?PfI74Hn*!3 zLFw#4wgf!!T`M1MciEas_V*r&TUXi+MfO{q<;PIFh()Gl7e^TPJZgyBRPTbDU$?S} z?29o&S%u-3F?~N=Po|1n)bS!~S4X|Cv8D+w22gq0TB}MwO|JrHaCu+Bg>JO;*{4xy zXW_o$W6pL#?kD&^#Qku5Y_vC1;d;Ba7nxbny?N-r?-xCrjO6kDI++kxmtd|!004LS zW?Sa~9uUsr%&6$%IWL!X1?);S+2Q*k~It*5xlOF z@rOYg0Qr#lcyYY>K$dO-+|k%s`zGC`(Ewd%32xO{Qh8E7Vv7ONsJ5F6H}TABuNQnQ z4=$pOw1vXXUK<#a2PYV*smn~7%MBP-7v)o&|RqGF*!f`uN9%yKM?Y@Ag{9>aV@rA?ro{^ z`GCK}pRQE2#tYlRvBgY}DP?M>V{sBzqh;?ynG26*jBex}c4^nmT4a*n?I+*%_ z8RK;xOV#iFmk&U;?;2~!H;(^=53YhJavesJDOJL|1P*+@Vb&ztGgLhy_XXF9v2E?t zY?;Dh|A>GC`pHxUeeYf7lY3fyo#t8X?z1;)>YIy!z2Ki?o1;e?KKoAsMo2*Gpa%cj zEikeQcKd)8Q$uWf+hFgcMX}2vEEvBem<>(8g46iz%}A4C({)Jxm?}m}Ab+mXVSV{| ztf`1P>*E@K05uk&M_rb5j<}B^C6W(O0Is*B)EnSC(oHHZS0YGWoLU~6cK_@!G&#B@ zxGFTdGqma|9wl*aZbMV123MwOVWV>6T#aC{6OS8cZw#C)YoCokf>=mV2r^38SW(Du zB=byeu$Bf9q!M!uL?~Rm80gpI? zgaRf{r}@0YFIAFW5(UbzBu*DU>B|d5UoHreH%Nfi%nR*s^-^D|WG`c%7D~ zzb_I5a02E%zZc;h*Z*B_-yQI8TJ4wnGVdRe|CH`;&mliD+g}{;dOqO`e~JAs-Ps#S zuf9jv{hrdNU){HN>s=t2fPbkabpJ(i=JLO+a^e^t0e9y@p^U|L6pR>##Hy`tN&1;|d`(D-G zF8f~)+tXiX*{?mZ|qW2Xi31VUk913&|S2LPY| z;ENDgjc;H`W8RaISD9zUeVQ)Sjy#WT8YTmPtC-g8QERkT|kv z+AL0+H}_~NlG#{bR3o#Q*0=tYi@w${RG3${x?x1kCR|v!-F2P*#1mFnX)X~bje8y! zyCh_JgZEm2By%lix|OM#TxCmPh=3{{tgP?)_gYCk8gejZn|P%nh8m8upg>HJN{<<3%<+g6g+6f8`K6@cliM0T6v=oL|z};lzUwDcD+cS4J zFEVe5S$jl6denmosCmn8<<{K9lklu5p29SpqI&OYW8vqC-nsUNcK%rO?o%;A;Av=6 zdX;nZMcvw9T|4^;qGc?$$JagwqEI#1Qp0Q<1|a1!3tEOejD&gZ0ENZBjs>8REC}QK&0iQGwy#E{#v?DWB5y$>~bSZZj zEWsUm;P2ryH6~6GaJiR*b|uP=a5`bR$NxdHn7oA51#qLInc@HayQcXUjbAR6qlRZ7 zipQ6JlOoakbu0jvmC0O0yDMTYz&rplRs&l_8WmVqSW&24!ddc{=%th1KceJlmf8pFnXN$a-a28;TGeROFfkeL zVUiKd=%C?7sTWUgKeA?*SEaD1A?#c`tM3QzV3DCEp8R7oqwoQ>JzYtK8AQ0Mahh7i zB*No-mDGo>h?RQACezt+smR?R$>WDL?_hyPI1bWD^FfBovpGou^Pap00!dz`%d6YM z&k0TlfH$}lcY+^xbZ~BDeR^*3W!&_Yjx5N-i8%Z>GWs`n6e;o;Tzx)tHgIv@R8X7l7x)mR3HzFH-Q~HX3mSS=*k4OMjXi~U&+9#UkxBbNfOAuZfUAmw^-jH+ zN((<+GaXFB@jSo^OnA;AqIH=%Vw&s9V>AmK8sFBKf}3}Vzvv#FL>pnuTs&_MpAvbx zdml?5)0>I%RB#07)i6CL=sT!XVK17qwF%WqCoAs-QoLD*{6Td4B`K$^sN{e84S`Om z8&h!@GZJkaJM|NAz_@Oj6em#>k0{fUqVI_p)2q0T@1$Z)SMs_96{?Ntv zx%om5R^fdoE}mpW$VboR&Wql+%Hp0vj)?iFRFVK=~60f(*E+a>ZyMSWKdRRbnL}V4fGl7~~e#ZkX0)$+HD&!i@ znT8TmG64(^js43jZHsv6h0$(Uhv-+Co-xsR4~Mr8MaaWNCNe-HKXF{79qZRsO&mra zlSFuofm~FKZ7_j*mZ!?YI1}+z{p>L6j1$6~PSsua)*J!R%KX7(-m1lyPh>BhMefv@ zG&^|2&1LM_rz@Oa+42wt4(z3<11_v&7E5w5T9`vEQeJ``bZ^gZ_p8~%vrF&fujXE` z{Waiv{iu;?4zbPA=Uw;loSV3sN2~YW__7o)F)jCby) zk`=D~O=5Aj^)AC`lo*-Kig!1Co$OmdbJLBP)4D7O&rHia!}V2f1piWACSf~^XVFWG z(<{#+;0KMx|oULqAJ|CB8EtWHRW-?lw3U(JN_L4vQ5Xa$k5gDieMGz*9qKs2nFgoG^ z{Tv~r4C`|m4$B8PQc_jxHHdHCv<+x8L!bxcRpJ^VjlEp z_AIlzxez7wlz4CS+K6B!ylQdp82&xJPEEWhMLhbheroiy5$?}xmc_ry!_psg<90H% zlT+t^@qS!{-$IvO@Z=4CCP8qbyvmLG{{)?U9>@km8-0kC%JhL|hU0i^oo#;<3Z-wz zN9pSje@y}p+eNy7Gyn?1&c~3iL;-8V}-10h{+AldYkV-}) zVdejFW;aCh>s66}{q_(Ws!63WZ4SqpuX*BAi272}v|`u_vb_TkEH4=-KnD?Hm>LSZ z0x}{sGo2|dEymbLp9`@^BkqvMRHml*5j@*Jl_-~*4!|MSlkn)UAXKWT$a=;(ngwx} zn`*HgR*OE%HdT=R`-T2b;G`?j@`ba|g7vu`N9Hq#inI18^^&kbq3iNv~;rW)D>+YnZX3@rZ!*-Ra6`v^nAzg=&hTWw#Csq;tpirx!1D{kO; z2BqkD#1*58=JLu{NUY0Bp=*f|W6LhtLwcrQo_h@kR*6NSV&C&!^Tk<*`u6BWfC@Cu zXAjf2BBbj=vN^7`l9y-+_3z(8ksy3<{G}NqNz8eOCssnkLUAV4Kzem;Yi5vJ?jN?gp*u!m zj0pAe#)mEz==5^x|Jo)1r?VG#5hmqOsU|h)()+FD&)tFoTZc;sc4sL5A|A!Y9Twmc zvSlbPN3h(8yAG~e58yt1=1d^aP{9MktR2}}$mUIgB0qMhHI3NdgEVkdG)1pD)%8U;#?@ISIW{#&!%`LA2EN#4gRt)?FlZu@#ktwHKd*5p~FUAXVy( z$4u&S!HW&Ni<(_5X>l-nl~)xGJRXz9(Pc-bE?chKfWH$`yn~?iN}9hysTB_vjFU@d z+{7Ll6k2zi6R+nN;B~Z-k5-do>jTeAxM7q(c{Up<>>zu=Z^QCjr$le_ZirqA5gwJ~ zgrwxCrNkQR4RT?*xZ)(s%9sTfY|`irhI!Q19SJ(?x;!qMO>7r7GXcf1f1+G9KkpJz zY8{-xxeKtTzxBPZP`cL|`Oq2Z`nSFsJG;xo@kbS8LptoTSxF1}4l%{e>4rhSH3l|T zpoALqAW^TfCef0f#MKe{D9;G`&uo!dEki`h5$Q}gAWvW~Z$C|hF-s`G!zGQ^?^vUA z*xHUgJ@Sc6`kqtm6|h;I5L0bzvBfE}L&idNJB!%2!i2LJo7CxwHx<~J)(0B;Dv46^ zYg&Mek+i9{3;qRm<nDuy<3(G^>#v?SnO86MGPvlS=TxCR^VQMND%3!gYAv(!0%&6-q1LxL_S zTmOc&KHHF4a+GSZ9%1Dtx;s6I_*#MdE<)sq<be(aMg+*pQMXgRKB0*$hyaxXCWvbSPFIo!-7o|`*zi58(#CV+(Ib`XhmPvLTF%1{pmxX zBi0XjmsY|!pq$ozrp%4GyyU3KA4T)G>KmHOe6)*t^tf44>U2|8st%2TZjM%!*u{hj zF*Iobjy&Y~sfSu}Fu!fYX1Qd^0UKnL*(eJ4kR)-NI+)|l;z@Jah6S=>U$Og9)MEkz zc4T@OtU%KDSmT|9otW%VYX+pM$uh0?Yz(2UbL6s;+)RIlRA=^ zmi)Xqd1_L~-}>nyszW?XRht6G`HzrflXG)QWsBVyqPE3;b>C>Fgg>(hzOxmGE2eqp z5}M$l2f7Ss4){6Wt@%;_{pFzyiwg$;;3QvMnw_3|e~$Si?AGL(i`|QTw-CVV%gbm@ zIH{&T+f>_0(e-@gtn%YI;xp7OW&Na5os~1RW(^vv>D1(-Mm^-C(X$jGvL^gtLaM5j z=TRX?dsmRdi9&4$b88{3+qUKry=8^UCDX=y4E;b{YxjKUIRkRv9y?Ob zsfzWbDSS9n?u_NGk|N%F{Xal){X&sO0hO7kYvr4MkM%~jifbz#dZX=&mlwMoj{djX zo#x*OXMUC?>1HSV0GC`<1T7u~Cw_*w9#M(aJoVMB6y}6VuESz6uii}iClz~M$3m%v zktf+ecJPcAk>tAGqHfj$ouV!N^{vn}O zMP-^&Rtn&LeE(tQso79r?Ji_f)3D)eFj3`YZWf6F+4X;t!ji^=GNfIubLI2(dl3c` z(FaIH8KSr#*+WtwELr+=5DE$sIiXr1slw?_*}7jR!~}#TI7KB`3#B?&v?+o+x3z0z zwXe5`BNr&Ez0E4VcN+l}jt-Q651JT(W=(FK&%$_(wIl-txg>sLgV*XcU?@XhFveL( zr~{xITY*7&*7omhH}BTRGa4kh0^ut0FkjL~b4sabF=P%}R(YLaYUNx{Nab7#Fm*E` zpD$tQF`1{3NGvQ-@x9M06w$6TgwCC;t5X|XJol&_r({%-fgUVb^T?qGC0nfK>f$6G z3^gt^0iCVxs*yrRo6ZR9H#pw`r4#l6&9J11hn^5NegzR`A)SdU9VQX|oyi6{F|!n; z9|>z~181-rVk{&*CLTwzmt4dn_Xp$%{`l09&_nml`eX6XN*G!RhJKhh=2+)VM5SZ%bg)0 zZkg^Yf7tXUFeu8uD{CTNGN96wY#q_VkCRIeLBWPBoFgn=^VAJ>i1Y~oo#R40v{@+c z(TOg->T-U2MX8nh3jw>h&w~`8ARKs?)+}%a83@2pSsrfi9(X0a5D*v($@dqrLWe|0 zdIXX}+w&%uVqb^VAd`SKkO$eyi}B2LpAC6S2_AjyQMc>A7OiS8tm_?ak@=S@{-G)- zCl`zC@~<%rjBvZ+ixt~;56OW^rW`{XF;WxQ-2a8Ib6gUIiK6S+wr$(CZQHhO+qQYf zwr$(CXYwUKQmLdW@6c7CdFzIj>OYI^zf9d>Tvm04qY=B-7K$ zJHo-?0{Uyc?7r2aND}}7bo&4R{QC1ue0zmWurWC4q>ntKe%vE0Lfe@sNweU;8;y=I zLM(xWX0J9{ma1FHC%%`RC%CV)i(BYPVoqn8Fccb;D%*uSNm}%atgr0bmpEYj(si`J$PvnwA z2y@Q-JY{_ejxDGL%e!uWwx?OgPKJ*EOPU+4hJNCHt-E%;x< zWZN2J{GOPS_Rn>dzOSZ2MOyACb?|Q2PTTC7LTXxSAM?UC%*V~_$6899 zvX)J?k-dPCmI7+hCWaTBBMvq=EA{~#&f8NcfX9^e1pdqCCk}2(^>1QJ^>Ba zoo|#GS%`leEWRwiuh@O_IfoVaQbIfY+jrLs^62jQzKkx5IPzYe$)j=TAJol)lh!Wp z^=Q{$@U(lov=xQ`tm%+5hRhseYJ~iZ4WAm-g(MPR@>O|>itxS}F*E#}Kx>{LhaYDj zm+1L*32Zr2k3UMa;6W!gS*OgJA9WAaOs(GAlRQy|Byy>q^V|G;rhuT2opxQo39w@q z5i>6UJJ3e@K2Zap9lCDsa~|nksh?qljxiW>Zij7-AM^DK;)+Q)KJIM8WG0LYFmYG7 z_`}o)u=fP;%s}(=aSuuNlNub&tsIqMHsLKB*E8i5WEKDvy$Y+-6V_!)=UL9ClS6qe zN{_3*0`h>#{EW*9s?~*ArK60f4G+OW5vw3EZ%gWeg_{hP3LXvwsROaypNR9{#lEZY zg&+#Di#D8dpf5Vh^mdkV_zS}f1MGw}6%CBvVlSqHnAf7Wwx`W6%;?RaS=qNOaZ;&; zwYODmyG{Cu^~J_Kye2s=@n-WPoN*$0sG&{&5z~35!-zW7#e!C2{|vX2?%0&{z~T!- z_P}~0h1IAgU+~Ft$E49vK0jABMl)V?$?qfPgC?S+$a}>53V?CsGW3~&+ei;H0Ntxb z96iX8`rbrJk+_si?Jg)6N{~&=Z|<0LhrA{axL$L{oaj%m@8|j{%__dx5EuPn*U-Ml z_=?o{S(tU!6WKz>;!{vVXCg*4^4tPRr8BHHX&46+fb_uAOT7?bGrfDZC``ya(+Q1nky_CdUQ2~hj5^}6LG=;e6-3>6 z6ATMT#y%f&oL(8B64V=w&+j{S)<#6hzeCwev*KxIryyb!T7NZdX=j=mO=AD6TIIO= zRM98&bt>k}6v*KRE z$pt-qVG~Q!%PF~NAF)$ISgnMVvo1C>foTNB3F}d^x~M76aNvKyp-w z1I7A-s|KL(GX2Nw5Oi~B3uk#uy)Cg?1Vt7VODP3Hh-nlKoPUB*@9SO_y0Fr#*UddD z8yLl?;-T$mulH<@tklgF(}*a_XO84^dPd-f-L6}V+VPZ!Pm7h@bY$$wUe?c=QRnIF zQ}5%Pl|*4#O#bnY(r6*maTl@X5R$qBEt^p9lT-nY->9N~nV2g$XVSHUqJv`>zl0v6 zH)at3QylY7CB$skny;8}fY&v405VoEOfcpbsd)rKc6T`JQ2W*+!^{Y`|nZuyZ7^8%K zH&SAD`X20*(|u(mLOfbM(ma}Zcrwjg#40=0j{=y_0;E`h!=Dsp1@;7Xg+xx_ETbMn zWm|1+am)j4CF(pzV^&(z$(nr-nvg$mW|~!!T5x*0WsLi|c)dx5nyI+2q$m=Jm3E2L z`z*X2|4p172aZ54fLaLeg;X4WXKDSsP!vfjHyWKR3uB#SlSO%>O}{4w<2+(A?maw@ z{6!olEyB|H(r_dtqpNOcqIbWq;alsC71Ms9F^LTrg61J zFbTx)Vf7Bfh}9&V;k<*R%5kX$7L$UTnmA{Ym;}@)ocK?5Rsj}2aun4x?&&uFbzV$r z0Zex}1&P{3`>$3eNno?X>P$b)Lz4kLw6`V01~1a}^R9^?yHE{Bs-*#+_j8>3x3F

-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

f?FJVDyIVu~uuuvexDgCu)v|DqV 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∈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 KdA}wG!2bg}Ht~`G diff --git a/sweetest/sweetest/lib/__init__.py b/sweet/lib/__init__.py similarity index 100% rename from sweetest/sweetest/lib/__init__.py rename to sweet/lib/__init__.py diff --git a/sweetest/sweetest/lib/c.py b/sweet/lib/c.py similarity index 100% rename from sweetest/sweetest/lib/c.py rename to sweet/lib/c.py diff --git a/sweetest/sweetest/lib/http_handle.py b/sweet/lib/http_handle.py similarity index 100% rename from sweetest/sweetest/lib/http_handle.py rename to sweet/lib/http_handle.py diff --git a/sweetest/sweetest/lib/u.py b/sweet/lib/u.py similarity index 100% rename from sweetest/sweetest/lib/u.py rename to sweet/lib/u.py diff --git a/sweet/modules/__init__.py b/sweet/modules/__init__.py new file mode 100644 index 0000000..4683a33 --- /dev/null +++ b/sweet/modules/__init__.py @@ -0,0 +1,8 @@ +from pathlib import Path + + +path = Path(__file__).resolve().parents[0] + +__all__ = [] +for p in path.iterdir(): + __all__.append(p.stem) \ No newline at end of file diff --git a/sweet/modules/db.py b/sweet/modules/db.py new file mode 100644 index 0000000..fded656 --- /dev/null +++ b/sweet/modules/db.py @@ -0,0 +1,214 @@ +from injson import check +from sweet import log, vars +from sweet.utility import compare, json2dict + + +keywords = { + 'SQL': 'SQL' +} + + +class App: + + keywords = keywords + + def __init__(self, setting): + # 获取连接参数 + self.db = DB(setting) + + def _close(self): + pass + + def _call(self, step): + # 根据关键字调用关键字实现 + getattr(self, step['keyword'].lower())(step) + + def sql(self, step): + + response = {} + + _sql = step['element'] + + log.debug(f'SQL: {repr(_sql)}') + + row = {} + if _sql.lower().startswith('select'): + row = self.db.fetchone(_sql) + log.debug(f'SQL response: {repr(row)}') + if not row: + raise Exception('*** Fetch None ***') + + elif _sql.lower().startswith('db.'): + _sql_ = _sql.split('.', 2) + collection = _sql_[1] + sql = _sql_[2] + response = self.db.mongo(collection, sql) + if response: + log.debug(f'find result: {repr(response)}') + else: + self.db.execute(_sql) + + if _sql.lower().startswith('select'): + text = _sql[6:].split('FROM')[0].split('from')[0].strip() + keys = dedup(text).split(',') + for i, k in enumerate(keys): + keys[i] = k.split(' ')[-1] + response = dict(zip(keys, row)) + log.debug(f'select result: {repr(response)}') + + expected = step['data'] + if not expected: + expected = step['expected'] + if 'json' in expected: + expected['json'] = json2dict(expected.get('json', '{}')) + result = check(expected.pop('json'), response['json']) + log.debug(f'json check result: {result}') + if result['code'] != 0: + raise Exception( + f'json | EXPECTED:{repr(expected["json"])}, REAL:{repr(response["json"])}, RESULT: {result}') + elif result['var']: + vars.put(result['var']) + log.debug(f'json var: {repr(result["var"])}') + + if expected: + for key in expected: + sv, pv = expected[key], response[key] + log.debug(f'key: {repr(key)}, expect: {repr(sv)}, real: { repr(pv)}') + + compare(sv, pv) + + output = step['output'] + if output: + + for k, v in output.items(): + if k == 'json': + sub = json2dict(output.get('json', '{}')) + result = check(sub, response['json']) + vars.put(result['var']) + log.debug(f'json var: {repr(result["var"])}') + else: + vars.put({k: response[v]}) + log.debug(f'output: {vars.output()}') + + +def dedup(text): + ''' + 去掉 text 中括号及其包含的字符 + ''' + _text = '' + n = 0 + + for s in text: + if s not in ('(', ')'): + if n <= 0: + _text += s + elif s == '(': + n += 1 + elif s == ')': + n -= 1 + return _text + + +class DB: + + def __init__(self, arg): + self.connect = '' + self.cursor = '' + self.db = '' + + try: + if arg['type'].lower() == 'mongodb': + import pymongo + host = arg.pop('host') if arg.get( + 'host') else 'localhost:27017' + host = host.split(',') if ',' in host else host + port = int(arg.pop('port')) if arg.get('port') else 27017 + if arg.get('user'): + arg['username'] = arg.pop('user') + # username = arg['user'] if arg.get('user') else '' + # password = arg['password'] if arg.get('password') else '' + # self.connect = pymongo.MongoClient('mongodb://' + username + password + arg['host'] + ':' + arg['port'] + '/') + self.connect = pymongo.MongoClient(host=host, port=port, **arg) + self.connect.server_info() + self.db = self.connect[arg['dbname']] + + return + + sql = '' + if arg['type'].lower() == 'mysql': + import pymysql as mysql + self.connect = mysql.connect( + host=arg['host'], port=int(arg['port']), user=arg['user'], password=arg['password'], database=arg['dbname'], charset=arg.get('charset', 'utf8')) + self.cursor = self.connect.cursor() + sql = 'select version()' + + elif arg['type'].lower() == 'oracle': + import os + import cx_Oracle as oracle + # Oracle查询出的数据,中文输出问题解决 + os.environ['NLS_LANG'] = 'SIMPLIFIED CHINESE_CHINA.UTF8' + self.connect = oracle.connect( + arg['user'] + '/' + arg['password'] + '@' + arg['host'] + '/' + arg['sid']) + self.cursor = self.connect.cursor() + sql = 'select * from v$version' + elif arg['type'].lower() == 'sqlserver': + import pymssql as sqlserver + self.connect = sqlserver.connect( + host=arg['host'], port=arg['port'], user=arg['user'], password=arg['password'], database=arg['dbname'], charset=arg.get('charset', 'utf8')) + self.cursor = self.connect.cursor() + sql = 'select @@version' + + self.cursor.execute(sql) + self.cursor.fetchone() + + except: + log.exception(f'*** {arg["type"]} connect is failure ***') + raise + + def fetchone(self, sql): + try: + self.cursor.execute(sql) + data = self.cursor.fetchone() + self.connect.commit() + return data + except: + log.exception('*** fetchone failure ***') + raise + + def fetchall(self, sql): + try: + self.cursor.execute(sql) + data = self.cursor.fetchall() + self.connect.commit() + return data + except: + log.exception('*** Fetchall failure ***') + raise + + def execute(self, sql): + try: + self.cursor.execute(sql) + self.connect.commit() + except: + log.exception('*** execute failure ***') + raise + + def mongo(self, collection, sql): + try: + cmd = 'self.db[\'' + collection + '\'].' + sql + result = eval(cmd) + if sql.startswith('find_one'): + return result + elif sql.startswith('find'): + for d in result: + return d + elif 'count' in sql: + return {'count': result} + else: + return {} + except: + log.exception('*** execute failure ***') + raise + + def __del__(self): + self.connect.close() diff --git a/sweet/modules/file.py b/sweet/modules/file.py new file mode 100644 index 0000000..d025600 --- /dev/null +++ b/sweet/modules/file.py @@ -0,0 +1,190 @@ +import os + + +keywords = { + '复制': 'COPY', + 'COPY': 'COPY', + '移动': 'MOVE', + 'MOVE': 'MOVE', + '删除文件': 'REMOVE', + 'REMOVE': 'REMOVE', + '删除目录': 'RMDIR', + 'RMDIR': 'RMDIR', + '创建目录': 'MKDIR', + 'MKDIR': 'MKDIR', + '路径存在': 'EXISTS', + 'EXISTS': 'EXISTS', + '路径不存在': 'NOT_EXISTS', + 'NOT_EXISTS': 'NOT_EXISTS', + '是文件': 'IS_FILE', + 'IS_FILE': 'IS_FILE', + '是目录': 'IS_DIR', + 'IS_DIR': 'IS_DIR', + '不是文件': 'NOT_FILE', + 'NOT_FILE': 'NOT_FILE', + '不是目录': 'NOT_DIR', + 'NOT_DIR': 'NOT_DIR' +} + + +class App: + + keywords = keywords + + def __init__(self, setting): + self.dir = '.' + if 'dir' in setting: + self.dir = setting['dir'] + os.chdir(self.dir) + + def _close(self): + pass + + def _call(self, step): + # 根据关键字调用关键字实现 + getattr(self, step['keyword'].lower())(step) + + + def copy(self, step): + source = step['element'] + data = step['data'] + destination = data['text'] + + if 'dir' in data: + os.chdir(data['dir']) + else: + os.chdir(self.dir) + + code = 0 + if os.name == 'nt': + code = os.system(f'COPY /Y {source} {destination}') + if os.name == 'posix': + code = os.system(f'cp -f -R {source} {destination}') + + if code != 0: + raise Exception( + f'COPY {source} {destination} is failure, code: {code}') + + def move(self, step): + source = step['element'] + data = step['data'] + destination = data['text'] + + if 'dir' in data: + os.chdir(data['dir']) + else: + os.chdir(self.dir) + + code = 0 + if os.name == 'nt': + code = os.system(f'MOVE /Y {source} {destination}') + if os.name == 'posix': + code = os.system(f'mv -f {source} {destination}') + + if code != 0: + raise Exception( + f'MOVE {source} {destination} is failure, code: {code}') + + def remove(self, step): + path = step['element'] + data = step['data'] + + if 'dir' in data: + os.chdir(data['dir']) + else: + os.chdir(self.dir) + + code = 0 + if os.name == 'nt': + code = os.system(f'del /S /Q {path}') + if os.name == 'posix': + code = os.system(f'rm -f {path}') + + if code != 0: + raise Exception(f'REMOVE {path} is failure, code: {code}') + + def rmdir(self, step): + path = step['element'] + data = step['data'] + + if 'dir' in data: + os.chdir(data['dir']) + else: + os.chdir(self.dir) + + code = 0 + if os.name == 'nt': + code = os.system(f'rd /S /Q {path}') + if os.name == 'posix': + code = os.system(f'rm -rf {path}') + + if code != 0: + raise Exception(f'RERMDIR {path} is failure, code: {code}') + + def mkdir(self, step): + path = step['element'] + data = step['data'] + + if 'dir' in data: + os.chdir(data['dir']) + else: + os.chdir(self.dir) + + code = 0 + if os.name == 'nt': + code = os.system(f'mkdir {path}') + if os.name == 'posix': + code = os.system(f'mkdir -p {path}') + + if code != 0: + raise Exception(f'MKDIR {path} is failure, code: {code}') + + def exists(self, step): + path = step['element'] + result = os.path.exists(path) + + if not result: + raise Exception(f'{path} is not exists') + + def not_exists(self, step): + try: + self.exists(step) + except: + pass + else: + path = step['element'] + raise Exception(f'{path} is a exists') + + def is_file(self, step): + path = step['element'] + + result = os.path.isfile(path) + + if not result: + raise Exception(f'{path} is not file') + + def not_file(self, step): + try: + self.is_file(step) + except: + pass + else: + path = step['element'] + raise Exception(f'{path} is a file') + + def is_dir(self, step): + path = step['element'] + + result = os.path.isdir(path) + + if not result: + raise Exception(f'{path} is not dir') + + def not_dir(self, step): + try: + self.is_dir(step) + except: + pass + else: + path = step['element'] + raise Exception(f'{path} is a dir') diff --git a/sweet/modules/http.py b/sweet/modules/http.py new file mode 100644 index 0000000..b81db2d --- /dev/null +++ b/sweet/modules/http.py @@ -0,0 +1,261 @@ +import requests +from pathlib import Path +from injson import check + +from sweet import log, vars +from sweet.utility import json2dict + + +path = Path('lib') / 'http_handle.py' +if path.is_file(): + from lib import http_handle +else: + from sweet.lib import http_handle + + +keywords = { + 'GET': 'GET', + 'POST': 'POST', + 'PUT': 'PUT', + 'PATCH': 'PATCH', + 'DELETE': 'DELETE', + 'OPTIONS': 'OPTIONS' +} + + +class App: + + keywords = keywords + + def __init__(self, setting): + # 获取 path + self.path = setting.get('path', '') + if self.path: + if not self.path.endswith('/'): + self.path += '/' + + self.r = requests.Session() + + # 获取 headers + self.headers = {} + for key in keywords: + if setting.get(key.lower()): + self.headers[key.upper()] = setting.get(key.lower()) + + + def _close(self): + self.r.close() + + def _call(self, step): + # 根据关键字调用关键字实现 + getattr(self, step['keyword'].lower())(step) + + + def get(self, step): + self.request('get', step) + + def post(self, step): + self.request('post', step) + + def put(self, step): + self.request('put', step) + + def patch(self, step): + self.request('patch', step) + + def delete(self, step): + self.request('delete', step) + + def options(self, step): + self.request('options', step) + + + def request(self, kw, step): + url = step['element'] + if url.startswith('/'): + url = url[1:] + + data = step['data'] + # 测试数据解析时,会默认添加一个 text 键,需要删除 + if 'text' in data and not data['text']: + data.pop('text') + + _data = {} + _data['headers'] = json2dict(data.pop('headers', '{}')) + if data.get('cookies'): + data['cookies'] = json2dict(data['cookies']) + if kw == 'get': + _data['params'] = json2dict( + data.pop('params', '{}')) or json2dict(data.pop('data', '{}')) + elif kw == 'post': + if data.get('text'): + _data['data'] = data.pop('text').encode('utf-8') + else: + _data['data'] = json2dict(data.pop('data', '{}')) + _data['json'] = json2dict(data.pop('json', '{}')) + _data['files'] = eval(data.pop('files', 'None')) + elif kw in ('put', 'patch'): + _data['data'] = json2dict(data.pop('data', '{}')) + + for k in data: + for s in ('{', '[', 'False', 'True'): + if s in data[k]: + try: + data[k] = eval(data[k]) + except: + log.warning(f'try eval data failure: {data[k]}') + break + expected = step['expected'] + expected['status_code'] = expected.get('status_code', None) + expected['text'] = expected.get('text', '').encode('utf-8') + expected['json'] = json2dict(expected.get('json', '{}')) + expected['cookies'] = json2dict(expected.get('cookies', '{}')) + expected['headers'] = json2dict(expected.get('headers', '{}')) + timeout = float(expected.get('timeout', 10)) + expected['time'] = float(expected.get('time', 0)) + + for key in keywords: + if kw.upper() == key.upper(): + self.r.headers.update(self.headers[key.upper()]) + + log.debug(f'URL: {self.path + url}') + + # 处理 before_send + before_send = data.pop('before_send', '') + if before_send: + _data, data = getattr(http_handle, before_send)(kw, _data, data) + else: + _data, data = getattr(http_handle, 'before_send')(kw, _data, data) + + if _data['headers']: + for k in [x for x in _data['headers']]: + if not _data['headers'][k]: + del self.r.headers[k] + del _data['headers'][k] + self.r.headers.update(_data['headers']) + + r = '' + if kw == 'get': + r = getattr(self.r, kw)(self.path + url, + params=_data['params'], timeout=timeout, **data) + if _data['params']: + log.debug(f'PARAMS: {_data["params"]}') + + elif kw == 'post': + r = getattr(self.r, kw)(self.path + url, + data=_data['data'], json=_data['json'], files=_data['files'], timeout=timeout, **data) + log.debug(f'BODY: {r.request.body}') + + elif kw in ('put', 'patch'): + r = getattr(self.r, kw)(self.path + url, + data=_data['data'], timeout=timeout, **data) + log.debug(f'BODY: {r.request.body}') + + elif kw in ('delete', 'options'): + r = getattr(self.r, kw)(self.path + url, timeout=timeout, **data) + + log.debug(f'status_code: {repr(r.status_code)}') + try: # json 响应 + log.debug(f'response json: {repr(r.json())}') + except: # 其他响应 + log.debug(f'response text: {repr(r.text)}') + + response = {'status_code': r.status_code, 'headers': r.headers, + '_cookies': r.cookies, 'content': r.content, 'text': r.text} + + try: + response['cookies'] = requests.utils.dict_from_cookiejar(r.cookies) + except: + response['cookies'] = r.cookies + + try: + j = r.json() + response['json'] = j + except: + response['json'] = {} + + # 处理 after_receive + after_receive = expected.pop('after_receive', '') + if after_receive: + response = getattr(http_handle, after_receive)(response) + else: + response = getattr(http_handle, 'after_receive')(response) + + if expected['status_code']: + if str(expected['status_code']) != str(response['status_code']): + raise Exception( + f'status_code | EXPECTED:{repr(expected["status_code"])}, REAL:{repr(response["status_code"])}') + + if expected['text']: + if expected['text'].startswith('*'): + if expected['text'][1:] not in response['text']: + raise Exception( + f'text | EXPECTED:{repr(expected["text"])}, REAL:{repr(response["text"])}') + else: + if expected['text'] == response['text']: + raise Exception( + f'text | EXPECTED:{repr(expected["text"])}, REAL:{repr(response["text"])}') + + if expected['headers']: + result = check(expected['headers'], response['headers']) + log.debug(f'headers check result: {result}') + if result['code'] != 0: + raise Exception( + f'headers | EXPECTED:{repr(expected["headers"])}, REAL:{repr(response["headers"])}, RESULT: {result}') + elif result['var']: + # var.update(result['var']) + vars.put(result['var']) + log.debug(f'headers var: {repr(result["var"])}') + + if expected['cookies']: + log.debug(f'response cookies: {response["cookies"]}') + result = check(expected['cookies'], response['cookies']) + log.debug(f'cookies check result: {result}') + if result['code'] != 0: + raise Exception( + f'cookies | EXPECTED:{repr(expected["cookies"])}, REAL:{repr(response["cookies"])}, RESULT: {result}') + elif result['var']: + # var.update(result['var']) + vars.put(result['var']) + log.debug(f'cookies var: {repr(result["var"])}') + + if expected['json']: + result = check(expected['json'], response['json']) + log.debug(f'json check result: {result}') + if result['code'] != 0: + raise Exception( + f'json | EXPECTED:{repr(expected["json"])}, REAL:{repr(response["json"])}, RESULT: {result}') + elif result['var']: + # var.update(result['var']) + vars.put(result['var']) + log.debug(f'json var: {repr(result["var"])}') + + if expected['time']: + if expected['time'] < r.elapsed.total_seconds(): + raise Exception( + f'time | EXPECTED:{repr(expected["time"])}, REAL:{repr(r.elapsed.total_seconds())}') + + output = step['output'] + # if output: + # log.debug('output: %s' % repr(output)) + + for k, v in output.items(): + if v == 'status_code': + status_code = response['status_code'] + vars.put({k: status_code}) + log.debug(f'{k}: {status_code}') + elif v == 'text': + text = response['text'] + vars.put({k: text}) + log.debug(f'{k}: {text}') + elif k == 'json': + sub = json2dict(output.get('json', '{}')) + result = check(sub, response['json']) + # var.update(result['var']) + vars.put(result['var']) + log.debug(f'json var: {repr(result["var"])}') + elif k == 'cookies': + sub = json2dict(output.get('cookies', '{}')) + result = check(sub, response['cookies']) + vars.put(result['var']) + log.debug(f'cookies var: {repr(result["var"])}') diff --git a/sweet/modules/mobile/__init__.py b/sweet/modules/mobile/__init__.py new file mode 100644 index 0000000..37db2eb --- /dev/null +++ b/sweet/modules/mobile/__init__.py @@ -0,0 +1,2 @@ +from sweet.modules.mobile.app import App + diff --git a/sweet/modules/mobile/app.py b/sweet/modules/mobile/app.py new file mode 100644 index 0000000..b87c880 --- /dev/null +++ b/sweet/modules/mobile/app.py @@ -0,0 +1,482 @@ +from selenium import webdriver +from selenium.common.exceptions import ElementClickInterceptedException +from appium.webdriver.common.touch_action import TouchAction +from time import sleep +import re + +from sweet import log, vars +from sweet.utility import compare, replace, json2dict + +from sweet.modules.mobile.window import Windows +from sweet.modules.web.locator import locating +from sweet.modules.web.config import * + + +class App: + + keywords = keywords + + def __init__(self, setting): + self.action = {} + platform = setting.get('platformName', '') + # snapshot = setting.pop('snapshot', False) + + if platform.lower() == 'ios': + from appium import webdriver as appdriver + self.driver = appdriver.Remote(self.server_url, self.desired_caps) + + elif platform.lower() == 'android': + from appium import webdriver as appdriver + self.driver = appdriver.Remote(self.server_url, self.desired_caps) + + # 等待元素超时时间 + self.driver.implicitly_wait(element_wait_timeout) # seconds + # 页面刷新超时时间 + self.driver.set_page_load_timeout(page_flash_timeout) # seconds + self.w = Windows() + self.w.driver = self.driver + + def _close(self): + pass + + def _call(self, step): + # 处理截图数据 + # snap = Snapshot() + # snap.pre(step) + + context = replace(step.get('frame', '')).strip() + self.w.switch_context(context) + + if self.w.current_context.startswith('WEBVIEW'): + # 切换标签页 + tab = step['data'].get('#tab') + if tab: + del step['data']['#tab'] + self.driver.switch_to_window(self.w.windows[tab]) + log.debug(f'current context: {repr(self.w.current_context)}') + + # 根据关键字调用关键字实现 + element = getattr(self, step['keyword'].lower())(step) + # snap.web_shot(step, element) + + + def title(self, data, output): + log.debug(f'DATA:{repr(data["text"])}') + log.debug(f'REAL:{repr(self.driver.title)}') + + if data['text'].startswith('*'): + assert data['text'][1:] in self.driver.title + else: + assert data['text'] == self.driver.title + # 只能获取到元素标题 + for key in output: + vars.put({key: self.driver.title}) + + + def current_url(self, data, output): + log.debug(f'DATA:{repr(data["text"])}') + log.debug(f'REAL:{repr(self.driver.current_url)}') + try: + if data['text'].startswith('*'): + assert data['text'][1:] in self.driver.current_url + else: + assert data['text'] == self.driver.current_url + except: + raise Exception( + f'check failure, DATA:{data["text"]}, REAL:{self.driver.current_url}') + # 只能获取到元素 url + for key in output: + vars.put({key: self.driver.current_url}) + return self.driver.current_url + + def locat(self, element, action=''): + if not isinstance(element, dict): + raise Exception(f'no this element:{element}') + + + def open(self, step): + url = step['element']['value'] + + if step['data'].get('#clear', ''): + self.driver.delete_all_cookies() + + self.driver.get(url) + + cookie = step['data'].get('cookie', '') + if cookie: + self.driver.add_cookie(json2dict(cookie)) + co = self.driver.get_cookie(json2dict(cookie).get('name', '')) + log.debug(f'cookie is add: {co}') + sleep(0.5) + + + def check(self, step): + data = step['data'] + if not data: + data = step['expected'] + + element = step['element'] + by = element['by'] + output = step['output'] + + if by in ('title', 'current_url'): + getattr(self, by)(data, output) + else: + location = self.locat(element) + for key in data: + # 预期结果 + expected = data[key] + # 切片操作处理 + s = re.findall(r'\[.*?\]', key) + if s: + s = s[0] + key = key.replace(s, '') + + if key == 'text': + real = location.text + else: + real = location.get_attribute(key) + if s: + real = eval('real' + s) + + log.debug(f'DATA:{repr(expected)}') + log.debug(f'REAL:{repr(real)}') + try: + compare(expected, real) + except: + raise Exception( + f'check failure, DATA:{repr(expected)}, REAL:{repr(real)}') + + # 获取元素其他属性 + for key in output: + if output[key] == 'text': + v = location.text + vars.put({key: v}) + elif output[key] in ('text…', 'text...'): + if location.text.endswith('...'): + v = location.text[:-3] + vars.put({key: v}) + else: + v = location.text + vars.put({key: v}) + else: + v = location.get_attribute(output[key]) + vars.put({key: v}) + + + def notcheck(self, step): + try: + self.check(step) + raise Exception('check is success') + except: + pass + + def input(self, step): + data = step['data'] + location = self.locat(step['element']) + + if step['data'].get('清除文本', '') == '否' or step['data'].get('clear', '').lower() == 'no': + pass + else: + location.clear() + + for key in data: + if key.startswith('text'): + if isinstance(data[key], tuple): + location.send_keys(*data[key]) + elif location: + location.send_keys(data[key]) + sleep(0.5) + if key == 'word': # 逐字输入 + for d in data[key]: + location.send_keys(d) + sleep(0.3) + + def set_value(self, step): + data = step['data'] + location = self.locat(step['element']) + if step['data'].get('清除文本', '') == '否' or step['data'].get('clear', '').lower() == 'no': + pass + else: + location.clear() + + for key in data: + if key.startswith('text'): + if isinstance(data[key], tuple): + location.set_value(*data[key]) + elif location: + location.set_value(data[key]) + sleep(0.5) + if key == 'word': # 逐字输入 + for d in data[key]: + location.set_value(d) + sleep(0.3) + + def click(self, step): + elements = step['elements'] # click 支持多个元素连续操作,需要转换为 list + # data = step['data'] + + location = '' + for element in elements: + location = self.locat(element, 'CLICK') + sleep(0.5) + try: + location.click() + except ElementClickInterceptedException: # 如果元素为不可点击状态,则等待1秒,再重试一次 + sleep(1) + location.click() + sleep(0.5) + + # 获取元素其他属性 + output = step['output'] + for key in output: + if output[key] == 'text': + vars.put({key: location.text}) + elif output[key] == 'tag_name': + vars.put({key: location.tag_name}) + elif output[key] in ('text…', 'text...'): + if location.text.endswith('...'): + vars.put({key: location.text[:-3]}) + else: + vars.put({key: location.text}) + else: + vars.put({key: location.get_attribute(output[key])}) + + def tap(self, step): + action = TouchAction(self.driver) + + elements = step['elements'] # click 支持多个元素连续操作,需要转换为 list + # data = step['data'] + + location = '' + + for element in elements: + if ',' in element: + position = element.split(',') + x = int(position[0]) + y = int(position[1]) + position = (x, y) + self.driver.tap([position]) + sleep(0.5) + else: + location = self.locat(element, 'CLICK') + action.tap(location).perform() + sleep(0.5) + + # 获取元素其他属性 + output = step['output'] + for key in output: + if output[key] == 'text': + vars.put({key: location.text}) + elif output[key] == 'tag_name': + vars.put({key: location.tag_name}) + elif output[key] in ('text…', 'text...'): + if location.text.endswith('...'): + vars.put({key: location.text[:-3]}) + else: + vars.put({key: location.text}) + else: + vars.put({key: location.get_attribute(output[key])}) + + def press_keycode(self, step): + element = step['element'] + self.driver.press_keycode(int(element)) + + def swipe(self, step): + elements = step['elements'] + duration = step['data'].get('持续时间', 0.3) + assert isinstance(elements, list) and len( + elements) == 2, '坐标格式或数量不对,正确格式如:100,200|300,400' + + start = elements[0].replace(',', ',').split(',') + start_x = int(start[0]) + start_y = int(start[1]) + + end = elements[1].replace(',', ',').split(',') + end_x = int(end[0]) + end_y = int(end[1]) + + if duration: + self.driver.swipe(start_x, start_y, end_x, + end_y, sleep(float(duration))) + else: + self.driver.swipe(start_x, start_y, end_x, end_y) + + def line(self, step): + elements = step['elements'] + duration = float(step['data'].get('持续时间', 0.3)) + assert isinstance(elements, list) and len( + elements) > 1, '坐标格式或数量不对,正确格式如:258,756|540,1032' + postions = [] + for element in elements: + element = element.replace(',', ',') + p = element.split(',') + postions.append(p) + + action = TouchAction(self.driver) + action = action.press( + x=postions[0][0], y=postions[0][1]).wait(duration * 1000) + for i in range(1, len(postions)): + action.move_to(x=postions[i][0], y=postions[i] + [1]).wait(duration * 1000) + action.release().perform() + + def line_unlock(self, step): + elements = step['elements'] + duration = float(step['data'].get('持续时间', 0.3)) + assert isinstance(elements, list) and len( + elements) > 2, '坐标格式或数量不对,正确格式如:lock_pattern|1|4|7|8|9' + location = self.locat(elements[0]) + rect = location.rect + w = rect['width'] / 6 + h = rect['height'] / 6 + + key = {} + key['1'] = (rect['x'] + 1 * w, rect['y'] + 1 * h) + key['2'] = (rect['x'] + 3 * w, rect['y'] + 1 * h) + key['3'] = (rect['x'] + 5 * w, rect['y'] + 1 * h) + key['4'] = (rect['x'] + 1 * w, rect['y'] + 3 * h) + key['5'] = (rect['x'] + 3 * w, rect['y'] + 3 * h) + key['6'] = (rect['x'] + 5 * w, rect['y'] + 3 * h) + key['7'] = (rect['x'] + 1 * w, rect['y'] + 5 * h) + key['8'] = (rect['x'] + 3 * w, rect['y'] + 5 * h) + key['9'] = (rect['x'] + 5 * w, rect['y'] + 5 * h) + + action = TouchAction(self.driver) + for i in range(1, len(elements)): + k = elements[i] + if i == 1: + action = action.press( + x=key[k][0], y=key[k][1]).wait(duration * 1000) + action.move_to(x=key[k][0], y=key[k][1]).wait(duration * 1000) + action.release().perform() + + def rocker(self, step): + elements = step['elements'] + duration = float(step['data'].get('持续时间', 0.3)) + rocker_name = step['data'].get('摇杆', 'rocker') + release = step['data'].get('释放', False) + + # if isinstance(element, str): + # if element: + # element = [element] + # else: + # element = [] + + postions = [] + for element in elements: + element = element.replace(',', ',') + p = element.split(',') + postions.append(p) + + # 如果 action 中么有此摇杆名,则是新的遥感 + if not self.action.get(rocker_name): + self.action[rocker_name] = TouchAction(self.driver) + self.action[rocker_name].press( + x=postions[0][0], y=postions[0][1]).wait(duration * 1000) + # 新摇杆的第一个点已操作,需要删除 + postions.pop(0) + # 依次操作 + for i in range(len(postions)): + self.action[rocker_name].move_to( + x=postions[i][0], y=postions[i][1]).wait(duration * 1000) + + if release: + # 释放摇杆,并删除摇杆 + self.action[rocker_name].release().perform() + del self.action[rocker_name] + else: + self.action[rocker_name].perform() + + def scroll(self, step): + elements = step['elements'] + assert isinstance(elements, list) and len( + elements) == 2, '元素格式或数量不对,正确格式如:origin_el|destination_el' + origin = self.locat(elements[0]) + destination = self.locat(elements[1]) + self.driver.scroll(origin, destination) + + def flick_element(self, step): + elements = step['elements'] + speed = step['data'].get('持续时间', 10) + assert isinstance(elements, list) and len( + elements) == 2, '坐标格式或数量不对,正确格式如:elment|200,300' + location = self.locat(elements[0]) + + end = elements[1].replace(',', ',').split(',') + end_x = int(end[0]) + end_y = int(end[1]) + + if speed: + self.driver.flick_element(location, end_x, end_y, int(speed)) + + def flick(self, step): + elements = step['elements'] + assert isinstance(elements, list) and len( + elements) == 2, '坐标格式或数量不对,正确格式如:100,200|300,400' + + start = elements[0].replace(',', ',').split(',') + start_x = int(start[0]) + start_y = int(start[1]) + + end = elements[1].replace(',', ',').split(',') + end_x = int(end[0]) + end_y = int(end[1]) + + self.driver.flick(start_x, start_y, end_x, end_y) + + def drag_and_drop(self, step): + elements = step['elements'] + assert isinstance(elements, list) and len( + elements) == 2, '元素格式或数量不对,正确格式如:origin_el|destination_el' + origin = self.locat(elements[0]) + destination = self.locat(elements[1]) + self.driver.drag_and_drop(origin, destination) + + def long_press(self, step): + action = TouchAction(self.driver) + + element = step['element'] + duration = step['data'].get('持续时间', 1000) + if ',' in element or ',' in element: + position = element.replace(',', ',').split(',') + x = int(position[0]) + y = int(position[1]) + action.long_press(x=x, y=y, duration=duration).perform() + else: + location = self.locat(element) + action.long_press(location, duration=duration).perform() + sleep(0.5) + + def pinch(self, step): + element = step['element'] + location = self.locat(element) + percent = step['data'].get('百分比', 200) + steps = step['data'].get('步长', 50) + self.driver.pinch(location, percent, steps) + + def zoom(self, step): + element = step['element'] + location = self.locat(element) + percent = step['data'].get('百分比', 200) + steps = step['data'].get('步长', 50) + self.driver.zoom(location, percent, steps) + + def hide_keyboard(self, step): + self.driver.hide_keyboard() + + def shake(self, step): + self.driver.shake() + + def launch_app(self, step): + self.driver.launch_app() + + def is_locked(self, step): + status = self.driver.is_locked() + assert status, "it's not locked" + + def lock(self, step): + self.driver.lock() + + def unlock(self, step): + self.driver.unlock() \ No newline at end of file diff --git a/sweet/modules/mobile/config.py b/sweet/modules/mobile/config.py new file mode 100644 index 0000000..6bb76d7 --- /dev/null +++ b/sweet/modules/mobile/config.py @@ -0,0 +1,58 @@ + + +element_wait_timeout = 10 # 等待元素出现超时时间,单位:秒 +page_flash_timeout = 90 # 页面刷新超时时间,单位:秒 + + +keywords = { + '检查': 'CHECK', + 'CHECK': 'CHECK', + '#检查': 'NOTCHECK', + '#CHECK': 'NOTCHECK', + '输入': 'INPUT', + 'INPUT': 'INPUT', + '填写': 'SET_VALUE', + 'SET_VALUE': 'SET_VALUE', + '点击': 'CLICK', + 'CLICK': 'CLICK', + '轻点': 'TAP', + 'TAP': 'TAP', + '按键码': 'PRESS_KEYCODE', # Android 特有,常见代码 HOME:3, 菜单键:82,返回键:4 + 'PRESS_KEYCODE': 'PRESS_KEYCODE', + '滑动': 'SWIPE', + 'SWIPE': 'SWIPE', + '划线': 'LINE', + 'LINE': 'LINE', + '划线解锁': 'LINE_UNLOCK', + 'LINE_UNLOCK': 'LINE_UNLOCK', + '摇杆': 'ROCKER', + 'ROCKER': 'ROCKER', + '滚动': 'SCROLL', # iOS 专用 + 'SCROLL': 'SCROLL', + '拖拽': 'DRAG_AND_DROP', + 'DRAG_AND_DROP': 'DRAG_AND_DROP', + '摇晃': 'SHAKE', # 貌似 Android 上不可用 + 'SHAKE': 'SHAKE', + '快速滑动': 'FLICK', + 'FLICK': 'FLICK', + '滑动元素': 'FLICK_ELEMENT', + 'FLICK_ELEMENT': 'FLICK_ELEMENT', + '长按': 'LONG_PRESS', + 'LONG_PRESS': 'LONG_PRESS', + '缩小': 'PINCH', + 'PINCH': 'PINCH', + '放大': 'ZOOM', + 'ZOOM': 'ZOOM', + '隐藏键盘': 'HIDE_KEYBOARD', # iOS 专用 + 'HIDE_KEYBOARD': 'HIDE_KEYBOARD', + '命名标签页': 'TAB_NAME', + 'TAB_NAME': 'TAB_NAME', + '重启': 'LAUNCH_APP', + 'LAUNCH_APP': 'LAUNCH_APP', + '锁屏状态': 'IS_LOCKED', + 'IS_LOCKED': 'IS_LOCKED', + '锁屏': 'LOCK', + 'LOCK': 'LOCK', + '解锁': 'UNLOCK', + 'UNLOCK': 'UNLOCK', +} \ No newline at end of file diff --git a/sweet/modules/mobile/locator.py b/sweet/modules/mobile/locator.py new file mode 100644 index 0000000..0d5d10e --- /dev/null +++ b/sweet/modules/mobile/locator.py @@ -0,0 +1,70 @@ +from time import sleep +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from sweet import log +from sweet.config import element_wait_timeout + + +def locating(driver, app, element, action=''): + location = None + try: + el= element + value = el['value'] + except: + log.exception(f'locating the element:{element} is failure, this element is not define') + raise Exception(f'locating the element:{element} is failure, this element is not define') + + if not isinstance(el, dict): + raise Exception(f'locating the element:{element} is failure, this element is not define') + + wait = WebDriverWait(driver, element_wait_timeout) + + if el['by'].lower() in ('title', 'url', 'current_url'): + return None + else: + try: + location = wait.until(EC.presence_of_element_located( + (getattr(By, el['by'].upper()), value))) + except: + sleep(5) + try: + location = wait.until(EC.presence_of_element_located( + (getattr(By, el['by'].upper()), value))) + except : + raise Exception(f'locating the element:{element} is failure: timeout') + try: + if driver.name in ('chrome', 'safari'): + driver.execute_script( + "arguments[0].scrollIntoViewIfNeeded(true)", location) + else: + driver.execute_script( + "arguments[0].scrollIntoView(false)", location) + except: + pass + + try: + if action == 'CLICK': + location = wait.until(EC.element_to_be_clickable( + (getattr(By, el['by'].upper()), value))) + else: + location = wait.until(EC.visibility_of_element_located( + (getattr(By, el['by'].upper()), value))) + except: + pass + + return location + + +def locatings(elements): + locations = {} + for el in elements: + locations[el] = locating(el) + return locations + + +# def locating_data(keys): +# data_location = {} +# for key in keys: +# data_location[key] = locating(key) +# return data_location diff --git a/sweet/modules/mobile/window.py b/sweet/modules/mobile/window.py new file mode 100644 index 0000000..e852fa0 --- /dev/null +++ b/sweet/modules/mobile/window.py @@ -0,0 +1,17 @@ +from sweet import log + +class Windows: + + def __init__(self): + + self.current_context = 'NATIVE_APP' + + def switch_context(self, context): + if context.strip() == '': + context = 'NATIVE_APP' + if context != self.current_context: + if context == '': + context = None + log.debug(f'switch context: {repr(context)}') + self.driver.switch_to.context(context) + self.current_context = context diff --git a/sweet/modules/web/__init__.py b/sweet/modules/web/__init__.py new file mode 100644 index 0000000..a99e5b5 --- /dev/null +++ b/sweet/modules/web/__init__.py @@ -0,0 +1,2 @@ +from sweet.modules.web.app import App + diff --git a/sweet/modules/web/app.py b/sweet/modules/web/app.py new file mode 100644 index 0000000..bc134cb --- /dev/null +++ b/sweet/modules/web/app.py @@ -0,0 +1,421 @@ +from selenium import webdriver +from selenium.webdriver.common.action_chains import ActionChains +from selenium.common.exceptions import ElementClickInterceptedException +from selenium.webdriver.support.select import Select +from time import sleep +import re + +from sweet import log, vars +from sweet.utility import compare, replace, json2dict + +from sweet.modules.web.window import Windows +from sweet.modules.web.locator import locating +from sweet.modules.web.config import * + + +class App: + + keywords = keywords + + def __init__(self, setting): + browserName = setting.get('browserName', '') + headless = setting.pop('headless', False) + # snapshot = setting.pop('snapshot', False) + executable_path = setting.pop('executable_path', False) + # server_url = setting.pop('server_url', '') + + if browserName.lower() == 'ie': + if executable_path: + self.driver = webdriver.Ie(executable_path=executable_path) + else: + self.driver = webdriver.Ie() + elif browserName.lower() == 'firefox': + profile = webdriver.FirefoxProfile() + profile.accept_untrusted_certs = True + + options = webdriver.FirefoxOptions() + # 如果配置了 headless 模式 + if headless: + options.set_headless() + # options.add_argument('-headless') + options.add_argument('--disable-gpu') + options.add_argument("--no-sandbox") + options.add_argument('window-size=1920x1080') + + if executable_path: + self.driver = webdriver.Firefox( + firefox_profile=profile, firefox_options=options, executable_path=executable_path) + else: + self.driver = webdriver.Firefox( + firefox_profile=profile, firefox_options=options) + self.driver.maximize_window() + elif browserName.lower() == 'chrome': + options = webdriver.ChromeOptions() + + # 如果配置了 headless 模式 + if headless: + options.add_argument('--headless') + options.add_argument('--disable-gpu') + options.add_argument("--no-sandbox") + options.add_argument('window-size=1920x1080') + + options.add_argument("--start-maximized") + options.add_argument('--ignore-certificate-errors') + # 指定浏览器分辨率,当"--start-maximized"无效时使用 + # options.add_argument('window-size=1920x1080') + prefs = {} + prefs["credentials_enable_service"] = False + prefs["profile.password_manager_enabled"] = False + options.add_experimental_option("prefs", prefs) + options.add_argument('disable-infobars') + options.add_experimental_option( + "excludeSwitches", ['load-extension', 'enable-automation', 'enable-logging']) + if executable_path: + self.driver = webdriver.Chrome( + options=options, executable_path=executable_path) + else: + self.driver = webdriver.Chrome(options=options) + else: + raise Exception( + 'Error: this browser is not supported or mistake name:%s' % browserName) + # 等待元素超时时间 + self.driver.implicitly_wait(element_wait_timeout) # seconds + # 页面刷新超时时间 + self.driver.set_page_load_timeout(page_flash_timeout) # seconds + self.w = Windows() + self.w.driver = self.driver + + def _close(self): + self.w.close() + + def _call(self, step): + # 处理截图数据 + # snap = Snapshot() + # snap.pre(step) + + name = step['data'].pop('#tab', '') + if name: + self.w.tab(name) + else: + self.w.switch() + + frame = replace(step.get('frame', '')) + self.w.switch_frame(frame) + + # 根据关键字调用关键字实现 + element = getattr(self, step['keyword'].lower())(step) + # snap.web_shot(step, element) + + + def title(self, data, output): + log.debug(f'DATA:{repr(data["text"])}') + log.debug(f'REAL:{repr(self.driver.title)}') + # try: + if data['text'].startswith('*'): + assert data['text'][1:] in self.driver.title + else: + assert data['text'] == self.driver.title + + for key in output: + vars.put({key: self.driver.title}) + + + def current_url(self, data, output): + log.debug(f'DATA:{repr(data["text"])}') + log.debug(f'REAL:{repr(self.driver.current_url)}') + try: + if data['text'].startswith('*'): + assert data['text'][1:] in self.driver.current_url + else: + assert data['text'] == self.driver.current_url + except: + raise Exception( + f'check failure, DATA:{data["text"]}, REAL:{self.driver.current_url}') + # 只能获取到元素 url + for key in output: + vars.put({key: self.driver.current_url}) + + def locat(self, element, action=''): + if not isinstance(element, dict): + raise Exception(f'no this element:{element}') + return locating(self.driver, element, action=action) + + def open(self, step): + if isinstance(step['element'], dict): + url = step['element']['value'] + else: + url = step['element'] + + if step['data'].get('#clear', ''): + self.driver.delete_all_cookies() + + self.driver.get(url) + + cookie = step['data'].get('cookie', '') + if cookie: + self.driver.add_cookie(json2dict(cookie)) + co = self.driver.get_cookie(json2dict(cookie).get('name', '')) + log.debug(f'cookie is add: {co}') + sleep(0.5) + + def check(self, step): + data = step['data'] + if not data: + data = step['expected'] + element = step['element'] + by = element['by'] + output = step['output'] + + location = '' + if by in ('title', 'current_url'): + getattr(self, by)(data, output) + else: + location = self.locat(element) + for key in data: + # 预期结果 + expected = data[key] + # 切片操作处理 + s = re.findall(r'\[.*?\]', key) + if s: + s = s[0] + key = key.replace(s, '') + + if key == 'text': + real = location.text + else: + real = location.get_attribute(key) + if s: + real = eval('real' + s) + + log.debug(f'DATA:{repr(expected)}') + log.debug('REAL:{repr(real)}') + try: + compare(expected, real) + except: + raise Exception( + f'check failure, DATA:{repr(expected)}, REAL:{repr(real)}') + + # 获取元素其他属性 + for key in output: + if output[key] == 'text': + v = location.text + vars.put({key: v}) + elif output[key] in ('text…', 'text...'): + if location.text.endswith('...'): + v = location.text[:-3] + vars.put({key: v}) + else: + v = location.text + vars.put({key: v}) + else: + v = location.get_attribute(output[key]) + vars.put({key: v}) + + return location + + + def notcheck(self, step): + try: + self.check(step) + raise Exception('check is success') + except: + pass + + def input(self, step): + data = step['data'] + location = self.locat(step['element']) + + if step['data'].get('清除文本', '') == '否' or step['data'].get('clear', '').lower() == 'no': + pass + else: + location.clear() + + for key in data: + if key.startswith('text'): + if isinstance(data[key], tuple): + location.send_keys(*data[key]) + elif location: + location.send_keys(data[key]) + sleep(0.5) + if key == 'word': # 逐字输入 + for d in data[key]: + location.send_keys(d) + sleep(0.3) + return location + + def click(self, step): + data = step['data'] + + location = '' + for element in step.get('elements'): + # location = locating(self.driver, element, 'CLICK') + location = self.locat(element, 'CLICK') + try: + location.click() + except ElementClickInterceptedException: # 如果元素为不可点击状态,则等待1秒,再重试一次 + sleep(1) + if data.get('mode'): + self.driver.execute_script( + "arguments[0].click();", location) + else: + location.click() + sleep(0.5) + + # 获取元素其他属性 + output = step['output'] + for key in output: + if output[key] == 'text': + vars.put({key: location.text}) + elif output[key] == 'tag_name': + vars.put({key: location.tag_name}) + elif output[key] in ('text…', 'text...'): + if location.text.endswith('...'): + vars.put({key: location.text[:-3]}) + else: + vars.put({key: location.text}) + else: + vars.put({key: location.get_attribute(output[key])}) + + return location + + def select(self, step): + data = step['data'] + + location = self.locat(step['element']) + for key in data: + if key.startswith('index'): + Select(location).select_by_index(data[key]) + elif key.startswith('value'): + Select(location).select_by_value(data[key]) + elif key.startswith('text') or key.startswith('visible_text'): + Select(location).select_by_visible_text(data[key]) + + def deselect(self, step): + data = step['data'] + location = self.locat(step['element']) + for key in data: + if key.startswith('all'): + Select(location).deselect_all() + elif key.startswith('index'): + Select(location).deselect_by_index(data[key]) + elif key.startswith('value'): + Select(location).deselect_by_value(data[key]) + elif key.startswith('text') or key.startswith('visible_text'): + Select(location).deselect_by_visible_text(data[key]) + + def hover(self, step): + actions = ActionChains(self.driver) + location = self.locat(step['element']) + actions.move_to_element(location) + actions.perform() + sleep(0.5) + + return location + + def context_click(self, step): + actions = ActionChains(self.driver) + location = self.locat(step['element']) + actions.context_click(location) + actions.perform() + sleep(0.5) + + return location + + def double_click(self, step): + actions = ActionChains(self.driver) + location = self.locat(step['element']) + actions.double_click(location) + actions.perform() + sleep(0.5) + + return location + + def drag_and_drop(self, step): + actions = ActionChains(self.driver) + elements = step['elements'] + source = self.locat(elements[0]) + target = self.locat(elements[1]) + actions.drag_and_drop(source, target) + actions.perform() + sleep(0.5) + + def swipe(self, step): + actions = ActionChains(self.driver) + data = step['data'] + location = self.locat(step['element']) + x = data.get('x', 0) + y = data.get('y', 0) + actions.drag_and_drop_by_offset(location, x, y) + actions.perform() + sleep(0.5) + + def script(self, step): + element = step['element'] + self.driver.execute_script(element) + + def message(self, step): + data = step['data'] + text = data.get('text', '') + value = step['element'] + + if value.lower() in ('确认', 'accept'): + self.driver.switch_to_alert().accept() + elif value.lower() in ('取消', '关闭', 'cancel', 'close'): + self.driver.switch_to_alert().dismiss() + elif value.lower() in ('输入', 'input'): + self.driver.switch_to_alert().send_keys(text) + self.driver.switch_to_alert().accept() + log.debug('switch frame: Alert') + self.w.frame = 'Alert' + + def upload(self, step): + import win32com.client + + data = step['data'] + location = self.locat(step['element']) + file_path = data.get('text', '') or data.get('file', '') + + location.click() + sleep(3) + shell = win32com.client.Dispatch("WScript.Shell") + shell.Sendkeys(file_path) + sleep(2) + shell.Sendkeys("{ENTER}") + sleep(2) + + def navigate(self, step): + element = step['element'] + + if element.lower() in ('刷新', 'refresh'): + self.driver.refresh() + elif element.lower() in ('前进', 'forward'): + self.driver.forward() + elif element.lower() in ('后退', 'back'): + self.driver.back() + + def scroll(self, step): + data = step['data'] + x = data.get('x') + y = data.get('y') or data.get('text') + + element = step['element'] + if element == '': + # if x is None: + # x = '0' + # self.driver.execute_script( + # f"windoself.w.scrollTo({x},{y})") + if y: + self.driver.execute_script( + f"document.documentElement.scrollTop={y}") + if x: + self.driver.execute_script( + f"document.documentElement.scrollLeft={x}") + else: + location = self.locat(element) + + if y: + self.driver.execute_script( + f"arguments[0].scrollTop={y}", location) + if x: + self.driver.execute_script( + f"arguments[0].scrollLeft={x}", location) diff --git a/sweet/modules/web/config.py b/sweet/modules/web/config.py new file mode 100644 index 0000000..9426928 --- /dev/null +++ b/sweet/modules/web/config.py @@ -0,0 +1,41 @@ +element_wait_timeout = 10 # 等待元素出现超时时间,单位:秒 +page_flash_timeout = 90 # 页面刷新超时时间,单位:秒 + + +keywords = { + '打开': 'OPEN', + 'OPEN': 'OPEN', + '检查': 'CHECK', + 'CHECK': 'CHECK', + '#检查': 'NOTCHECK', + '#CHECK': 'NOTCHECK', + '输入': 'INPUT', + 'INPUT': 'INPUT', + '点击': 'CLICK', + 'CLICK': 'CLICK', + '选择': 'SELECT', + 'SELECT': 'SELECT', + '取消选择': 'DESELECT', + 'DESELECT': 'DESELECT', + '移动到': 'HOVER', + '悬停': 'HOVER', + 'HOVER': 'HOVER', + '右击': 'CONTEXT_CLICK', + 'CONTEXT_CLICK': 'CONTEXT_CLICK', + '双击': 'DOUBLE_CLICK', + 'DOUBLE_CLICK': 'DOUBLE_CLICK', + '拖拽': 'DRAG_AND_DROP', + 'DRAG_AND_DROP': 'DRAG_AND_DROP', + '滑动': 'SWIPE', + 'SWIPE': 'SWIPE', + '脚本': 'SCRIPT', + 'SCRIPT': 'SCRIPT', + '对话框': 'MESSAGE', + 'MESSAGE': 'MESSAGE', + '上传文件': 'UPLOAD', + 'UPLOAD': 'UPLOAD', + '导航': 'NAVIGATE', + 'NAVIGATE': 'NAVIGATE', + '滚动条': 'SCROLL', + 'SCROLL': 'SCROLL' +} \ No newline at end of file diff --git a/sweet/modules/web/locator.py b/sweet/modules/web/locator.py new file mode 100644 index 0000000..00634a5 --- /dev/null +++ b/sweet/modules/web/locator.py @@ -0,0 +1,70 @@ +from time import sleep +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from sweet import log +from sweet.modules.web.config import element_wait_timeout + + +def locating(driver, element, action=''): + location = None + try: + el= element + value = el['value'] + except: + log.exception(f'locating the element:{element} is failure, this element is not define') + raise Exception(f'locating the element:{element} is failure, this element is not define') + + if not isinstance(el, dict): + raise Exception(f'locating the element:{element} is failure, this element is not define') + + wait = WebDriverWait(driver, element_wait_timeout) + + if el['by'].lower() in ('title', 'url', 'current_url'): + return None + else: + try: + location = wait.until(EC.presence_of_element_located( + (getattr(By, el['by'].upper()), value))) + except: + sleep(5) + try: + location = wait.until(EC.presence_of_element_located( + (getattr(By, el['by'].upper()), value))) + except : + raise Exception(f'locating the element:{element} is failure: timeout') + try: + if driver.name in ('chrome', 'safari'): + driver.execute_script( + "arguments[0].scrollIntoViewIfNeeded(true)", location) + else: + driver.execute_script( + "arguments[0].scrollIntoView(false)", location) + except: + pass + + try: + if action == 'CLICK': + location = wait.until(EC.element_to_be_clickable( + (getattr(By, el['by'].upper()), value))) + else: + location = wait.until(EC.visibility_of_element_located( + (getattr(By, el['by'].upper()), value))) + except: + pass + + return location + + +# def locations(elements): +# locations = {} +# for el in elements: +# locations[el] = location(el) +# return locations + + +# def locating_data(keys): +# data_location = {} +# for key in keys: +# data_location[key] = location(key) +# return data_location diff --git a/sweet/modules/web/window.py b/sweet/modules/web/window.py new file mode 100644 index 0000000..39cd175 --- /dev/null +++ b/sweet/modules/web/window.py @@ -0,0 +1,98 @@ +from sweet import log + + +class Windows: + + def __init__(self): + self.current_window = '' + self.windows = {} + self.frame = 0 + + def tab(self, name): + current_handle = self.driver.current_window_handle + if name in self.windows: + if current_handle != self.windows[name]: + self.driver.switch_to_window(self.windows[name]) + log.debug(f'switch the windows: #tab:{name}, handle:{repr(self.windows[name])}') + else: + log.debug(f'current windows: #tab:{name}, handle:{repr(self.windows[name])}') + + else: + all_handles = self.driver.window_handles + for handle in all_handles: + if handle not in self.windows.values(): + self.windows[name] = handle + if handle != current_handle: + self.driver.switch_to_window(handle) + log.debug(f'switch the windows: #tab:{name}, handle:{repr(handle)}') + else: + log.debug(f'current windows: #tab:{name}, handle:{repr(current_handle)}') + + self.clear() + + def clear(self): # 关闭未命名的 windows + current_handle = self.driver.current_window_handle + current_name = '' + for name in self.windows: + if current_handle == self.windows[name]: + current_name = name + + all_handles = self.driver.window_handles + for handle in all_handles: + # 未命名的 handle + if handle not in self.windows.values(): + # 切换到每一个窗口,并关闭它 + self.driver.switch_to_window(handle) + log.debug(f'switch the windows: #tab:, handle:{repr(handle)}') + self.driver.close() + log.debug(f'close the windows: #tab:, handle:{repr(handle)}') + self.driver.switch_to_window(current_handle) + log.debug(f'switch the windows: #tab:{current_name}, handle:{repr(current_handle)}') + + + def switch(self): + """ + docstring + """ + current_handle = self.driver.current_window_handle + use_handles = list(self.windows.values()) + [self.driver.current_window_handle] + all_handles = self.driver.window_handles + for handle in all_handles: + # 未命名的 handle + if handle not in self.windows.values(): + # 切换到新窗口 + self.driver.switch_to_window(handle) + log.debug(f'switch the windows: #tab:, handle:{repr(handle)}') + + + def switch_frame(self, frame): + if frame.strip(): + frame = [x.strip() for x in frame.split('|')] + if frame != self.frame: + if self.frame != 0: + self.driver.switch_to.default_content() + for f in frame: + log.debug(f'frame value: {repr(f)}') + if f.startswith('#'): + f = int(f[1:]) + elif '#' in f: + from sweet.testcase import elements_format + from sweet.modules.web.locator import locating_element + element = elements_format('public', f)[2] + f = locating_element(element) + log.debug(f' switch frame: {repr(f)}') + self.driver.switch_to.frame(f) + self.frame = frame + else: + if self.frame != 0: + self.driver.switch_to.default_content() + self.frame = 0 + + + def close(self): + all_handles = self.driver.window_handles + for handle in all_handles: + # 切换到每一个窗口,并关闭它 + self.driver.switch_to_window(handle) + self.driver.close() + log.debug(f'close th windows: {repr(handle)}') diff --git a/sweetest/Junit/Baidu-Report.xml b/sweetest/Junit/Baidu-Report.xml deleted file mode 100644 index f6a9075..0000000 --- a/sweetest/Junit/Baidu-Report.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/sweetest/data/Baidu-baidu.csv b/sweetest/data/Baidu-baidu.csv deleted file mode 100644 index 1e5f4b1..0000000 --- a/sweetest/data/Baidu-baidu.csv +++ /dev/null @@ -1,3 +0,0 @@ -_keywords,_title,flag -segmentfault,SegmentFault ˼,Y -,, diff --git a/sweetest/element/Baidu-Elements.xlsx b/sweetest/element/Baidu-Elements.xlsx deleted file mode 100644 index ddfe198e9aed8b125e6712c95840c286f910fb65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13212 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 diff --git a/sweetest/start.py b/sweetest/start.py deleted file mode 100644 index 195eebb..0000000 --- a/sweetest/start.py +++ /dev/null @@ -1,47 +0,0 @@ -from sweetest import Autotest -import sys - - -# 项目名称,和测试用例、页面元素表文件名称中的项目名称必须一致 -plan_name = 'Baidu' - -# 单 sheet 页面模式 -sheet_name = 'baidu' - -# sheet 页面匹配模式,仅支持结尾带* -#sheet_name = 'TestCase*' - -# sheet 页面列表模式 -#sheet_name = ['TestCase', 'test'] - -# 环境配置信息 -# Chrome -desired_caps = {'platformName': 'Desktop', 'browserName': 'Chrome'} -# headless -#desired_caps = {'platformName': 'Desktop', 'browserName': 'Chrome', 'headless': True} -# 设置全局截图 -#desired_caps = {'platformName': 'Desktop', 'browserName': 'Chrome', 'snapshot': True} -# 指定 driver 路径 -#desired_caps = {'platformName': 'Desktop', 'browserName': 'Chrome', 'executable_path': 'D:\drivers\chromedriver.exe'} -server_url = '' - -# Windows GUI -# notepad start -#desired_caps = {'platformName': 'Windows', 'cmd_line': r'notepad.exe', 'timeout': 5, 'backend': 'uia'} -# notepad connect -#desired_caps = {'platformName': 'Windows', 'path': r'C:\Program Files\Microsoft Office\Office16\EXCEL.EXE'} - -# 初始化自动化实例 -sweet = Autotest(plan_name, sheet_name, desired_caps, server_url) - -# 按条件执行,支持筛选的属性有:'id', 'title', 'designer', 'priority' -# sweet.fliter(priority='H') - -# 执行自动化测试 -sweet.plan() - -# Web 测试报告 -sweet.report(r'D:\report') - -# 如果是集成到 CI/CD,则给出退出码 -#sys.exit(sweet.code) diff --git a/sweetest/sweetest/__init__.py b/sweetest/sweetest/__init__.py deleted file mode 100644 index aaee9a4..0000000 --- a/sweetest/sweetest/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -import sys -import shutil -import zipfile -from pathlib import Path -from sweetest.autotest import Autotest -from sweetest.report import reporter - - -def extract(zfile, path): - f = zipfile.ZipFile(zfile, 'r') - for file in f.namelist(): - f.extract(file, path) - - -def sweetest(): - sweetest_dir = Path(__file__).resolve().parents[0] - example_dir = sweetest_dir / 'example' / 'sweetest_example.zip' - extract(str(example_dir), Path.cwd()) - - print('\n文档: https://sweeter.io\n公众号:喜文测试\nQ Q 群:941761748 (验证码:python)注意首字母小写') - print('\n\n生成 sweetest example 成功\n快速体验,请输入如下命令(进入示例目录,启动运行脚本):\n\ncd sweetest_example\npython start.py') - - -def report(): - sweetest_dir = Path(__file__).resolve().parents[0] - report_dir = sweetest_dir / 'example' / 'report.zip' - extract(str(report_dir), Path.cwd()) \ No newline at end of file diff --git a/sweetest/sweetest/autotest.py b/sweetest/sweetest/autotest.py deleted file mode 100644 index e84a194..0000000 --- a/sweetest/sweetest/autotest.py +++ /dev/null @@ -1,146 +0,0 @@ - -from pathlib import Path -import sys -import json -from sweetest.data import testsuite_format, testsuite2data, testsuite2report -from sweetest.parse import parse -from sweetest.elements import e -from sweetest.globals import g -from sweetest.windows import w -from sweetest.testsuite import TestSuite -from sweetest.utility import Excel, get_record, mkdir -from sweetest.log import logger, set_log -from sweetest.junit import JUnit -from sweetest.report import summary, markdown -from sweetest.config import _testcase, _elements, _report - - -class Autotest: - def __init__(self, file_name, sheet_name, desired_caps={}, server_url=''): - if desired_caps: - self.desired_caps = desired_caps - else: - self.desired_caps = { - 'platformName': 'Desktop', 'browserName': 'Chrome'} - self.server_url = server_url - self.conditions = {} - g.plan_name = file_name.split('-')[0] - g.init(self.desired_caps, self.server_url) - - plan_path = Path('snapshot') / g.plan_name - task_path = plan_path / g.start_time[1:] - - for p in ('JUnit', 'report', 'snapshot', plan_path, task_path, 'report/' + g.plan_name): - mkdir(p) - - g.plan_data['log'] = set_log(logger, task_path) - - self.testcase_file = str( - Path('testcase') / (file_name + '-' + _testcase + '.xlsx')) - self.elements_file = str( - Path('element') / (g.plan_name + '-' + _elements + '.xlsx')) - self.report_xml = str( - Path('JUnit') / (file_name + '-' + _report + g.start_time + '.xml')) - self.testcase_workbook = Excel(self.testcase_file, 'r') - self.sheet_names = self.testcase_workbook.get_sheet(sheet_name) - self.report_excel = str(Path( - 'report') / g.plan_name / (file_name + '-' + _report + g.start_time + '.xlsx')) - self.report_workbook = Excel(self.report_excel, 'w') - - self.report_data = {} # 测试报告详细数据 - - def fliter(self, **kwargs): - # 筛选要执行的测试用例 - self.conditions = kwargs - - def plan(self): - self.code = 0 # 返回码 - # 1.解析配置文件 - try: - e.get_elements(self.elements_file) - except: - logger.exception('*** Parse config file failure ***') - self.code = -1 - sys.exit(self.code) - - self.junit = JUnit() - self.junit_suite = {} - - # 2.逐个执行测试套件 - for sheet_name in self.sheet_names: - g.sheet_name = sheet_name - # xml 测试报告初始化 - self.junit_suite[sheet_name] = self.junit.create_suite( - g.plan_name, sheet_name) - self.junit_suite[sheet_name].start() - - self.run(sheet_name) - - self.plan_data = g.plan_end() - self.testsuite_data = g.testsuite_data - - summary_data = summary( - self.plan_data, self.testsuite_data, self.report_data, {}) - self.report_workbook.write(summary_data, '_Summary_') - self.report_workbook.close() - - with open(self.report_xml, 'w', encoding='utf-8') as f: - self.junit.write(f) - - def run(self, sheet_name): - # 1.从 Excel 获取测试用例集 - try: - data = self.testcase_workbook.read(sheet_name) - testsuite = testsuite_format(data) - # logger.info('Testsuite imported from Excel:\n' + - # json.dumps(testsuite, ensure_ascii=False, indent=4)) - logger.info('From Excel import testsuite success') - except: - logger.exception('*** From Excel import testsuite failure ***') - self.code = -1 - sys.exit(self.code) - - # 2.初始化全局对象 - try: - g.set_driver() - # 如果测试数据文件存在,则从该文件里读取数据,赋值到全局变量列表里 - data_file = Path('data') / (g.plan_name + - '-' + sheet_name + '.csv') - if data_file.is_file(): - g.test_data = get_record(str(data_file)) - w.init() - except: - logger.exception('*** Init global object failure ***') - self.code = -1 - sys.exit(self.code) - - # 3.解析测试用例集 - try: - parse(testsuite) - logger.debug('testsuite has been parsed:\n' + str(testsuite)) - except: - logger.exception('*** Parse testsuite failure ***') - self.code = -1 - sys.exit(self.code) - - # 4.执行测试套件 - g.ts = TestSuite(testsuite, sheet_name, - self.junit_suite[sheet_name], self.conditions) - g.ts.run() - - # 5.判断测试结果 - if self.junit_suite[sheet_name].high_errors + self.junit_suite[sheet_name].medium_errors + \ - self.junit_suite[sheet_name].high_failures + self.junit_suite[sheet_name].medium_failures: - self.code = -1 - - # 6.保存测试结果 - try: - data = testsuite2data(testsuite) - self.report_workbook.write(data, sheet_name) - self.report_data[sheet_name] = testsuite2report(testsuite) - except: - logger.exception('*** Save the report is failure ***') - - - def report(self, md_path): - markdown(self.plan_data, self.testsuite_data, self.report_data, md_path) \ No newline at end of file diff --git a/sweetest/sweetest/config.py b/sweetest/sweetest/config.py deleted file mode 100644 index 7cb819a..0000000 --- a/sweetest/sweetest/config.py +++ /dev/null @@ -1,210 +0,0 @@ - -web_keywords = { - '打开': 'OPEN', - 'OPEN': 'OPEN', - '检查': 'CHECK', - 'CHECK': 'CHECK', - '#检查': 'NOTCHECK', - '#CHECK': 'NOTCHECK', - '输入': 'INPUT', - 'INPUT': 'INPUT', - '点击': 'CLICK', - 'CLICK': 'CLICK', - '选择': 'SELECT', - 'SELECT': 'SELECT', - '取消选择': 'DESELECT', - 'DESELECT': 'DESELECT', - '移动到': 'HOVER', - '悬停': 'HOVER', - 'HOVER': 'HOVER', - '右击': 'CONTEXT_CLICK', - 'CONTEXT_CLICK': 'CONTEXT_CLICK', - '双击': 'DOUBLE_CLICK', - 'DOUBLE_CLICK': 'DOUBLE_CLICK', - '拖拽': 'DRAG_AND_DROP', - 'DRAG_AND_DROP': 'DRAG_AND_DROP', - '滑动': 'SWIPE', - 'SWIPE': 'SWIPE', - '脚本': 'SCRIPT', - 'SCRIPT': 'SCRIPT', - '对话框': 'MESSAGE', - 'MESSAGE': 'MESSAGE', - '上传文件': 'UPLOAD', - 'UPLOAD': 'UPLOAD', - '导航': 'NAVIGATE', - 'NAVIGATE': 'NAVIGATE', - '滚动条': 'SCROLL', - 'SCROLL': 'SCROLL' -} - -common_keywords = { - '执行': 'EXECUTE', - 'EXECUTE': 'EXECUTE', - 'SQL': 'SQL' -} - -http_keywords = { - 'GET': 'GET', - 'POST': 'POST', - 'PUT': 'PUT', - 'PATCH': 'PATCH', - 'DELETE': 'DELETE', - 'OPTIONS': 'OPTIONS' -} - -mobile_keywords = { - '检查': 'CHECK', - 'CHECK': 'CHECK', - '#检查': 'NOTCHECK', - '#CHECK': 'NOTCHECK', - '输入': 'INPUT', - 'INPUT': 'INPUT', - '填写': 'SET_VALUE', - 'SET_VALUE': 'SET_VALUE', - '点击': 'CLICK', - 'CLICK': 'CLICK', - '轻点': 'TAP', - 'TAP': 'TAP', - '按键码': 'PRESS_KEYCODE', # Android 特有,常见代码 HOME:3, 菜单键:82,返回键:4 - 'PRESS_KEYCODE': 'PRESS_KEYCODE', - '滑动': 'SWIPE', - 'SWIPE': 'SWIPE', - '划线': 'LINE', - 'LINE': 'LINE', - '划线解锁': 'LINE_UNLOCK', - 'LINE_UNLOCK': 'LINE_UNLOCK', - '摇杆': 'ROCKER', - 'ROCKER': 'ROCKER', - '滚动': 'SCROLL', # iOS 专用 - 'SCROLL': 'SCROLL', - '拖拽': 'DRAG_AND_DROP', - 'DRAG_AND_DROP': 'DRAG_AND_DROP', - '摇晃': 'SHAKE', # 貌似 Android 上不可用 - 'SHAKE': 'SHAKE', - '快速滑动': 'FLICK', - 'FLICK': 'FLICK', - '滑动元素': 'FLICK_ELEMENT', - 'FLICK_ELEMENT': 'FLICK_ELEMENT', - '长按': 'LONG_PRESS', - 'LONG_PRESS': 'LONG_PRESS', - '缩小': 'PINCH', - 'PINCH': 'PINCH', - '放大': 'ZOOM', - 'ZOOM': 'ZOOM', - '隐藏键盘': 'HIDE_KEYBOARD', # iOS 专用 - 'HIDE_KEYBOARD': 'HIDE_KEYBOARD', - '命名标签页': 'TAB_NAME', - 'TAB_NAME': 'TAB_NAME', - '重启': 'LAUNCH_APP', - 'LAUNCH_APP': 'LAUNCH_APP', - '锁屏状态': 'IS_LOCKED', - 'IS_LOCKED': 'IS_LOCKED', - '锁屏': 'LOCK', - 'LOCK': 'LOCK', - '解锁': 'UNLOCK', - 'UNLOCK': 'UNLOCK', - '启动页面': 'ACTIVITY', - 'ACTIVITY': 'ACTIVITY' -} - -windows_keywords = { - '检查': 'CHECK', - 'CHECK': 'CHECK', - '菜单': 'MENU_SELECT', - 'MENU_SELECT': 'MENU_SELECT', - '选择': 'SELECT', - 'SELECT': 'SELECT', - '点击': 'CLICK', - 'CLICK': 'CLICK', - '双击': 'DOUBLE_CLICK', - 'DOUBLE_CLICK': 'DOUBLE_CLICK', - '勾选': 'CHECK_OFF', - 'CHECK_OFF': 'CHECK_OFF', - '输入': 'INPUT', - 'INPUT': 'INPUT', - '填写': 'SET_TEXT', - 'SET_TEXT': 'SET_TEXT', - '按键': 'SEND_KEYS', - 'SEND_KEYS': 'SEND_KEYS', - '窗口': 'WINDOW', - 'WINDOW': 'WINDOW' -} - -files_keywords = { -'复制': 'COPY', -'COPY': 'COPY', -'移动': 'MOVE', -'MOVE': 'MOVE', -'删除文件': 'REMOVE', -'REMOVE': 'REMOVE', -'删除目录': 'RMDIR', -'RMDIR': 'RMDIR', -'创建目录': 'MKDIR', -'MKDIR': 'MKDIR', -'路径存在': 'EXISTS', -'EXISTS': 'EXISTS', -'路径不存在': 'NOT_EXISTS', -'NOT_EXISTS': 'NOT_EXISTS', -'是文件': 'IS_FILE', -'IS_FILE': 'IS_FILE', -'是目录': 'IS_DIR', -'IS_DIR': 'IS_DIR', -'不是文件': 'NOT_FILE', -'NOT_FILE': 'NOT_FILE', -'不是目录': 'NOT_DIR', -'NOT_DIR': 'NOT_DIR', -'命令行': 'COMMAND', -'COMMAND': 'COMMAND', -'SHELL': 'SHELL', -'CMD': 'CMD' -} - -all_keywords = {} -for keywords in (web_keywords, common_keywords, http_keywords, mobile_keywords, windows_keywords, files_keywords): - all_keywords = dict(all_keywords, **keywords) - - -zh_en = { - '等待时间': '#wait_time', - '标签页名': '#tab_name', - '打开方式': '#open_type', - '#ScreenShot': '#screen_shot', - '#ElementShot': '#element_shot', - '循环结束条件': '#break' -} - -# 文件名后缀 -_testcase = 'TestCase' # '测试用例' -_elements = 'Elements' # '页面元素表' -_report = 'Report' # '测试结果' - -# 特殊符号的转换别名 -comma_lower = '#$%^&' -comma_upper = '&^%$#' -equals = '%^$#&' -vertical = '&$&*^&A@' - -# header = ['用例编号', '用例标题', '前置条件', '测试步骤', '操作', '页面', '元素', -#'测试数据', '预期结果', '输出数据', '优先级', '设计者', '自动化标记', '测试结果', '备注'] - -header = { - '用例编号': 'id', - '用例标题': 'title', - '前置条件': 'condition', - '测试步骤': 'step', - '操作': 'keyword', - '页面': 'page', - '元素': 'element', - '测试数据': 'data', - '预期结果': 'expected', - '输出数据': 'output', - '优先级': 'priority', - '设计者': 'designer', - '自动化标记': 'flag', - '步骤结果': 'score', - '用例结果': 'result', - '备注': 'remark' -} - -element_wait_timeout = 10 # 等待元素出现超时时间,单位:秒 -page_flash_timeout = 90 # 页面刷新超时时间,单位:秒 diff --git a/sweetest/sweetest/data.py b/sweetest/sweetest/data.py deleted file mode 100644 index 3af0a99..0000000 --- a/sweetest/sweetest/data.py +++ /dev/null @@ -1,115 +0,0 @@ -import xlrd -from sweetest.utility import Excel, data2dict -from sweetest.config import header -from sweetest.globals import g - - -def testsuite_format(data): - ''' - 将元素为 dict 的 list,处理为 testcase 的 list - testcase 的格式: - { - 'id': 'Login_001', #用例编号 - 'title': 'Login OK', #用例标题 - 'condition': '', #前置条件 - 'designer': 'Leo', #设计者 - 'flag': '', #自动化标记 - 'result': '', #用例结果 - 'remark': '', #备注 - 'steps': - [ - { - 'no': 1, #测试步骤 - 'keyword': '输入', - 'page': '产品管系统登录页', - 'element': '用户名', - 'data': 'user1', #测试数据 - 'output': '', #输出数据 - 'score': '', #测试结果 - 'remark': '' #备注 - }, - {……} - …… - ] - } - ''' - testsuite = [] - testcase = {'testsuite': '', 'no': 0} - data = data2dict(data) - - for d in data: - # 如果用例编号不为空,则为新的用例 - if d['id'].strip(): - # 如果 testcase[id] 非空,则添加到 testsuite 里,并重新初始化 testcase - if testcase.get('id'): - testsuite.append(testcase) - testcase = {'testsuite': '', 'no': 0} - for key in ('id', 'title', 'condition', 'designer', 'flag', 'result', 'remark'): - testcase[key] = d[key] - if '#' in d['id']: - testcase['set'] = d['id'].split('#')[0] - testcase['flag'] = 'N' - testcase['priority'] = d['priority'] if d['priority'] else 'M' - testcase['steps'] = [] - # 如果测试步骤不为空,则为有效步骤,否则用例解析结束 - no = str(d['step']).strip() - if no: - step = {} - step['control'] = '' - if no[0] in ('^', '>', '<', '#'): - step['control'] = no[0] - step['no'] = no - else: - step['no'] = str(int(d['step'])) - for key in ('keyword', 'page', 'element', 'data', 'expected', 'output', 'score', 'remark'): - step[key] = d.get(key, '') - - # 仅作为测试结果输出时,保持原样 - step['_keyword'] = d['keyword'] - step['_element'] = d['element'] - step['_data'] = d['data'] - step['vdata'] = d.get('data', '') - step['_expected'] = d.get('expected', '') - step['_output'] = d.get('output', '') - testcase['steps'].append(step) - if testcase: - testsuite.append(testcase) - return testsuite - - -def testsuite_from_excel(file_name, sheet_name): - d = Excel(file_name) - return testsuite_format(data2dict(d.read(sheet_name))) - - -def testsuite2data(data): - # result = [list(header.values())] - result = [[g.header_custom[key.lower()] for key in header.values()]] - for d in data: - s = d['steps'][0] # 第一步和用例标题同一行 - testcase = [d['id'], d['title'], d['condition'], s['no'], s['_keyword'], s['page'], s['_element'], - s['vdata'], s['_output'], d['priority'], d['designer'], d['flag'], s['score'], d['result'], s['remark']] - if g.header_custom['expected']: - testcase.insert(8, s['_expected']) - result.append(testcase) - for s in d['steps'][1:]: - step = ['', '', '', s['no'], s['_keyword'], s['page'], s['_element'], - s['vdata'], s['_output'], '', '', '', s['score'], '', s['remark']] - if g.header_custom['expected']: - step.insert(8, s['_expected']) - result.append(step) - return result - - -def testsuite2report(data): - report = [] - for case in data: - if case['condition'] in ('BASE', 'SETUP') or case['flag'] != 'N': - for step in case['steps']: - step['keyword'] = step.pop('_keyword') - step['element'] = step.pop('_element') - step['data'] = str(step.pop('vdata')) - step['expected'] = step.pop('_expected') - step['output'] = step.pop('_output') - report.append(case) - return report diff --git a/sweetest/sweetest/database.py b/sweetest/sweetest/database.py deleted file mode 100644 index f86f498..0000000 --- a/sweetest/sweetest/database.py +++ /dev/null @@ -1,105 +0,0 @@ -from sweetest.log import logger - - -class DB: - - def __init__(self, arg): - self.connect = '' - self.cursor = '' - self.db = '' - - try: - if arg['type'].lower() == 'mongodb': - import pymongo - host = arg.pop('host') if arg.get('host') else 'localhost:27017' - host = host.split(',') if ',' in host else host - port = int(arg.pop('port')) if arg.get('port') else 27017 - if arg.get('user'): - arg['username'] = arg.pop('user') - # username = arg['user'] if arg.get('user') else '' - # password = arg['password'] if arg.get('password') else '' - # self.connect = pymongo.MongoClient('mongodb://' + username + password + arg['host'] + ':' + arg['port'] + '/') - self.connect = pymongo.MongoClient(host=host, port=port, **arg) - self.connect.server_info() - self.db = self.connect[arg['dbname']] - - return - - if arg['type'].lower() == 'mysql': - import pymysql as mysql - self.connect = mysql.connect( - host=arg['host'], port=int(arg['port']), user=arg['user'], password=arg['password'], database=arg['dbname'], charset=arg.get('charset', 'utf8')) - self.cursor = self.connect.cursor() - sql = 'select version()' - - elif arg['type'].lower() == 'oracle': - import os - import cx_Oracle as oracle - # Oracle查询出的数据,中文输出问题解决 - os.environ['NLS_LANG'] = 'SIMPLIFIED CHINESE_CHINA.UTF8' - self.connect = oracle.connect( - arg['user'] + '/' + arg['password'] + '@' + arg['host'] + '/' + arg['sid']) - self.cursor = self.connect.cursor() - sql = 'select * from v$version' - elif arg['type'].lower() == 'sqlserver': - import pymssql as sqlserver - self.connect = sqlserver.connect( - host=arg['host'], port=arg['port'], user=arg['user'], password=arg['password'], database=arg['dbname'], charset=arg.get('charset', 'utf8')) - self.cursor = self.connect.cursor() - sql = 'select @@version' - - self.cursor.execute(sql) - self.cursor.fetchone() - - except: - logger.exception('*** %s connect is failure ***' % arg['type']) - raise - - def fetchone(self, sql): - try: - self.cursor.execute(sql) - data = self.cursor.fetchone() - self.connect.commit() - return data - except: - logger.exception('*** Fetchone failure ***') - raise - - def fetchall(self, sql): - try: - self.cursor.execute(sql) - data = self.cursor.fetchall() - self.connect.commit() - return data - except: - logger.exception('*** Fetchall failure ***') - raise - - def execute(self, sql): - try: - self.cursor.execute(sql) - self.connect.commit() - except: - logger.exception('*** Execute failure ***') - raise - - - def mongo(self, collection, sql): - try: - cmd = 'self.db[\'' + collection + '\'].' + sql - result = eval(cmd) - if sql.startswith('find_one'): - return result - elif sql.startswith('find'): - for d in result: - return d - elif 'count' in sql: - return {'count': result} - else: - return {} - except: - logger.exception('*** Execute failure ***') - raise - - def __del__(self): - self.connect.close() diff --git a/sweetest/sweetest/elements.py b/sweetest/sweetest/elements.py deleted file mode 100644 index 7166556..0000000 --- a/sweetest/sweetest/elements.py +++ /dev/null @@ -1,81 +0,0 @@ -from sweetest.utility import Excel, data2dict, replace -from sweetest.log import logger - - -def elements_format(data): - elements = {} - page = '' - custom = '' - for d in data: - if d['page'].strip(): - page = d['page'] - custom = '' - else: - d['page'] = page - - if d.get('custom', '').strip(): - custom = d['custom'] - else: - d['custom'] = custom - - elements[d['page'] + '-' + d['element']] = d - return elements - - -class Elements: - def __init__(self): - pass - - def env(self): - pass - - def get_elements(self, elements_file): - d = Excel(elements_file) - self.elements = elements_format(data2dict(d.read('elements'))) - - def have(self, page, element): - ele = element.split('#') - - if len(ele) >= 2: - _el = ele[0] + '#' - else: - _el = element - # 如果有<>,则不判断了 - if '<' in _el: - return '', '通用' + '-' + element - # 在元素定位表中查询 - elem = page + '-' + _el - if self.elements.get(elem, ''): - return self.elements[elem]['custom'], page + '-' + element - else: - # 查不到就在通用里查,还是查不到,可能是不在 element.xlsx 中定义的元素 - elem = '通用' + '-' + _el - if self.elements.get(elem, ''): - return self.elements[elem]['custom'], '通用' + '-' + element - else: - logger.info('Page:%s element:%s' % (page, element)) - return '', element - - def get(self, element, flag=False): - ele = element.split('#') - # #号后面的值,即用户输入的变量 - _v = [] - # 支持多个变量替代,但是顺序要对应 - if len(ele) >= 2: - _el = ele[0] + '#' - _v = ele[1:] - else: - _el = element - el = self.elements.get(_el, '') - if not el: - if flag: - return _el, '' - return _el, element.split('#', 1)[-1] - value = el['value'] - for v in _v: - v = '#' if v=='^' else v # 当 value 中的 # 无需替换时,用例中的元素使用 ^ 表示 - value = value.replace('#', v, 1) - return el, replace(value) - - -e = Elements() diff --git a/sweetest/sweetest/globals.py b/sweetest/sweetest/globals.py deleted file mode 100644 index baf49ea..0000000 --- a/sweetest/sweetest/globals.py +++ /dev/null @@ -1,156 +0,0 @@ -import time -from selenium import webdriver -from sweetest.config import element_wait_timeout, page_flash_timeout - - -def now(): - t = time.time() - return time.strftime("@%Y%m%d_%H%M%S", time.localtime(t)) - - -def timestamp(): - # js 格式的时间戳 - return int(time.time() * 1000) - - -class Global: - def __init__(self): - self.start_time = now() - self.start_timestamp = timestamp() - self.plan_name = '' - self.sheet_name = '' - self.plan_data = {} - self.testsuite_data = {} - self.no = 1 - self.driver = '' - self.snippet = {} - self.caseset = {} - - - def init(self, desired_caps, server_url): - self.desired_caps = desired_caps - self.server_url = server_url - self.platform = desired_caps.get('platformName', '') - self.browserName = desired_caps.get('browserName', '') - self.headless = desired_caps.pop('headless', False) - self.snapshot = desired_caps.pop('snapshot', False) - self.executable_path = desired_caps.pop('executable_path', False) - - - def set_driver(self): - self.test_data = {'_last_': False} - self.var = {} - self.caseset = {} - self.casesets = [] # 用例组合执行容器 - self.current_page = '通用' - self.db = {} - self.http = {} - self.windows = {} - self.baseurl = {} - self.action = {} - self.wait_times = 0 - - if self.platform.lower() == 'desktop': - if self.browserName.lower() == 'ie': - #capabilities = webdriver.DesiredCapabilities().INTERNETEXPLORER - #capabilities['acceptInsecureCerts'] = True - if self.executable_path: - self.driver = webdriver.Ie(executable_path=self.executable_path) - else: - self.driver = webdriver.Ie() - elif self.browserName.lower() == 'firefox': - profile = webdriver.FirefoxProfile() - profile.accept_untrusted_certs = True - - options = webdriver.FirefoxOptions() - # 如果配置了 headless 模式 - if self.headless: - options.set_headless() - # options.add_argument('-headless') - options.add_argument('--disable-gpu') - options.add_argument("--no-sandbox") - options.add_argument('window-size=1920x1080') - - if self.executable_path: - self.driver = webdriver.Firefox( - firefox_profile=profile, firefox_options=options, executable_path=self.executable_path) - else: - self.driver = webdriver.Firefox( - firefox_profile=profile, firefox_options=options) - self.driver.maximize_window() - elif self.browserName.lower() == 'chrome': - options = webdriver.ChromeOptions() - - # 如果配置了 headless 模式 - if self.headless: - options.add_argument('--headless') - options.add_argument('--disable-gpu') - options.add_argument("--no-sandbox") - options.add_argument('window-size=1920x1080') - - options.add_argument("--start-maximized") - options.add_argument('--ignore-certificate-errors') - # 指定浏览器分辨率,当"--start-maximized"无效时使用 - # options.add_argument('window-size=1920x1080') - prefs = {} - prefs["credentials_enable_service"] = False - prefs["profile.password_manager_enabled"] = False - options.add_experimental_option("prefs", prefs) - options.add_argument('disable-infobars') - options.add_experimental_option("excludeSwitches", ['load-extension', 'enable-automation']) - if self.executable_path: - self.driver = webdriver.Chrome( - options=options, executable_path=self.executable_path) - else: - self.driver = webdriver.Chrome(options=options) - else: - raise Exception( - 'Error: this browser is not supported or mistake name:%s' % self.browserName) - # 等待元素超时时间 - self.driver.implicitly_wait(element_wait_timeout) # seconds - # 页面刷新超时时间 - self.driver.set_page_load_timeout(page_flash_timeout) # seconds - - elif self.platform.lower() == 'ios': - from appium import webdriver as appdriver - if not self.driver: - self.driver = appdriver.Remote(self.server_url, self.desired_caps) - - elif self.platform.lower() == 'android': - from appium import webdriver as appdriver - if not self.driver: - self.driver = appdriver.Remote(self.server_url, self.desired_caps) - - elif self.platform.lower() == 'windows': - from pywinauto.application import Application - from sweetest.keywords.windows import Windows - self.desired_caps.pop('platformName') - backend = self.desired_caps.pop('backend', 'win32') - _path = '' - if self.desired_caps.get('#path'): - _path = self.desired_caps.pop('#path') - _backend = self.desired_caps.pop('#backend') - - if self.desired_caps.get('cmd_line'): - app = Application(backend).start(**self.desired_caps) - elif self.desired_caps.get('path'): - app = Application(backend).connect(**self.desired_caps) - else: - raise Exception('Error: Windows GUI start/connect args error') - self.windows['default'] = Windows(app) - - if _path: - _app = Application(_backend).connect(path=_path) - self.windows['#'] = Windows(_app) - - - def plan_end(self): - self.plan_data['plan'] = self.plan_name - #self.plan_data['task'] = self.start_timestamp - self.plan_data['start_timestamp'] = self.start_timestamp - self.plan_data['end_timestamp'] = int(time.time() * 1000) - - return self.plan_data - - -g = Global() diff --git a/sweetest/sweetest/junit.py b/sweetest/sweetest/junit.py deleted file mode 100644 index 9d35bdf..0000000 --- a/sweetest/sweetest/junit.py +++ /dev/null @@ -1,184 +0,0 @@ -from datetime import datetime -from xml.dom.minidom import Document - - -class JUnit(): - - def __init__(self): - self.testsuites = [] - - def create_suite(self, name, hostname="localhost"): - suite = TestSuite(name, hostname) - self.testsuites.append(suite) - return suite - - def finish(self): - for suite in self.testsuites: - if suite.open == True: - suite.finish() - - def write(self, file): - self.finish() - doc = Document() - root = doc.createElement("testsuites") - doc.appendChild(root) - for suite in self.testsuites: - root.appendChild(suite.to_xml(doc)) - file.write(doc.toprettyxml()) - - -class TestSuite(): - def __init__(self, name, hostname): - self.properties = [] - self.name = name - self.hostname = hostname - self.open = False - self.cases = [] - self.systemout = None - self.systemerr = None - - def start(self): - self.open = True - self.time = datetime.now() - self.timestamp = datetime.isoformat(self.time) - return self - - def create_case(self, name, classname=""): - if self.open: - case = TestCase(name, classname) - self.cases.append(case) - return case - else: - raise Exception( - "This test suite cannot be modified in its current state") - - def append_property(self, name, value): - self.properties.append([name, value]) - - def finish(self, output=None, error=None): - if self.open == True: - self.open = False - # set the number of test cases, error cases, failed cases, and the amount of time taken in seconds. - td = datetime.now() - self.time - self.time = float( - (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6)) / 10**6 - self.errors = 0 - self.high_errors = 0 - self.medium_errors = 0 - self.low_errors = 0 - self.failures = 0 - self.high_failures = 0 - self.medium_failures = 0 - self.low_failures = 0 - self.skipped = 0 - self.disabled = 0 - for case in self.cases: - if case.state == None: - case.error( - "XmlUnit Finished", "The test was forced to finish since the suite was finished.") - status = case.state.lower() - if status == "failure": - self.failures += 1 - if case.priority == 'H': - self.high_failures += 1 - if case.priority == 'M': - self.medium_failures += 1 - if case.priority == 'L': - self.low_failures += 1 - elif status == "error": - self.errors += 1 - if case.priority == 'H': - self.high_errors += 1 - if case.priority == 'M': - self.medium_errors += 1 - if case.priority == 'L': - self.low_errors += 1 - elif status == "skipped": - self.skipped += 1 - elif status == "blocked": - self.disabled += 1 - else: - pass - self.tests = len(self.cases) - self.output = output - self.error = error - return None - else: - raise Exception("This test suite is already finished.") - - def to_xml(self, doc): - node = doc.createElement("testsuite") - node.setAttribute("name", self.name) - node.setAttribute("hostname", self.hostname) - node.setAttribute("timestamp", self.timestamp) - node.setAttribute("tests", "%s" % self.tests) - node.setAttribute("failures", "%s" % self.failures) - node.setAttribute("failures_detail", "H:%s M:%s L:%s" % ( - self.high_failures, self.medium_failures, self.low_failures)) - node.setAttribute("errors", "%s" % (self.errors + self.skipped + self.disabled)) - node.setAttribute("errors_detail", "H:%s M:%s L:%s" % ( - self.high_errors, self.medium_errors, self.low_errors)) - node.setAttribute("time", "%s" % self.time) - node.setAttribute("skipped", "%s" % self.skipped) - node.setAttribute("disabled", "%s" % self.disabled) - for case in self.cases: - node.appendChild(case.to_xml(doc)) - - return node - - -class TestCase(): - - def __init__(self, name, classname): - self.state = None - self.name = name - self.classname = classname - self.priority = 'M' - return None - - def start(self): - self.time = datetime.now() - return self - - def custom(self, state, type, message): - if self.state != None: - raise Exception("This test case is already finished.") - self.state = state - self.message = message - self.type = type - td = datetime.now() - self.time - self.time = float( - (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6)) / 10**6 - - def fail(self, type, message): - self.custom("failure", type, message) - - def skip(self, type, message): - self.custom("skipped", type, message) - - def error(self, type, message): - self.custom("error", type, message) - - def block(self, type, message): - self.custom("skipped", type, message) # Azure Devops 不识别 blocked,改为 skipped - - def succeed(self): - if self.state != None: - raise Exception("This test case is already finished.") - self.state = "success" - td = datetime.now() - self.time - self.time = float( - (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6)) / 10**6 - - def to_xml(self, doc): - node = doc.createElement("testcase") - node.setAttribute("name", self.name) - node.setAttribute("classname", self.classname) - node.setAttribute("priority", self.priority) - node.setAttribute("time", "%s" % self.time) - if self.state != "success": - subnode = doc.createElement(self.state) - subnode.setAttribute("type", self.type) - subnode.setAttribute("message", self.message) - node.appendChild(subnode) - return node diff --git a/sweetest/sweetest/keywords/__init__.py b/sweetest/sweetest/keywords/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sweetest/sweetest/keywords/common.py b/sweetest/sweetest/keywords/common.py deleted file mode 100644 index aa46bfe..0000000 --- a/sweetest/sweetest/keywords/common.py +++ /dev/null @@ -1,200 +0,0 @@ -from copy import deepcopy -from sweetest.globals import g -from sweetest.elements import e -from sweetest.log import logger -from sweetest.parse import data_format -from sweetest.database import DB -from sweetest.utility import replace_dict, compare -from injson import check -from sweetest.utility import json2dict - - -def execute(step): - # 先处理循环结束条件 - condition = '' - for k in ('循环结束条件', 'condition', '#break'): - if step['data'].get(k): - condition = step['data'].get(k) - del step['data'][k] - if condition.lower() in ('成功', 'success'): - condition = 'success' - elif condition.lower() in ('失败', 'failure'): - condition = 'failure' - - # 执行赋值操作 - data = step['data'] - for k, v in data.items(): - g.var[k] = v - - from sweetest.testcase import TestCase - element = step['element'] - times = 1 - _element = element.split('*') - - # snippet 执行失败是否退出标志 - flag = True - if element[-1] == '*': - flag = False - - # 循环次数为 N 标志 - n_flag = False - if len(_element) >= 2: - element = _element[0] - if _element[1].upper() == 'N': - times = 999 - n_flag = True - else: - times = int(_element[1]) - - # 初始化测试片段执行结果 - result = 'success' - steps = [] - testcase = {} - if step['page'] in ('用例片段', 'SNIPPET'): - g.var['_last_'] = False - for t in range(times): - if t > 0: - _data = data_format(str(step['_data'])) - replace_dict(_data) - for k, v in _data.items(): - g.var[k] = v - testcase = deepcopy(g.snippet[element]) - tc = TestCase(testcase) - tc.run() - for s in testcase['steps']: - s['no'] = str(step['no']) + '*' + \ - str(t + 1) + '-' + str(s['no']) - steps += testcase['steps'] - # 用例片段执行失败时 - if testcase['result'] != 'success': - result = testcase['result'] - # 循环退出条件为失败,则直接返回,返回结果是 success - if condition == 'failure': - return 'success', testcase['steps'] - # 如果没有结束条件,且直接退出标志位为真,则返回结果 - if not condition and flag: - return result, steps - - # 用例片段执行成功时 - else: - # 如果循环退出条件是成功,则直接返回,返回结果是 success - if condition == 'success': - return 'success', testcase['steps'] - - if n_flag and g.var['_last_']: # 只有循环次数为 N 时,才判断是否是变量最后一个值 - g.var['_last_'] = False - break - # 执行结束,还没有触发循环退出条件,则返回结果为 failure - if condition: - return 'failure', testcase['steps'] - elif step['page'] in ('用例组合', 'CASESET'): - caseset = element - for t in range(times): - if t > 0: - _data = data_format(str(step['_data'])) - replace_dict(_data) - for k, v in _data.items(): - g.var[k] = v - for testcase in g.caseset[caseset]: - testcase = deepcopy(testcase) - testcase['flag'] = '' - g.ts.run_testcase(testcase) - g.casesets.append(testcase) - # if testcase['result'] != 'success': - # result = testcase['result'] - return result, steps - - -def dedup(text): - ''' - 去掉 text 中括号及其包含的字符 - ''' - _text = '' - n = 0 - - for s in text: - if s not in ( '(', ')'): - if n <= 0: - _text += s - elif s == '(': - n += 1 - elif s == ')': - n -= 1 - return _text - - -def sql(step): - - response = {} - - element = step['element'] - _sql = e.get(element)[1] - - logger.info('SQL: %s' % repr(_sql)) - # 获取连接参数 - value = e.get(step['page'] + '-' + 'config')[1] - arg = data_format(value) - - if step['page'] not in g.db.keys(): - g.db[step['page']] = DB(arg) - if _sql.lower().startswith('select'): - row = g.db[step['page']].fetchone(_sql) - logger.info('SQL response: %s' % repr(row)) - if not row: - raise Exception('*** Fetch None ***') - - elif _sql.lower().startswith('db.'): - _sql_ = _sql.split('.', 2) - collection = _sql_[1] - sql = _sql_[2] - response = g.db[step['page']].mongo(collection, sql) - if response: - logger.info('find result: %s' % repr(response)) - else: - g.db[step['page']].execute(_sql) - - if _sql.lower().startswith('select'): - text = _sql[6:].split('FROM')[0].split('from')[0].strip() - keys = dedup(text).split(',') - for i, k in enumerate(keys): - keys[i] = k.split(' ')[-1] - response = dict(zip(keys, row)) - logger.info('select result: %s' % repr(response)) - - expected = step['data'] - if not expected: - expected = step['expected'] - if 'json' in expected: - expected['json'] = json2dict(expected.get('json', '{}')) - result = check(expected.pop('json'), response['json']) - logger.info('json check result: %s' % result) - if result['code'] != 0: - raise Exception(f'json | EXPECTED:{repr(expected["json"])}, REAL:{repr(response["json"])}, RESULT: {result}') - elif result['var']: - var = dict(var, **result['var']) - g.var = dict(g.var, **result['var']) - logger.info('json var: %s' % (repr(result['var']))) - - if expected: - for key in expected: - sv, pv = expected[key], response[key] - logger.info('key: %s, expect: %s, real: %s' % - (repr(key), repr(sv), repr(pv))) - - compare(sv, pv) - - output = step['output'] - if output: - _output = {} - for k, v in output.items(): - if k == 'json': - sub = json2dict(output.get('json', '{}')) - result = check(sub, response['json']) - # logger.info('Compare json result: %s' % result) - var = dict(var, **result['var']) - g.var = dict(g.var, **result['var']) - logger.info('json var: %s' % (repr(result['var']))) - else: - _output[k] = response[v] - g.var[k] = response[v] - logger.info('output: %s' % repr(_output)) \ No newline at end of file diff --git a/sweetest/sweetest/keywords/files.py b/sweetest/sweetest/keywords/files.py deleted file mode 100644 index 2eee943..0000000 --- a/sweetest/sweetest/keywords/files.py +++ /dev/null @@ -1,211 +0,0 @@ -import os -from sweetest.log import logger - - -def copy(step): - cwd = os.getcwd() - source = step['element'] - destination = step['data']['text'] - - if step['page']: - os.chdir(step['page']) - - code = 0 - if os.name == 'nt': - code = os.system(f'COPY /Y {source} {destination}') - if os.name == 'posix': - code = os.system(f'cp -f -R {source} {destination}') - - if step['page']: - os.chdir(cwd) - - if code != 0: - raise Exception(f'COPY {source} {destination} is failure, code: {code}') - - -def move(step): - cwd = os.getcwd() - source = step['element'] - destination = step['data']['text'] - - if step['page']: - os.chdir(step['page']) - - code = 0 - if os.name == 'nt': - code = os.system(f'MOVE /Y {source} {destination}') - if os.name == 'posix': - code = os.system(f'mv -f {source} {destination}') - - if step['page']: - os.chdir(cwd) - - if code != 0: - raise Exception(f'MOVE {source} {destination} is failure, code: {code}') - - -def remove(step): - cwd = os.getcwd() - path = step['element'] - - if step['page']: - os.chdir(step['page']) - - code = 0 - if os.name == 'nt': - code = os.system(f'del /S /Q {path}') - if os.name == 'posix': - code = os.system(f'rm -f {path}') - - if step['page']: - os.chdir(cwd) - - if code != 0: - raise Exception(f'REMOVE {path} is failure, code: {code}') - - -def rmdir(step): - cwd = os.getcwd() - path = step['element'] - - if step['page']: - os.chdir(step['page']) - - code = 0 - if os.name == 'nt': - code = os.system(f'rd /S /Q {path}') - if os.name == 'posix': - code = os.system(f'rm -rf {path}') - - if step['page']: - os.chdir(cwd) - - if code != 0: - raise Exception(f'RERMDIR {path} is failure, code: {code}') - - -def mkdir(step): - cwd = os.getcwd() - path = step['element'] - - if step['page']: - os.chdir(step['page']) - - code = 0 - if os.name == 'nt': - code = os.system(f'mkdir {path}') - if os.name == 'posix': - code = os.system(f'mkdir -p {path}') - - if step['page']: - os.chdir(cwd) - - if code != 0: - raise Exception(f'MKDIR {path} is failure, code: {code}') - - -def exists(step): - cwd = os.getcwd() - path = step['element'] - - if step['page']: - os.chdir(step['page']) - - result = os.path.exists(path) - - if step['page']: - os.chdir(cwd) - - if not result: - raise Exception(f'{path} is not exists') - - -def not_exists(step): - try: - exists(step) - except: - pass - else: - path = step['element'] - raise Exception(f'{path} is a exists') - - -def is_file(step): - cwd = os.getcwd() - path = step['element'] - - if step['page']: - os.chdir(step['page']) - - result = os.path.isfile(path) - - if step['page']: - os.chdir(cwd) - - if not result: - raise Exception(f'{path} is not file') - - -def not_file(step): - try: - is_file(step) - except: - pass - else: - path = step['element'] - raise Exception(f'{path} is a file') - - -def is_dir(step): - cwd = os.getcwd() - path = step['element'] - - if step['page']: - os.chdir(step['page']) - - result = os.path.isdir(path) - - if step['page']: - os.chdir(cwd) - - if not result: - raise Exception(f'{path} is not dir') - - -def not_dir(step): - try: - is_dir(step) - except: - pass - else: - path = step['element'] - raise Exception(f'{path} is a dir') - - -def command(step, name=None): - cwd = os.getcwd() - cmd = step['element'] - - if name and os.name != name: - logger.info(f'COMMAND: this OS is not {name}, "{cmd}" is skipped') - return - - - if step['page']: - os.chdir(step['page']) - - code = os.system(cmd) - - if step['page']: - os.chdir(cwd) - - if code != 0: - raise Exception(f'COMMAND: "{cmd}" is failure, code: {code}') - - -def shell(step): - command(step, 'posix') - - -def cmd(step): - command(step, 'nt') \ No newline at end of file diff --git a/sweetest/sweetest/keywords/http.py b/sweetest/sweetest/keywords/http.py deleted file mode 100644 index df03480..0000000 --- a/sweetest/sweetest/keywords/http.py +++ /dev/null @@ -1,255 +0,0 @@ -from copy import deepcopy -import requests -import json -from injson import check -from sweetest.globals import g -from sweetest.elements import e -from sweetest.log import logger -from sweetest.parse import data_format -from sweetest.utility import json2dict -from pathlib import Path - -path = Path('lib') / 'http_handle.py' -if path.is_file(): - from lib import http_handle -else: - from sweetest.lib import http_handle - - -class Http: - - def __init__(self, step): - # 获取 baseurl - baseurl = e.get(step['page'] + '-' + 'baseurl', True)[1] - if not baseurl: - self.baseurl = '' - else: - if not baseurl.endswith('/'): - baseurl += '/' - self.baseurl = baseurl - - self.r = requests.Session() - # 获取 headers - self.headers_get = e.get(step['page'] + '-' + 'headers_get', True)[1] - self.headers_post = e.get(step['page'] + '-' + 'headers_post', True)[1] - - -def get(step): - request('get', step) - - -def post(step): - request('post', step) - - -def put(step): - request('put', step) - - -def patch(step): - request('patch', step) - - -def delete(step): - request('delete', step) - - -def options(step): - request('options', step) - - -def request(kw, step): - element = step['element'] - url = e.get(element)[1] - if url.startswith('/'): - url = url[1:] - - data = step['data'] - # 测试数据解析时,会默认添加一个 text 键,需要删除 - if 'text' in data and not data['text']: - data.pop('text') - - _data = {} - _data['headers'] = json2dict(data.pop('headers', '{}')) - if data.get('cookies'): - data['cookies'] = json2dict(data['cookies']) - if kw == 'get': - _data['params'] = json2dict( - data.pop('params', '{}')) or json2dict(data.pop('data', '{}')) - elif kw == 'post': - if data.get('text'): - _data['data'] = data.pop('text').encode('utf-8') - else: - _data['data'] = json2dict(data.pop('data', '{}')) - _data['json'] = json2dict(data.pop('json', '{}')) - _data['files'] = eval(data.pop('files', 'None')) - elif kw in ('put', 'patch'): - _data['data'] = json2dict(data.pop('data', '{}')) - - - for k in data: - for s in ('{', '[', 'False', 'True'): - if s in data[k]: - try: - data[k] = eval(data[k]) - except: - logger.warning('Try eval data failure: %s' % data[k]) - break - expected = step['expected'] - expected['status_code'] = expected.get('status_code', None) - expected['text'] = expected.get('text', None) - expected['json'] = json2dict(expected.get('json', '{}')) - expected['cookies'] = json2dict(expected.get('cookies', '{}')) - expected['headers'] = json2dict(expected.get('headers', '{}')) - timeout = float(expected.get('timeout', 10)) - expected['time'] = float(expected.get('time', 0)) - - if not g.http.get(step['page']): - g.http[step['page']] = Http(step) - http = g.http[step['page']] - - if kw == 'post': - if http.headers_post: - http.r.headers.update(eval(http.headers_post)) - else: - if http.headers_get: - http.r.headers.update(eval(http.headers_get)) - - logger.info('URL: %s' % http.baseurl + url) - - # 处理 before_send - before_send = data.pop('before_send', '') - if before_send: - _data, data = getattr(http_handle, before_send)(kw, _data, data) - else: - _data, data = getattr(http_handle, 'before_send')(kw, _data, data) - - if _data['headers']: - for k in [x for x in _data['headers']]: - if not _data['headers'][k]: - del http.r.headers[k] - del _data['headers'][k] - http.r.headers.update(_data['headers']) - - if kw == 'get': - r = getattr(http.r, kw)(http.baseurl + url, - params=_data['params'], timeout=timeout, **data) - if _data['params']: - logger.info(f'PARAMS: {_data["params"]}') - - elif kw == 'post': - r = getattr(http.r, kw)(http.baseurl + url, - data=_data['data'], json=_data['json'], files=_data['files'], timeout=timeout, **data) - logger.info(f'BODY: {r.request.body}') - - elif kw in ('put', 'patch'): - r = getattr(http.r, kw)(http.baseurl + url, - data=_data['data'], timeout=timeout, **data) - logger.info(f'BODY: {r.request.body}') - - elif kw in ('delete', 'options'): - r = getattr(http.r, kw)(http.baseurl + url, timeout=timeout, **data) - - logger.info('status_code: %s' % repr(r.status_code)) - try: # json 响应 - logger.info('response json: %s' % repr(r.json())) - except: # 其他响应 - logger.info('response text: %s' % repr(r.text)) - - response = {'status_code': r.status_code, 'headers': r.headers, - '_cookies': r.cookies, 'content': r.content, 'text': r.text} - - try: - response['cookies'] = requests.utils.dict_from_cookiejar(r.cookies) - except: - response['cookies'] = r.cookies - - try: - j = r.json() - response['json'] = j - except: - response['json'] = {} - - # 处理 after_receive - after_receive = expected.pop('after_receive', '') - if after_receive: - response = getattr(http_handle, after_receive)(response) - else: - response = getattr(http_handle, 'after_receive')(response) - - var = {} # 存储所有输出变量 - - if expected['status_code']: - if str(expected['status_code']) != str(response['status_code']): - raise Exception(f'status_code | EXPECTED:{repr(expected["status_code"])}, REAL:{repr(response["status_code"])}') - - if expected['text']: - if expected['text'].startswith('*'): - if expected['text'][1:] not in response['text']: - raise Exception(f'text | EXPECTED:{repr(expected["text"])}, REAL:{repr(response["text"])}') - else: - if expected['text'] == response['text']: - raise Exception(f'text | EXPECTED:{repr(expected["text"])}, REAL:{repr(response["text"])}') - - if expected['headers']: - result = check(expected['headers'], response['headers']) - logger.info('headers check result: %s' % result) - if result['code'] != 0: - raise Exception(f'headers | EXPECTED:{repr(expected["headers"])}, REAL:{repr(response["headers"])}, RESULT: {result}') - elif result['var']: - var = dict(var, **result['var']) - g.var = dict(g.var, **result['var']) - logger.info('headers var: %s' % (repr(result['var']))) - - if expected['cookies']: - logger.info('response cookies: %s' % response['cookies']) - result = check(expected['cookies'], response['cookies']) - logger.info('cookies check result: %s' % result) - if result['code'] != 0: - raise Exception(f'cookies | EXPECTED:{repr(expected["cookies"])}, REAL:{repr(response["cookies"])}, RESULT: {result}') - elif result['var']: - var = dict(var, **result['var']) - g.var = dict(g.var, **result['var']) - logger.info('cookies var: %s' % (repr(result['var']))) - - if expected['json']: - result = check(expected['json'], response['json']) - logger.info('json check result: %s' % result) - if result['code'] != 0: - raise Exception(f'json | EXPECTED:{repr(expected["json"])}, REAL:{repr(response["json"])}, RESULT: {result}') - elif result['var']: - var = dict(var, **result['var']) - g.var = dict(g.var, **result['var']) - logger.info('json var: %s' % (repr(result['var']))) - - if expected['time']: - if expected['time'] < r.elapsed.total_seconds(): - raise Exception(f'time | EXPECTED:{repr(expected["time"])}, REAL:{repr(r.elapsed.total_seconds())}') - - output = step['output'] - # if output: - # logger.info('output: %s' % repr(output)) - - for k, v in output.items(): - if v == 'status_code': - g.var[k] = response['status_code'] - logger.info('%s: %s' % (k, repr(g.var[k]))) - elif v == 'text': - g.var[k] = response['text'] - logger.info('%s: %s' % (k, repr(g.var[k]))) - elif k == 'json': - sub = json2dict(output.get('json', '{}')) - result = check(sub, response['json']) - # logger.info('Compare json result: %s' % result) - var = dict(var, **result['var']) - g.var = dict(g.var, **result['var']) - logger.info('json var: %s' % (repr(result['var']))) - elif k == 'cookies': - sub = json2dict(output.get('cookies', '{}')) - result = check(sub, response['cookies']) - # logger.info('Compare json result: %s' % result) - var = dict(var, **result['var']) - g.var = dict(g.var, **result['var']) - logger.info('cookies var: %s' % (repr(result['var']))) - if var: - step['_output'] += '\n||output=' + str(var) \ No newline at end of file diff --git a/sweetest/sweetest/keywords/mobile.py b/sweetest/sweetest/keywords/mobile.py deleted file mode 100644 index 84dfb0e..0000000 --- a/sweetest/sweetest/keywords/mobile.py +++ /dev/null @@ -1,486 +0,0 @@ -from time import sleep -import re -from sweetest.globals import g -from sweetest.elements import e -from sweetest.windows import w -from sweetest.locator import locating_elements, locating_data, locating_element -from sweetest.log import logger -from sweetest.parse import data_format -from sweetest.utility import compare -from appium.webdriver.common.touch_action import TouchAction -from selenium.common.exceptions import ElementClickInterceptedException - - -class Common(): - @classmethod - def title(cls, data, output): - logger.info('DATA:%s' % repr(data['text'])) - logger.info('REAL:%s' % repr(g.driver.title)) - if data['text'].startswith('*'): - assert data['text'][1:] in g.driver.title - else: - assert data['text'] == g.driver.title - # 只能获取到元素标题 - for key in output: - g.var[key] = g.driver.title - - @classmethod - def current_url(cls, data, output): - logger.info('DATA:%s' % repr(data['text'])) - logger.info('REAL:%s' % repr(g.driver.current_url)) - if data['text'].startswith('*'): - assert data['text'][1:] in g.driver.current_url - else: - assert data['text'] == g.driver.current_url - # 只能获取到元素 url - for key in output: - g.var[key] = g.driver.current_url - - -def check(step): - data = step['data'] - if not data: - data = step['expected'] - - element = step['element'] - element_location = locating_element(element) - if '#' in element: - e_name = element.split('#')[0] + '#' - else: - e_name = element - by = e.elements[e_name]['by'] - output = step['output'] - - if by in ('title', 'current_url'): - getattr(Common, by)(data, output) - - else: - for key in data: - # 预期结果 - expected = data[key] - # 切片操作处理 - s = re.findall(r'\[.*?\]', key) - if s: - s = s[0] - key = key.replace(s, '') - - if key == 'text': - real = element_location.text - else: - real = element_location.get_attribute(key) - if s: - real = eval('real' + s) - - logger.info('DATA:%s' % repr(expected)) - logger.info('REAL:%s' % repr(real)) - compare(expected, real) - - # 获取元素其他属性 - for key in output: - if output[key] == 'text': - g.var[key] = element_location.text - elif output[key] in ('text…', 'text...'): - if element_location.text.endswith('...'): - g.var[key] = element_location.text[:-3] - else: - g.var[key] = element_location.text - else: - g.var[key] = element_location.get_attribute(output[key]) - - -def notcheck(step): - data = step['data'] - if not data: - data = step['expected'] - - element = step['element'] - # element_location = locating_element(element) - - if e.elements[element]['by'] == 'title': - assert data['text'] != g.driver.title - - -def input(step): - data = step['data'] - element = step['element'] - element_location = locating_element(element) - - if isinstance(data['text'], tuple): - element_location.send_keys(*data['text']) - elif element_location: - if step['data'].get('清除文本', '') == '否' or step['data'].get('clear', '').lower() == 'no': - pass - else: - element_location.clear() - element_location.send_keys(data['text']) - - -def set_value(step): - data = step['data'] - element = step['element'] - element_location = locating_element(element) - - if isinstance(data['text'], tuple): - element_location.set_value(*data['text']) - elif element_location: - if step['data'].get('清除文本', '') == '否' or step['data'].get('clear', '').lower() == 'no': - pass - else: - element_location.clear() - element_location.set_value(data['text']) - - -def click(step): - element = step['element'] - if isinstance(element, str): - #element_location = locating_element(element, 'CLICK') - element_location = locating_element(element) - try: - element_location.click() - except ElementClickInterceptedException: # 如果元素为不可点击状态,则等待1秒,再重试一次 - sleep(1) - element_location.click() - elif isinstance(element, list): - for _e in element: - #element_location = locating_element(_e, 'CLICK') - element_location = locating_element(_e) - try: - element_location.click() - except ElementClickInterceptedException: # 如果元素为不可点击状态,则等待1秒,再重试一次 - sleep(1) - element_location.click() - sleep(0.5) - sleep(0.5) - - # 获取元素其他属性 - output = step['output'] - for key in output: - if output[key] == 'text': - g.var[key] = element_location.text - elif output[key] == 'tag_name': - g.var[key] = element_location.tag_name - elif output[key] in ('text…', 'text...'): - if element_location.text.endswith('...'): - g.var[key] = element_location.text[:-3] - else: - g.var[key] = element_location.text - else: - g.var[key] = element_location.get_attribute(output[key]) - - # if w.current_context.startswith('WEBVIEW'): - # # 判断是否打开了新的窗口,并将新窗口添加到所有窗口列表里 - # all_handles = g.driver.window_handles - # for handle in all_handles: - # if handle not in w.windows.values(): - # w.register(step, handle) - - -def tap(step): - action = TouchAction(g.driver) - - element = step['element'] - if isinstance(element, str): - - if ',' in element: - position = element.split(',') - x = int(position[0]) - y = int(position[1]) - position = (x, y) - g.driver.tap([position]) - else: - element_location = locating_element(element, 'CLICK') - action.tap(element_location).perform() - elif isinstance(element, list): - if ',' in element[0]: - for el in element: - position = el.split(',') - x = int(position[0]) - y = int(position[1]) - position = (x, y) - g.driver.tap([position]) - sleep(0.5) - else: - for _e in element: - element_location = locating_element(_e, 'CLICK') - action.tap(element_location).perform() - sleep(0.5) - sleep(0.5) - - # 获取元素其他属性 - output = step['output'] - for key in output: - if output[key] == 'text': - g.var[key] = element_location.text - elif output[key] == 'tag_name': - g.var[key] = element_location.tag_name - elif output[key] in ('text…', 'text...'): - if element_location.text.endswith('...'): - g.var[key] = element_location.text[:-3] - else: - g.var[key] = element_location.text - else: - g.var[key] = element_location.get_attribute(output[key]) - - # if w.current_context.startswith('WEBVIEW'): - # # 判断是否打开了新的窗口,并将新窗口添加到所有窗口列表里 - # all_handles = g.driver.window_handles - # for handle in all_handles: - # if handle not in w.windows.values(): - # w.register(step, handle) - - -def press_keycode(step): - element = step['element'] - g.driver.press_keycode(int(element)) - - -def swipe(step): - element = step['element'] - duration = step['data'].get('持续时间', 0.3) - assert isinstance(element, list) and len( - element) == 2, '坐标格式或数量不对,正确格式如:100,200|300,400' - - start = element[0].replace(',', ',').split(',') - start_x = int(start[0]) - start_y = int(start[1]) - - end = element[1].replace(',', ',').split(',') - end_x = int(end[0]) - end_y = int(end[1]) - - if duration: - g.driver.swipe(start_x, start_y, end_x, end_y, sleep(float(duration))) - else: - g.driver.swipe(start_x, start_y, end_x, end_y) - - -def line(step): - element = step['element'] - duration = float(step['data'].get('持续时间', 0.3)) - assert isinstance(element, list) and len( - element) > 1, '坐标格式或数量不对,正确格式如:258,756|540,1032' - postions = [] - for _e in element: - _e = _e.replace(',', ',') - p = _e.split(',') - postions.append(p) - - action = TouchAction(g.driver) - action = action.press( - x=postions[0][0], y=postions[0][1]).wait(duration * 1000) - for i in range(1, len(postions)): - action.move_to(x=postions[i][0], y=postions[i] - [1]).wait(duration * 1000) - action.release().perform() - - -def line_unlock(step): - element = step['element'] - duration = float(step['data'].get('持续时间', 0.3)) - assert isinstance(element, list) and len( - element) > 2, '坐标格式或数量不对,正确格式如:lock_pattern|1|4|7|8|9' - _e = locating_element(element[0]) - rect = _e.rect - w = rect['width'] / 6 - h = rect['height'] / 6 - - key = {} - key['1'] = (rect['x'] + 1 * w, rect['y'] + 1 * h) - key['2'] = (rect['x'] + 3 * w, rect['y'] + 1 * h) - key['3'] = (rect['x'] + 5 * w, rect['y'] + 1 * h) - key['4'] = (rect['x'] + 1 * w, rect['y'] + 3 * h) - key['5'] = (rect['x'] + 3 * w, rect['y'] + 3 * h) - key['6'] = (rect['x'] + 5 * w, rect['y'] + 3 * h) - key['7'] = (rect['x'] + 1 * w, rect['y'] + 5 * h) - key['8'] = (rect['x'] + 3 * w, rect['y'] + 5 * h) - key['9'] = (rect['x'] + 5 * w, rect['y'] + 5 * h) - - action = TouchAction(g.driver) - for i in range(1, len(element)): - k = element[i] - if i == 1: - action = action.press( - x=key[k][0], y=key[k][1]).wait(duration * 1000) - action.move_to(x=key[k][0], y=key[k][1]).wait(duration * 1000) - action.release().perform() - - -def rocker(step): - element = step['element'] - duration = float(step['data'].get('持续时间', 0.3)) - rocker_name = step['data'].get('摇杆', 'rocker') - release = step['data'].get('释放', False) - - if isinstance(element, str): - if element: - element = [element] - else: - element = [] - - postions = [] - for _e in element: - _e = _e.replace(',', ',') - p = _e.split(',') - postions.append(p) - - # 如果 action 中么有此摇杆名,则是新的遥感 - if not g.action.get(rocker_name): - g.action[rocker_name] = TouchAction(g.driver) - g.action[rocker_name].press( - x=postions[0][0], y=postions[0][1]).wait(duration * 1000) - # 新摇杆的第一个点已操作,需要删除 - postions.pop(0) - # 依次操作 - for i in range(len(postions)): - g.action[rocker_name].move_to( - x=postions[i][0], y=postions[i][1]).wait(duration * 1000) - - if release: - # 释放摇杆,并删除摇杆 - g.action[rocker_name].release().perform() - del g.action[rocker_name] - else: - g.action[rocker_name].perform() - - -def scroll(step): - element = step['element'] - assert isinstance(element, list) and len( - element) == 2, '元素格式或数量不对,正确格式如:origin_el|destination_el' - origin = locating_element(element[0]) - destination = locating_element(element[1]) - g.driver.scroll(origin, destination) - - -def flick_element(step): - element = step['element'] - speed = step['data'].get('持续时间', 10) - assert isinstance(element, list) and len( - element) == 2, '坐标格式或数量不对,正确格式如:elment|200,300' - _e = eval(element[0]) - - end = element[1].replace(',', ',').split(',') - end_x = int(end[0]) - end_y = int(end[1]) - - if speed: - g.driver.flick_element(_e, end_x, end_y, int(speed)) - - -def flick(step): - element = step['element'] - assert isinstance(element, list) and len( - element) == 2, '坐标格式或数量不对,正确格式如:100,200|300,400' - - start = element[0].replace(',', ',').split(',') - start_x = int(start[0]) - start_y = int(start[1]) - - end = element[1].replace(',', ',').split(',') - end_x = int(end[0]) - end_y = int(end[1]) - - g.driver.flick(start_x, start_y, end_x, end_y) - - -def drag_and_drop(step): - element = step['element'] - assert isinstance(element, list) and len( - element) == 2, '元素格式或数量不对,正确格式如:origin_el|destination_el' - origin = locating_element(element[0]) - destination = locating_element(element[1]) - g.driver.drag_and_drop(origin, destination) - - -def long_press(step): - action = TouchAction(g.driver) - - element = step['element'] - duration = step['data'].get('持续时间', 1000) - if ',' in element or ',' in element: - position = element.replace(',', ',').split(',') - x = int(position[0]) - y = int(position[1]) - action.long_press(x=x, y=y, duration=duration).perform() - else: - element_location = locating_element(element) - action.long_press(element_location, duration=duration).perform() - sleep(0.5) - - -def pinch(step): - element = step['element'] - element_location = locating_element(element[0]) - percent = step['data'].get('百分比', 200) - steps = step['data'].get('步长', 50) - g.driver.pinch(element_location, percent, steps) - - -def zoom(step): - element = step['element'] - element_location = locating_element(element[0]) - percent = step['data'].get('百分比', 200) - steps = step['data'].get('步长', 50) - g.driver.zoom(element_location, percent, steps) - - -def hide_keyboard(step): - g.driver.hide_keyboard() - - -def shake(step): - g.driver.shake() - - -def launch_app(step): - g.driver.launch_app() - - -def is_locked(step): - status = g.driver.is_locked() - assert status, "it's not locked" - - -def lock(step): - g.driver.lock() - - -def unlock(step): - g.driver.unlock() - - -def tab_name(step): - element = step['element'] - name = step['data']['text'] - # 从所有窗口中查找给定元素,如果查询到就命名,否则报错 - all_handles = g.driver.window_handles - logger.info('All Handles: %s' % all_handles) - - flag = False - for handle in all_handles: - #logger.info('Page Source: %s \n%s' % (handle, g.driver.page_source)) - #logger.info('All Windows: %s' %w.windows) - if handle not in w.windows.values(): - # 切换至此窗口 - g.driver.switch_to_window(handle) - try: - # 成功定位到关键元素 - locating_element(element, 'CLICK') - # 添加到窗口资源池 g.windows - w.windows[name] = handle - # 把当前窗口名字改为新窗口名称 - w.current_window = name - flag = True - logger.info('Current Window: %s' % repr(name)) - logger.info('Current Handle: %s' % repr(handle)) - except: - pass - if not flag: - raise Exception( - 'Tab Name failure: the element:%s in all tab is not found' % element) - -def activity(step): - - appPackage = step['data']['appPackage'] - appActivity = step['data']['appActivity'] - g.driver.start_activity(appPackage, appActivity) \ No newline at end of file diff --git a/sweetest/sweetest/keywords/web.py b/sweetest/sweetest/keywords/web.py deleted file mode 100644 index 785ffc6..0000000 --- a/sweetest/sweetest/keywords/web.py +++ /dev/null @@ -1,382 +0,0 @@ -from selenium.webdriver.common.action_chains import ActionChains -from selenium.common.exceptions import ElementClickInterceptedException -from selenium.webdriver.support.select import Select -from time import sleep -import re -from sweetest.globals import g -from sweetest.elements import e -from sweetest.windows import w -from sweetest.locator import locating_elements, locating_data, locating_element -from sweetest.log import logger -from sweetest.parse import data_format -from sweetest.utility import compare, json2dict - - -class Common(): - @classmethod - def title(cls, data, output): - logger.info('DATA:%s' % repr(data['text'])) - logger.info('REAL:%s' % repr(g.driver.title)) - try: - if data['text'].startswith('*'): - assert data['text'][1:] in g.driver.title - else: - assert data['text'] == g.driver.title - except: - raise Exception(f'Check Failure, DATA:{data["text"]}, REAL:{g.driver.title}') - # 只能获取到元素标题 - for key in output: - g.var[key] = g.driver.title - return g.driver.title - - - @classmethod - def current_url(cls, data, output): - logger.info('DATA:%s' % repr(data['text'])) - logger.info('REAL:%s' % repr(g.driver.current_url)) - try: - if data['text'].startswith('*'): - assert data['text'][1:] in g.driver.current_url - else: - assert data['text'] == g.driver.current_url - except: - raise Exception(f'Check Failure, DATA:{data["text"]}, REAL:{g.driver.current_url}') - # 只能获取到元素 url - for key in output: - g.var[key] = g.driver.current_url - return g.driver.current_url - - -def open(step): - element = step['element'] - value = e.get(element)[1] - if step['data'].get('清理缓存', '') or step['data'].get('clear', ''): - g.driver.delete_all_cookies() - if step['data'].get('#open_type', '') in ('新标签页', 'tab'): - js = "window.open('%s')" % value - g.driver.execute_script(js) - # 判断是否打开了新的窗口,并将新窗口添加到所有窗口列表里 - all_handles = g.driver.window_handles - for handle in all_handles: - if handle not in w.windows.values(): - w.register(step, handle) - else: - if step['data'].get('#open_type', '') in ('新浏览器' , 'browser'): - w.close() - g.set_driver() - w.init() - g.driver.get(value) - w.open(step) - cookie = step['data'].get('cookie', '') - if cookie: - g.driver.add_cookie(json2dict(cookie)) - co = g.driver.get_cookie(json2dict(cookie).get('name', '')) - logger.info(f'cookie is add: {co}') - sleep(0.5) - - -def check(step): - data = step['data'] - if not data: - data = step['expected'] - - element = step['element'] - element_location = locating_element(element) - if '#' in element: - e_name = element.split('#')[0] + '#' - else: - e_name = element - by = e.elements[e_name]['by'] - output = step['output'] - var = {} - - if by in ('title', 'current_url'): - var[by] = getattr(Common, by)(data, output) - - else: - for key in data: - # 预期结果 - expected = data[key] - # 切片操作处理 - s = re.findall(r'\[.*?\]', key) - if s: - s = s[0] - key = key.replace(s, '') - - if key == 'text': - real = element_location.text - else: - real = element_location.get_attribute(key) - if s: - real = eval('real' + s) - - logger.info('DATA:%s' % repr(expected)) - logger.info('REAL:%s' % repr(real)) - try: - compare(expected, real) - except: - raise Exception(f'Check Failure, DATA:{repr(expected)}, REAL:{repr(real)}') - - # 获取元素其他属性 - for key in output: - if output[key] == 'text': - var[key] = g.var[key] = element_location.text - elif output[key] in ('text…', 'text...'): - if element_location.text.endswith('...'): - var[key] = g.var[key] = element_location.text[:-3] - else: - var[key] = g.var[key] = element_location.text - else: - var[key] = g.var[key] = element_location.get_attribute(output[key]) - if var: - step['_output'] += '\n||output=' + str(var) - return element_location - - -def notcheck(step): - data = step['data'] - if not data: - data = step['expected'] - - element = step['element'] - # element_location = locating_element(element) - - if e.elements[element]['by'] == 'title': - assert data['text'] != g.driver.title - - -def input(step): - data = step['data'] - element = step['element'] - element_location = locating_element(element) - - if step['data'].get('清除文本', '') == '否' or step['data'].get('clear', '').lower() == 'no': - pass - else: - element_location.clear() - - for key in data: - if key.startswith('text'): - if isinstance(data[key], tuple): - element_location.send_keys(*data[key]) - elif element_location: - element_location.send_keys(data[key]) - sleep(0.5) - if key == 'word': #逐字输入 - for d in data[key]: - element_location.send_keys(d) - sleep(0.3) - return element_location - - -def click(step): - element = step['element'] - data = step['data'] - if isinstance(element, str): - element_location = locating_element(element, 'CLICK') - if element_location: - try: - element_location.click() - except ElementClickInterceptedException: # 如果元素为不可点击状态,则等待1秒,再重试一次 - sleep(1) - if data.get('mode'): - g.driver.execute_script("arguments[0].click();", element_location) - else: - element_location.click() - elif isinstance(element, list): - for _e in element: - element_location = locating_element(_e, 'CLICK') - try: - element_location.click() - except ElementClickInterceptedException: # 如果元素为不可点击状态,则等待1秒,再重试一次 - sleep(1) - if data.get('mode'): - g.driver.execute_script("arguments[0].click();", element_location) - else: - element_location.click() - sleep(0.5) - sleep(0.5) - - # 获取元素其他属性 - output = step['output'] - for key in output: - if output[key] == 'text': - g.var[key] = element_location.text - elif output[key] in ('text…', 'text...'): - if element_location.text.endswith('...'): - g.var[key] = element_location.text[:-3] - else: - g.var[key] = element_location.text - else: - g.var[key] = element_location.get_attribute(output[key]) - - # 判断是否打开了新的窗口,并将新窗口添加到所有窗口列表里 - all_handles = g.driver.window_handles - for handle in all_handles: - if handle not in w.windows.values(): - w.register(step, handle) - - return element_location - - -def select(step): - data = step['data'] - element = step['element'] - element_location = locating_element(element) - for key in data: - if key.startswith('index'): - Select(element_location).select_by_index(data[key]) - elif key.startswith('value'): - Select(element_location).select_by_value(data[key]) - elif key.startswith('text') or key.startswith('visible_text'): - Select(element_location).select_by_visible_text(data[key]) - - -def deselect(step): - data = step['data'] - element = step['element'] - element_location = locating_element(element) - for key in data: - if key.startswith('all'): - Select(element_location).deselect_all() - elif key.startswith('index'): - Select(element_location).deselect_by_index(data[key]) - elif key.startswith('value'): - Select(element_location).deselect_by_value(data[key]) - elif key.startswith('text') or key.startswith('visible_text'): - Select(element_location).deselect_by_visible_text(data[key]) - - -def hover(step): - actions = ActionChains(g.driver) - element = step['element'] - element_location = locating_element(element) - actions.move_to_element(element_location) - actions.perform() - sleep(0.5) - - return element_location - - -def context_click(step): - actions = ActionChains(g.driver) - element = step['element'] - element_location = locating_element(element) - actions.context_click(element_location) - actions.perform() - sleep(0.5) - - return element_location - - -def double_click(step): - actions = ActionChains(g.driver) - element = step['element'] - element_location = locating_element(element) - actions.double_click(element_location) - actions.perform() - sleep(0.5) - - return element_location - - -def drag_and_drop(step): - actions = ActionChains(g.driver) - element = step['element'] - source = locating_element(element[0]) - target = locating_element(element[1]) - actions.drag_and_drop(source, target) - actions.perform() - sleep(0.5) - - -def swipe(step): - actions = ActionChains(g.driver) - element = step['element'] - data = step['data'] - - source = locating_element(element) - x = data.get('x', 0) - y = data.get('y', 0) - actions.drag_and_drop_by_offset(source, x, y) - actions.perform() - sleep(0.5) - - -def script(step): - element = step['element'] - value = e.get(element)[1] - g.driver.execute_script(value) - - -def message(step): - data = step['data'] - text = data.get('text', '') - element = step['element'] - value = e.get(element)[1] - - if value.lower() in ('确认', 'accept'): - g.driver.switch_to_alert().accept() - elif value.lower() in ('取消', '关闭', 'cancel', 'close'): - g.driver.switch_to_alert().dismiss() - elif value.lower() in ('输入', 'input'): - g.driver.switch_to_alert().send_keys(text) - g.driver.switch_to_alert().accept() - logger.info('--- Switch Frame: Alert') - w.frame = 'Alert' - - -def upload(step): - import win32com.client - - data = step['data'] - element = step['element'] - element_location = locating_element(element) - file_path = data.get('text', '') or data.get('file', '') - - element_location.click() - sleep(3) - shell = win32com.client.Dispatch("WScript.Shell") - shell.Sendkeys(file_path) - sleep(2) - shell.Sendkeys("{ENTER}") - sleep(2) - - -def navigate(step): - element = step['element'] - - if element.lower() in ('刷新', 'refresh'): - g.driver.refresh() - elif element.lower() in ('前进', 'forward'): - g.driver.forward() - elif element.lower() in ('后退', 'back'): - g.driver.back() - - -def scroll(step): - data = step['data'] - x = data.get('x') - y = data.get('y') or data.get('text') - - element = step['element'] - if element == '': - # if x is None: - # x = '0' - # g.driver.execute_script( - # f"window.scrollTo({x},{y})") - if y: - g.driver.execute_script( - f"document.documentElement.scrollTop={y}") - if x: - g.driver.execute_script( - f"document.documentElement.scrollLeft={x}") - else: - element_location = locating_element(element) - - if y: - g.driver.execute_script( - f"arguments[0].scrollTop={y}", element_location) - if x: - g.driver.execute_script( - f"arguments[0].scrollLeft={x}", element_location) \ No newline at end of file diff --git a/sweetest/sweetest/keywords/windows.py b/sweetest/sweetest/keywords/windows.py deleted file mode 100644 index b49738c..0000000 --- a/sweetest/sweetest/keywords/windows.py +++ /dev/null @@ -1,222 +0,0 @@ -from pywinauto.application import Application -from pywinauto.keyboard import send_keys as sendkeys -import re -from sweetest.log import logger -from sweetest.globals import g -from sweetest.utility import compare - -class Windows(): - def __init__(self, app): - self.app = app - self.backend = app.backend.name - self.dialogs = [] - - def dialog(self, page): - if page == []: - if self.dialogs: - return self.dialogs[-1] - else: - raise Exception('Dialog: your page start with "", but there is no parent dialog') - elif page[0] == '<': - if len(self.dialogs) >= 2: - self.dialogs.pop() - return self.dialog(page[1:]) - else: - raise Exception('Dialog: your page start with "<", but the parent is less than 1 dialog') - elif page[0] == '>': - if self.dialogs: - if self.backend == 'win32': - current_dialog = self.app.window(best_match=page[1]) - elif self.backend == 'uia': - current_dialog = self.dialogs[-1].child_window(best_match=page[1]) - self.dialogs.append(current_dialog) - return self.dialog(page[2:]) - else: - raise Exception('Dialog: your page start with ">", but there is no parent dialog') - else: - current_dialog = self.app.window(best_match=page[0]) - self.dialogs = [current_dialog] - return self.dialog(page[1:]) - - -def menu_select(dialog, step): - element = step['element'] - try: - dialog.menu_select(element) - except: - for el in element.split('->'): - dialog.child_window(best_match=el).select() - - -def select(dialog, step): - element = step['element'] - if dialog.backend.name == 'win32': - dialog.window(best_match=element).select() - elif dialog.backend.name == 'uia': - dialog.child_window(best_match=element).select() - - -def click(dialog, step): - element = step['element'] - if dialog.backend.name == 'win32': - dialog.window(best_match=element).click_input() - elif dialog.backend.name == 'uia': - dialog.child_window(best_match=element).click_input() - - -def check_off(dialog, step): - element = step['element'] - if dialog.backend.name == 'win32': - dialog.window(best_match=element).check() - elif dialog.backend.name == 'uia': - dialog.child_window(best_match=element).check() - - -def double_click(dialog,step): - element = step['element'] - if dialog.backend.name == 'win32': - dialog.window(best_match=element).double_click_input() - elif dialog.backend.name == 'uia': - dialog.child_window(best_match=element).double_click_input() - - -def input(dialog, step): - element = step['element'] - value = step['data']['text'] - if dialog.backend.name == 'win32': - dialog.window(best_match=element).type_keys(value, with_spaces=True, with_newlines='\r\n') - elif dialog.backend.name == 'uia': - dialog.child_window(best_match=element).type_keys(value, with_spaces=True, with_newlines='\r\n') - - -def set_text(dialog, step): - element = step['element'] - value = step['data']['text'] - if dialog.backend.name == 'win32': - dialog.window(best_match=element).set_edit_text(value) - elif dialog.backend.name == 'uia': - dialog.child_window(best_match=element).set_edit_text(value) - - -def send_keys(dialog, step): - element = step['element'] - value = step['data'].get('text') - dialog.set_focus() - if element: - if dialog.backend.name == 'win32': - dialog.window(best_match=element).set_focus() - elif dialog.backend.name == 'uia': - dialog.child_window(best_match=element).set_focus() - sendkeys(value) - else: - sendkeys(value) - - -def check(dialog, step): - element = step['element'] - data = step['data'] - if not data: - data = step['expected'] - output = step['output'] - for key in data: - # 预期结果 - expected = data[key] - # 切片操作处理 - s = re.findall(r'\[.*?\]', key) - if s: - s = s[0] - key = key.replace(s, '') - - if key == 'text': - if dialog.backend.name == 'win32': - real = dialog.window(best_match=element).texts()[0].replace('\r\n', '\n') - elif dialog.backend.name == 'uia': - real = dialog.child_window(best_match=element).texts()[0].replace('\r\n', '\n') - elif key == 'value': - if dialog.backend.name == 'win32': - real = dialog.window(best_match=element).text_block().replace('\r\n', '\n') - elif dialog.backend.name == 'uia': - real = dialog.child_window(best_match=element).get_value().replace('\r\n', '\n') - if s: - real = eval('real' + s) - - if key == 'selected': - if dialog.backend.name == 'win32': - real = dialog.window(best_match=element).is_selected() - elif dialog.backend.name == 'uia': - real = dialog.child_window(best_match=element).is_selected() - elif key == 'checked': - if dialog.backend.name == 'win32': - real = dialog.window(best_match=element).is_checked() - elif dialog.backend.name == 'uia': - real = dialog.child_window(best_match=element).is_checked() - elif key == 'enabled': - if dialog.backend.name == 'win32': - real = dialog.window(best_match=element).is_enabled() - elif dialog.backend.name == 'uia': - real = dialog.child_window(best_match=element).is_enabled() - elif key == 'visible': - if dialog.backend.name == 'win32': - real = dialog.window(best_match=element).is_visible() - elif dialog.backend.name == 'uia': - real = dialog.child_window(best_match=element).is_visible() - elif key == 'focused': - if dialog.backend.name == 'win32': - real = dialog.window(best_match=element).is_focused() - elif dialog.backend.name == 'uia': - real = dialog.child_window(best_match=element).is_focused() - - logger.info('DATA:%s' % repr(expected)) - logger.info('REAL:%s' % repr(real)) - compare(expected, real) - - # 获取元素其他属性 - for key in output: - k = output[key] - if dialog.window(best_match=element).class_name() == 'Edit' and k == 'text': - k = 'value' - - if k == 'text': - if dialog.backend.name == 'win32': - g.var[key] = dialog.window(best_match=element).texts()[0].replace('\r\n', '\n') - elif dialog.backend.name == 'uia': - g.var[key] = dialog.child_window(best_match=element).texts()[0].replace('\r\n', '\n') - - if k == 'value': - if dialog.backend.name == 'win32': - g.var[key] = dialog.window(best_match=element).text_block().replace('\r\n', '\n') - elif dialog.backend.name == 'uia': - g.var[key] = dialog.child_window(best_match=element).get_value().replace('\r\n', '\n') - - -def window(dialog, step): - element = step['element'] - if element.lower() in ('最小化', 'minimize'): - dialog.minimize() - elif element.lower() in ('最大化', 'maximize'): - dialog.maximize() - elif element.lower() in ('恢复', 'restore'): - dialog.restore() - elif element.lower() in ('关闭', 'close'): - dialog.close() - elif element.lower() in ('前台', 'set_focus'): - dialog.set_focus() - elif element.lower() in ('重置', 'reset'): - for i in range(10): - if not dialog.has_focus(): - try: - # 如果弹出保存窗口 - save = dialog.get_active() - element = '(N)' - if dialog.backend.name == 'win32': - save.window(best_match=element).click_input() - elif dialog.backend.name == 'uia': - save.child_window(best_match=element).click_input() - except: - pass - #按 Alt+F4 关闭窗口 - sendkeys('%{F4}') - else: - break - else: - raise Exception(f'Reset the Windows is failure: try Alt+F4 for {i+1} times') \ No newline at end of file diff --git a/sweetest/sweetest/locator.py b/sweetest/sweetest/locator.py deleted file mode 100644 index 59e15d5..0000000 --- a/sweetest/sweetest/locator.py +++ /dev/null @@ -1,75 +0,0 @@ -from time import sleep -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from sweetest.elements import e -from sweetest.globals import g -from sweetest.windows import w -from sweetest.log import logger -from sweetest.config import element_wait_timeout - - -def locating_element(element, action=''): - el_location = None - try: - el, value = e.get(element) - except: - logger.exception( - 'Locating the element:%s is Failure, this element is not define' % element) - raise Exception( - 'Locating the element:%s is Failure, this element is not define' % element) - - if not isinstance(el, dict): - raise Exception( - 'Locating the element:%s is Failure, this element is not define' % element) - - wait = WebDriverWait(g.driver, element_wait_timeout) - - if el['by'].lower() in ('title', 'url', 'current_url'): - return None - else: - try: - el_location = wait.until(EC.presence_of_element_located( - (getattr(By, el['by'].upper()), value))) - except: - sleep(5) - try: - el_location = wait.until(EC.presence_of_element_located( - (getattr(By, el['by'].upper()), value))) - except : - raise Exception('Locating the element:%s is Failure: Timeout' % element) - try: - if g.driver.name in ('chrome', 'safari'): - g.driver.execute_script( - "arguments[0].scrollIntoViewIfNeeded(true)", el_location) - else: - g.driver.execute_script( - "arguments[0].scrollIntoView(false)", el_location) - except: - pass - - try: - if action == 'CLICK': - el_location = wait.until(EC.element_to_be_clickable( - (getattr(By, el['by'].upper()), value))) - else: - el_location = wait.until(EC.visibility_of_element_located( - (getattr(By, el['by'].upper()), value))) - except: - pass - - return el_location - - -def locating_elements(elements): - elements_location = {} - for el in elements: - elements_location[el] = locating_element(el) - return elements_location - - -def locating_data(keys): - data_location = {} - for key in keys: - data_location[key] = locating_element(key) - return data_location diff --git a/sweetest/sweetest/log.py b/sweetest/sweetest/log.py deleted file mode 100644 index 1615757..0000000 --- a/sweetest/sweetest/log.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging -import datetime -from pathlib import Path -import sys -from sweetest.utility import mkdir - - -def today(): - now = datetime.datetime.now() - return now.strftime('%Y%m%d') - - - -def set_log(logger, log_path): - mkdir('log') - - # 文件日志 - log_file = Path('log') / f'{today()}.log' - file_handler = logging.FileHandler(filename=log_file, encoding="utf-8") - file_handler.setFormatter(formatter) # 可以通过setFormatter指定输出格式 - - # 单次文件日志 - sweet_log = log_path / 'sweet.log' - try: - sweet_log.unlink() - except: - pass - sweet_handler = logging.FileHandler(filename=sweet_log, encoding="utf-8") - sweet_handler.setFormatter(formatter) # 可以通过setFormatter指定输出格式 - - # 控制台日志 - console_handler = logging.StreamHandler(sys.stdout) - console_handler.formatter = formatter # 也可以直接给formatter赋值 - - # 为logger添加的日志处理器 - logger.addHandler(file_handler) - logger.addHandler(sweet_handler) - logger.addHandler(console_handler) - - # 指定日志的最低输出级别,默认为WARN级别 - # DEBUG,INFO,WARNING,ERROR,CRITICAL - logger.setLevel(logging.INFO) - - return str(sweet_log) - -# 获取logger实例,如果参数为空则返回root logger -logger = logging.getLogger("sweetest") -# 指定logger输出格式 -formatter = logging.Formatter('%(asctime)s [%(levelname)s]: # %(message)s') \ No newline at end of file diff --git a/sweetest/sweetest/parse.py b/sweetest/sweetest/parse.py deleted file mode 100644 index efa2790..0000000 --- a/sweetest/sweetest/parse.py +++ /dev/null @@ -1,68 +0,0 @@ -from sweetest.log import logger -from sweetest.config import all_keywords, comma_lower, comma_upper, equals, vertical, zh_en -from sweetest.elements import e -from sweetest.globals import g - - -def escape(data): - # 先把转义字符替换掉 - # return data.replace('\\,', comma_lower).replace('\\,', comma_upper).replace('\\=', equals) - return data.replace('\\,', comma_lower) - - -def recover(data): - # 再把转义字符恢复 - # return data.replace(comma_lower, ',').replace(comma_upper, ',').replace(equals, '=') - return data.replace(comma_lower, ',') - - -def check_keyword(kw): - try: - keyword = all_keywords.get(kw) - return keyword - except: - logger.exception('Keyword:%s is not exist' % kw) - exit() - - -def data_format(data): - data = escape(data) - if ',,' in data: - data_list = data.split(',,') - else: - # data = data.replace(',', ',') # 中文逗号不再视为分隔符 - data_list = [] - if data: - data_list = data.split(',') - data_dict = {} - for data in data_list: - # 只需要分割第一个'='号 - d = data.split('=', 1) - d[-1] = recover(d[-1]) # 只有值需要转义恢复,<元素属性> or <变量名> 不应该出现转义字符 - if len(d) == 1: - # 如果没有=号分割,说明只有内容,默认赋值给 text - if not data_dict.get('text'): - data_dict['text'] = d[0] - elif len(d) == 2: - d[0] = d[0].strip() # 清除 <元素属性> 2边的空格,如果有的话 - d[0] = zh_en.get(d[0], d[0]) - data_dict[d[0]] = d[1] - else: - raise Exception( - 'Error: Testcase\'s Data is error, more "=" or less ","') - return data_dict - - -def parse(testsuit): - ''' - 将测试用例解析为可执行参数,如: - 打开首页,解析为:OPEN 127.0.0.1 - ''' - for testcase in testsuit: - for step in testcase['steps']: - step['keyword'] = check_keyword(step['keyword']) - # step['page'], step['custom'], step['element'] = elements_format( - # step['page'], step['element']) - step['data'] = data_format(str(step['data'])) - step['expected'] = data_format(str(step['expected'])) - step['output'] = data_format(step['output']) diff --git a/sweetest/sweetest/report.py b/sweetest/sweetest/report.py deleted file mode 100644 index 34ab058..0000000 --- a/sweetest/sweetest/report.py +++ /dev/null @@ -1,235 +0,0 @@ -import time -import arrow -from pathlib import Path -from sweetest.globals import g -from sweetest.utility import mkdir - - -def reporter(plan_data, testsuites_data, report_data, extra_data): - - extra_data['plan'] = plan_data['plan'] - extra_data['task'] = int(time.time() * 1000) - - testcases = [] - for key, ts in report_data.items(): - count = {'result': 'success', 'total': 0, - 'success': 0, 'failure': 0, 'blocked': 0} - no = 1 - for tc in ts: - tc['testsuite'] = key - tc['no'] = no - no += 1 - tc = {**extra_data, **tc} - - res = tc['result'].lower() - if tc['condition'].lower() in ('base', 'setup', 'snippet'): - pass - elif res in count: - count[res] += 1 - count['total'] += 1 - testcases.append(tc) - if count['failure'] + count['blocked']: - count['result'] = 'failure' - testsuites_data[key] = {**count, **testsuites_data[key]} - - testsuite = [] - count = {'total': 0, 'success': 0, - 'failure': 0, 'blocked': 0} - result = 'success' - for key, ts in testsuites_data.items(): - for k in count: - count[k] += ts[k] - if ts['result'] != 'success': - result = 'failure' - - ts = {**extra_data, **ts} - ts['testsuite'] = key - testsuite.append(ts) - count['result'] = result - - plan = {**extra_data, **count, **plan_data} - - return plan, testsuite, testcases - - -def local_time(timestamp): - import time - t = time.localtime(int(timestamp / 1000)) - return str(time.strftime("%Y/%m/%d %H:%M:%S", t)) - - -def cost_time(start, end): - return int((end - start) / 1000) - - -def summary(plan_data, testsuites_data, report_data, extra_data): - plan, testsuites, testcases = reporter( - plan_data, testsuites_data, report_data, extra_data) - - data = [['测试套件', '用例总数', '成功', '阻塞', '失败', '测试结果', '开始时间', '结束时间', '耗时(秒)']] - failures = [['测试套件', '用例编号', '用例标题', '用例结果', '失败步骤', '备注']] - - for suite in testsuites: - row = [suite['testsuite'], suite['total'], suite['success'], suite['blocked'], suite['failure'], - suite['result'], local_time(suite['start_timestamp']), local_time(suite['end_timestamp']), - cost_time(suite['start_timestamp'], suite['end_timestamp'])] - data.append(row) - - flag = False - for case in testcases: - suite_name = '' if flag else case['testsuite'] - row = [] - if case['result'] == 'blocked': - row = [suite_name, case['id'], case['title'], case['result']] - elif case['result'] == 'failure': - for step in case['steps']: - if step['score'] == 'NO': - desc = '|'.join([step[k] for k in ('no', 'keyword', 'page', 'element')]) - row = [suite_name, case['id'], case['title'], case['result'], desc, step['remark']] - break - if row: - flag = True - failures.append(row) - - data.append(['--------']) - total = ['总计', plan['total'], plan['success'], plan['blocked'], plan['failure'], - plan['result'], local_time(plan['start_timestamp']), local_time(plan['end_timestamp']), - cost_time(plan['start_timestamp'], plan['end_timestamp'])] - - data.append(total) - if len(failures) > 1: - data.append(['********']) - data += failures - return data - -def markdown(plan, testsuites, testcases, md_path='markdown'): - success = OK = '通过' - failure = NO = '失败' - blocked = '阻塞' - skipped = '-' - - md = '| 测试套件名称 | 开始时间 | 结束时间 | 耗时 | 成功个数 | 失败个数 | 阻塞个数 | 总个数 | 结果 |\n' - md +='| ----------- | ------- | ------- | ---- | ------- | ------- | -------- | ----- | ---- |\n' - - result = success - sc, fc, bc, tc = 0, 0, 0, 0 - for v in testsuites.values(): - sc += v['success'] - fc += v['failure'] - bc += v['blocked'] - tc += v['total'] - re = success - if v['result'] == 'failure': - re = failure - cost = round((v['end_timestamp'] - v['start_timestamp'])/1000, 1) - md += f'| {v["testsuite"]} | {tm(v["start_timestamp"])} | {tm(v["end_timestamp"])} | {cost} | ' - md += f'{v["success"]} | {v["failure"]} | {v["blocked"]} | {v["total"]} | {re} |\n' - if v['result'] == 'failure': - result = failure - cost = round((plan['end_timestamp'] - plan['start_timestamp'])/1000, 1) - md += f'| **共计** | {tm(plan["start_timestamp"])} | {tm(plan["end_timestamp"])} | {cost} | ' - md += f'{sc} | {fc} | {bc} | {tc} | {result} |\n' - title = f'# 「{plan["plan"]}」自动化测试执行报告 {result} #\n\n[历史记录](/{plan["plan"]}/)\n\n' - md = title + f'## 测试计划执行结果\n\n{md}\n\n## 测试套件执行结果\n\n' - - if result == success: - icon = '✔️' - else: - icon = '❌' - - message = f'- {icon} {tm(plan["start_timestamp"])} - {tm(plan["end_timestamp"])} 测试计划' - message +=f'「[{plan["plan"]}]({plan["plan"]}/{plan["plan"]}_{tm(plan["start_timestamp"], "_")})」执行完成,测试结果:{result},成功:{sc},失败:{fc},阻塞:{bc}\n\n' - - # 测试套件 - 测试用例结果 - txt = '' - for k,v in testcases.items(): - txt += f'\n- ### {k}\n\n' - txt += '| 用例id | 用例名称 | 前置条件 |开始时间 | 结束时间 | 耗时 | 结果 |\n' - txt += '| ------- | ------- | ----------- | -------------- | -------------- | ----- | ------- |\n' - for case in v: - if case['flag'] == 'N': - continue - cost = round((case['end_timestamp'] - case['start_timestamp'])/1000, 1) - result = eval(case['result']) - txt += f'| [{case["id"]}](#{case["id"]}) | {case["title"]} | {case["condition"]} | {tm(case["start_timestamp"])} | {tm(case["end_timestamp"])} | {cost} | {result} |\n' - - md += f'{txt}\n\n## 测试用例执行结果\n' - txt = '' - for k,v in testcases.items(): - txt += f'\n- ### {k}\n' - for case in v: - if case['flag'] == 'N': - continue - txt += f'\n#### {case["id"]}\n\n**{case["title"]}** | {case["condition"]} | {case["designer"]} | {eval(case["result"])}\n\n' - txt += '| 步骤 | 操作 | 页面 | 元素 | 测试数据 | 预期结果 | 输出数据 | 耗时 | 测试结果 | 备注 | 截图 |\n' - txt += '|------|-------|-------|------|-----------|---------|-----------|-----|---------|------|--------|\n' - for step in case['steps']: - cost = round((step.get('end_timestamp', 0) - step.get('start_timestamp', 0))/1000, 1) - if cost == 0: - cost = '-' - if not step['score']: - result = skipped - else: - result = eval(step['score']) - snapshot = '' - if 'snapshot' in step: - for k,v in step['snapshot'].items(): - snapshot += f"[{k}](/report/{v} ':ignore')\n" - txt += f'| {step["no"]} | {step["keyword"]} | {step["page"]} | {escape(step["element"])} | {escape(step["data"])} | {escape(step["expected"])} | {escape(step["output"])} | {cost} | {result} | {step["remark"]} | {escape(snapshot, "%23")} |\n' - result = eval(case['result']) - md += txt - - - p = Path(md_path) / 'report' - latest = p / 'latest' - report = p / g.plan_name - mkdir(p) - mkdir(report) - - - with open(p / 'README.md', 'r', encoding='UTF-8') as f: - txt = f.read() - if '恭喜你安装成功' in txt: - txt = '' - with open(p / f'README.md', 'w', encoding='UTF-8') as f: - f.write(message + txt) - with open(latest / f'{g.plan_name}.md','w', encoding='UTF-8') as f: - f.write(md) - readme = report / 'README.md' - if readme.is_file(): - with open(report / 'README.md', 'r', encoding='UTF-8') as f: - txt = f.read() - else: - txt = '' - with open(report / 'README.md','w', encoding='UTF-8') as f: - f.write(message + txt) - with open(report / f'{plan["plan"]}_{tm(plan["start_timestamp"], "_")}.md','w',encoding='UTF-8') as f: - f.write(md) - with open(p / '_sidebar.md', 'r', encoding='UTF-8') as f: - txt = f.read() - if f'[{g.plan_name}]' not in txt: - with open(p / '_sidebar.md', 'a',encoding='UTF-8') as f: - f.write(f'\n * [{g.plan_name}](latest/{g.plan_name})') - - files = [] - for f in report.iterdir(): - if f.stem not in ['_sidebar', 'README']: - files.append(f.stem) - files.sort(reverse=True) - with open(report / '_sidebar.md', 'w',encoding='UTF-8') as f: - txt = f'* 「{g.plan_name}」测试结果\n' - for stem in files: - txt +=f'\n * [{stem}]({g.plan_name}/{stem})' - f.write(txt) - - - - -def escape(data, well='#'): - return data.replace('|', '\|').replace('<', '\<').replace('>', '\>').replace('\n', '
').replace('#', well) - -def tm(stamp, dot=' '): - if dot == ' ': - return arrow.get(stamp/1000).to('local').format(f'YYYY-MM-DD{dot}HH:mm:ss').replace(':', ':') - elif dot == '_': - return arrow.get(stamp/1000).to('local').format(f'YYYYMMDD{dot}HH:mm:ss').replace(':', '') \ No newline at end of file diff --git a/sweetest/sweetest/snapshot.py b/sweetest/sweetest/snapshot.py deleted file mode 100644 index 9250a85..0000000 --- a/sweetest/sweetest/snapshot.py +++ /dev/null @@ -1,295 +0,0 @@ -from pathlib import Path -from PIL import Image -from PIL import ImageChops -import math -import operator -import time -from functools import reduce -from sweetest.globals import g, now -from sweetest.log import logger -from sweetest.utility import mkdir - - -# 解决打开图片大于 20M 的限制 -Image.MAX_IMAGE_PIXELS = None - -def crop(element, src, target): - location = element.location - size = element.size - im = Image.open(src) - left = location['x'] - top = location['y'] - right = location['x'] + size['width'] - bottom = location['y'] + size['height'] - im = im.crop((left, top, right, bottom)) - im.save(target) - - -def blank(src, boxs): - white = Image.new('RGB',(5000, 5000), 'white') - im = Image.open(src) - for box in boxs: - w = white.crop(box) - im.paste(w, box[:2]) - im.save(src) - - -def cut(src, target, box): - im = Image.open(src) - im = im.crop(box) - im.save(target) - - -def get_screenshot(file_path): - if g.headless: - width = g.driver.execute_script("return Math.max(document.body.scrollWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth);") - height = g.driver.execute_script("return Math.max(document.body.scrollHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);") - g.driver.set_window_size(width,height) - time.sleep(3) - g.driver.get_screenshot_as_file(file_path) - if g.headless: - g.driver.set_window_size(1920,1080) - - -class Snapshot: - def __init__(self): - snapshot_plan = Path('snapshot') / g.plan_name - self.snapshot_folder = snapshot_plan / g.start_time[1:] - snapshot_expected = Path('snapshot') / 'expected' - self.expected_folder = snapshot_expected / g.plan_name - for p in (snapshot_plan, self.snapshot_folder, snapshot_expected, self.expected_folder): - mkdir(p) - - def pre(self, step, label): - self.label = label - self.screen_flag = False - self.element_flag = False - - # 处理输出数据中的截图设置 - self.output = {} - for k, v in dict(step['output'].items()).items(): - if v in ('#screen_shot','#ScreenShot'): - self.output['#screen_shot'] = k - step['output'].pop(k) - self.screen_flag = True - if v == ('#element_shot', '#ElementShot'): - self.output['#element_shot'] = k - step['output'].pop(k) - self.element_flag = True - - # 处理图片比较 - self.expected = {} - for data in (step['data'], step['expected']): - if '#screen_shot' in data: - self.screen_flag = True - p = Path(data['#screen_shot']).stem - self.expected['#screen_name'] = '('+p.split('[')[1].split(']')[0]+')' if '[' in p else p - if Path(data['#screen_shot']).is_file(): - - step['snapshot']['expected_screen'] = data.pop('#screen_shot') - else: - step['snapshot']['expected_screen'] = str(self.expected_folder / data.pop('#screen_shot')) - - if '#element_shot' in data: - self.element_flag = True - p = Path(data['#element_shot']).stem - self.expected['#element_name'] = '('+p.split('[')[1].split(']')[0]+')' if '[' in p else p - if Path(data['#element_shot']).is_file(): - step['snapshot']['expected_element'] = data.pop('#element_shot') - else: - step['snapshot']['expected_element'] = str(self.expected_folder / data.pop('#element_shot')) - - if '#cut' in data: - self.expected['#cut'] = data.pop('#cut') - if '#blank' in data: - self.expected['#blank'] = data.pop('#blank') - - def web_screen(self, step, element): - # 截图 - screen_v = self.output.get('#screen_shot', '') - element_v = self.output.get('#element_shot', '') - - if g.snapshot or self.screen_flag or self.element_flag: - from selenium.webdriver.support import expected_conditions as EC - if not EC.alert_is_present()(g.driver): - if self.expected.get('#screen_name'): - screen_name = self.expected['#screen_name'] - elif screen_v: - screen_name = screen_v - else: - screen_name = '' - if screen_name: - file_name = self.label + now() + '#screen' + '[' + screen_name + ']' + '.png' - else: - file_name = self.label + now() + '#screen' + '.png' - step['snapshot']['real_screen'] = str( - Path(self.snapshot_folder) / file_name) - get_screenshot(step['snapshot']['real_screen']) - if screen_v: - g.var[screen_v] = step['snapshot']['real_screen'] - - if element_v: - file_name = self.label + now() + '#element' + '[' + element_v + ']' + '.png' - step['snapshot']['real_element'] = str(Path(self.snapshot_folder) / file_name) - crop(element, step['snapshot']['real_screen'], step['snapshot']['real_element']) - g.var[element_v] = step['snapshot']['real_element'] - - def web_check(self, step, element): - - def deep(src): - # 把不需要比较的部分贴白 - if self.expected.get('#blank'): - blank(src, eval(self.expected.get('#blank'))) - # 裁剪需要比较的部分 - if self.expected.get('#cut'): - cut(src, src, eval(self.expected.get('#cut'))) - - if Path(step['snapshot'].get('expected_screen', '')).is_file(): - # 屏幕截图比较 - image1 = Image.open(step['snapshot']['expected_screen']) - image2 = Image.open(step['snapshot']['real_screen']) - deep(step['snapshot']['real_screen']) - histogram1 = image1.histogram() - histogram2 = image2.histogram() - differ = math.sqrt(reduce(operator.add, list( - map(lambda a, b: (a - b)**2, histogram1, histogram2))) / len(histogram1)) - diff = ImageChops.difference(image1.convert('RGB'), image2.convert('RGB')) - if differ == 0.0: - # 图片间没有任何不同 - logger.info('SnapShot: screen_shot is the same') - else: - file_name = self.label + now() + 'diff_screen' + '.png' - step['snapshot']['diff_screen'] = str( - Path(self.snapshot_folder) / file_name) - diff.save(step['snapshot']['diff_screen']) - raise Exception('SnapShot: screen_shot is diff: %s' % differ) - elif step['snapshot'].get('expected_screen'): - get_screenshot(step['snapshot']['expected_screen']) - deep(step['snapshot']['expected_screen']) - - if Path(step['snapshot'].get('expected_element', '')).is_file(): - file_name = self.label + now() + '#element' + '[' + self.expected['#element_name'] + ']' + '.png' - step['snapshot']['real_element'] = str(Path(self.snapshot_folder) / file_name) - crop(element, step['snapshot']['real_screen'], step['snapshot']['real_element']) - deep(step['snapshot']['real_element']) - - # 屏幕截图比较 - image1 = Image.open(step['snapshot']['expected_element']) - image2 = Image.open(step['snapshot']['real_element']) - histogram1 = image1.histogram() - histogram2 = image2.histogram() - differ = math.sqrt(reduce(operator.add, list( - map(lambda a, b: (a - b)**2, histogram1, histogram2))) / len(histogram1)) - diff = ImageChops.difference(image1.convert('RGB'), image2.convert('RGB')) - if differ == 0.0: - logger.info('SnapShot: element_shot is the same') - else: - file_name = self.label + now() + 'diff_element' + '.png' - step['snapshot']['diff_element'] = str( - Path(self.snapshot_folder) / file_name) - diff.save(step['snapshot']['diff_element']) - raise Exception('SnapShot: element_shot is diff: %s' % differ) - elif step['snapshot'].get('expected_element'): - crop(element, step['snapshot']['real_screen'], step['snapshot']['expected_element']) - deep(step['snapshot']['expected_element']) - - def web_shot(self, step, element): - self.web_screen(step, element) - self.web_check(step, element) - - - def windwos_capture(self, dialog, step): - # 截图 - screen_v = self.output.get('#screen_shot', '') - element_v = self.output.get('#element_shot', '') - - if g.snapshot or self.screen_flag: - if self.expected.get('#screen_name'): - screen_name = self.expected['#screen_name'] - elif screen_v: - screen_name = screen_v - else: - screen_name = '' - if screen_name: - file_name = self.label + now() + '#screen' + '[' + screen_name + ']' + '.png' - else: - file_name = self.label + now() + '#screen' + '.png' - step['snapshot']['real_screen'] = str(Path(self.snapshot_folder) / file_name) - pic = dialog.capture_as_image() - pic.save(step['snapshot']['real_screen']) - if screen_v: - g.var[screen_v] = step['snapshot']['real_screen'] - - if element_v: - file_name = self.label + now() + '#element' + '[' + element_v + ']' + '.png' - step['snapshot']['real_element'] = str(Path(self.snapshot_folder) / file_name) - - element = step['element'] - if dialog.backend.name == 'win32': - pic = dialog.window(best_match=element).capture_as_image() - elif dialog.backend.name == 'uia': - pic = dialog.child_window(best_match=element).capture_as_image() - pic.save(step['snapshot']['real_screen']) - g.var[element_v] = step['snapshot']['real_element'] - - - def windwos_check(self, dialog, step): - element = step['element'] - if Path(step['snapshot'].get('expected_screen', '')).is_file(): - # 屏幕截图比较 - image1 = Image.open(step['snapshot']['expected_screen']) - image2 = Image.open(step['snapshot']['real_screen']) - histogram1 = image1.histogram() - histogram2 = image2.histogram() - differ = math.sqrt(reduce(operator.add, list( - map(lambda a, b: (a - b)**2, histogram1, histogram2))) / len(histogram1)) - diff = ImageChops.difference(image1.convert('RGB'), image2.convert('RGB')) - if differ == 0.0: - # 图片间没有任何不同 - logger.info('SnapShot: screen_shot is the same') - else: - file_name = self.label + now() + 'diff_screen' + '.png' - step['snapshot']['diff_screen'] = str( - Path(self.snapshot_folder) / file_name) - diff.save(step['snapshot']['diff_screen']) - raise Exception('SnapShot: screen_shot is diff: %s' % differ) - elif step['snapshot'].get('expected_screen'): - pic = dialog.capture_as_image() - pic.save(step['snapshot']['expected_screen']) - - if Path(step['snapshot'].get('expected_element', '')).is_file(): - file_name = self.label + now() + '#element' + '.png' - step['snapshot']['real_element'] = str(Path(self.snapshot_folder) / file_name) - if dialog.backend.name == 'win32': - pic = dialog.window(best_match=element).capture_as_image() - elif dialog.backend.name == 'uia': - pic = dialog.child_window(best_match=element).capture_as_image() - pic.save(step['snapshot']['real_element']) - - # 屏幕截图比较 - image1 = Image.open(step['snapshot']['expected_element']) - image2 = Image.open(step['snapshot']['real_element']) - histogram1 = image1.histogram() - histogram2 = image2.histogram() - differ = math.sqrt(reduce(operator.add, list( - map(lambda a, b: (a - b)**2, histogram1, histogram2))) / len(histogram1)) - diff = ImageChops.difference(image1.convert('RGB'), image2.convert('RGB')) - if differ == 0.0: - logger.info('SnapShot: element_shot is the same') - else: - file_name = self.label + now() + 'diff_element' + '.png' - step['snapshot']['diff_element'] = str( - Path(self.snapshot_folder) / file_name) - diff.save(step['snapshot']['diff_element']) - raise Exception('SnapShot: element_shot is diff: %s' % differ) - elif step['snapshot'].get('expected_element'): - if dialog.backend.name == 'win32': - pic = dialog.window(best_match=element).capture_as_image() - elif dialog.backend.name == 'uia': - pic = dialog.child_window(best_match=element).capture_as_image() - pic.save(step['snapshot']['expected_element']) - - - def windows_shot(self, dialog, step): - self.windwos_capture(dialog, step) - self.windwos_check(dialog, step) \ No newline at end of file diff --git a/sweetest/sweetest/testcase.py b/sweetest/sweetest/testcase.py deleted file mode 100644 index d91cfa5..0000000 --- a/sweetest/sweetest/testcase.py +++ /dev/null @@ -1,283 +0,0 @@ -from time import sleep -from pathlib import Path -import re -from sweetest.log import logger -from sweetest.globals import g, now, timestamp -from sweetest.elements import e -from sweetest.windows import w -from sweetest.snapshot import Snapshot -from sweetest.locator import locating_elements, locating_data, locating_element -from sweetest.keywords import web, common, mobile, http, files -from sweetest.config import web_keywords, common_keywords, mobile_keywords, http_keywords, windows_keywords, files_keywords -from sweetest.utility import replace_dict, replace - - -def elements_format(page, element): - if not page: - page = g.current_page - - if not element: - return page, '', element - - if page in ('SNIPPET', '用例片段') or element in ('变量赋值',): - return page, '', element - - elements = element.split('|') - if len(elements) == 1: - custom, el = e.have(page, element) - g.current_page = page - return page, custom, el - else: - els = [] - for _element in elements: - custom, el = e.have(page, _element.strip()) - els.append(el) - g.current_page = page - return page, custom, els - - -def v_data(d, _d): - data = '' - if ',,' in str(_d): - s = ',,' - else: - s = ',' - - for k, v in d.items(): - data += k + '=' + str(v) + s - - if s ==',,': - return data[:-2] - else: - return data[:-1] - - -def test_v_data(): - data = {'a': 1, 'b': 'b'} - _data = "{'a': 1,, 'b': 'b'}" - return v_data(data, _data) - - -class TestCase: - def __init__(self, testcase): - self.testcase = testcase - self.snippet_steps = {} - - def run(self): - logger.info('>>> Run the TestCase: %s|%s' % - (self.testcase['id'], self.testcase['title'])) - logger.info('-'*50) - self.testcase['result'] = 'success' - self.testcase['report'] = '' - if_result = '' - - for index, step in enumerate(self.testcase['steps']): - # 统计开始时间 - step['start_timestamp'] = timestamp() - step['snapshot'] = {} - - # if 为否,不执行 then 语句 - if step['control'] == '>' and not if_result: - step['score'] = '-' - step['end_timestamp'] = timestamp() - logger.info('Skipped the Step: %s|%s|%s|%s' %(step['no'], step['page'], step['keyword'], step['element'])) - continue - - # if 为真,不执行 else 语句 - if step['control'] == '<' and if_result: - step['score'] = '-' - step['end_timestamp'] = timestamp() - logger.info('Skipped the Step: %s|%s|%s|%s' %(step['no'], step['page'], step['keyword'], step['element'])) - continue - - if g.platform.lower() in ('windows',) and step['keyword'].upper() in windows_keywords: - pass - elif step['keyword'].upper() in files_keywords: - pass - else: - step['page'], step['custom'], step['element'] = elements_format( - step['page'], step['element']) - - label = g.sheet_name + '#' + \ - self.testcase['id'] + '#' + str(step['no']).replace('<', '(').replace('>', ')').replace('*', 'x') - - logger.info('Run the Step: %s|%s|%s|%s' % - (step['no'], step['page'], step['keyword'], step['element'])) - - snap = Snapshot() - try: - after_function = step['data'].pop('AFTER_FUNCTION', '') - - # 处理强制等待时间 - if '#wait_time' in step['data']: - t = step['data'].pop('#wait_time', 0) - sleep(float(t)) - else: - if '#wait_times' in step['data']: - g.wait_times = float(step['data'].pop('#wait_times', 0)) - if g.wait_times: - sleep(g.wait_times) - - if step['page']: - step['page'] = replace(step['page']) - - if isinstance(step['element'], str): - step['element'] = replace(step['element']) - step['_element'] = step['element'] - elif isinstance(step['element'], list): - for i in range(len(step['element'])): - step['element'][i] = replace(step['element'][i]) - step['_element'] = '|'.join(step['element']) - - # 变量替换 - replace_dict(step['data']) - replace_dict(step['expected']) - - step['data'].pop('BEFORE_FUNCTION', '') - - step['vdata'] = v_data(step['data'], step['_data']) - - if g.platform.lower() in ('desktop',) and step['keyword'].upper() in web_keywords: - # 处理截图数据 - snap.pre(step, label) - - if step['keyword'].upper() not in ('MESSAGE', '对话框'): - # 判断页面是否已和窗口做了关联,如果没有,就关联当前窗口,如果已关联,则判断是否需要切换 - w.switch_window(step['page']) - # 切换 frame 处理,支持变量替换 - frame = replace(step['custom']) - w.switch_frame(frame) - - # 根据关键字调用关键字实现 - element = getattr(web, step['keyword'].lower())(step) - snap.web_shot(step, element) - - elif g.platform.lower() in ('ios', 'android') and step['keyword'].upper() in mobile_keywords: - # 切換 context 處理 - context = replace(step['custom']).strip() - w.switch_context(context) - - if w.current_context.startswith('WEBVIEW'): - # 切换标签页 - tab = step['data'].get('标签页') - if tab: - del step['data']['标签页'] - g.driver.switch_to_window(w.windows[tab]) - logger.info('Current Context: %s' % - repr(w.current_context)) - - # 根据关键字调用关键字实现 - getattr(mobile, step['keyword'].lower())(step) - - elif g.platform.lower() in ('windows',) and step['keyword'].upper() in windows_keywords: - from sweetest.keywords import windows - _page = '' - if step['page'].startswith('#'): - _page = step['page'][1:] - page = [x for x in re.split(r'(<|>)', _page) if x !=''] - else: - page = [x for x in re.split(r'(<|>)', step['page']) if x !=''] - - if _page: - dialog = g.windows['#'].dialog(page) - else: - dialog = g.windows['default'].dialog(page) - #dialog.wait('ready') - - snap.pre(step, label) - - # 根据关键字调用关键字实现 - getattr(windows, step['keyword'].lower())(dialog, step) - snap.windows_shot(dialog, step) - - elif step['keyword'].upper() in http_keywords: - # 根据关键字调用关键字实现 - getattr(http, step['keyword'].lower())(step) - - elif step['keyword'].upper() in files_keywords: - # 根据关键字调用关键字实现 - getattr(files, step['keyword'].lower())(step) - - elif step['keyword'].lower() == 'execute': - result, steps = getattr( - common, step['keyword'].lower())(step) - if step['page'] in ('SNIPPET', '用例片段'): - self.snippet_steps[index + 1] = steps - if result != 'success': - self.testcase['result'] = result - step['end_timestamp'] = timestamp() - break - - # elif step['page'] in ('SCRIPT', '脚本'): - # # 判断页面是否已和窗口做了关联,如果没有,就关联当前窗口,如果已关联,则判断是否需要切换 - # w.switch_window(step['page']) - # # 切换 frame 处理,支持变量替换 - # frame = replace(step['custom']) - # w.switch_frame(frame) - # common.script(step) - - else: - # 根据关键字调用关键字实现 - getattr(common, step['keyword'].lower())(step) - logger.info('>>>>>>>>>>>>>>>>>>>> success <<<<<<<<<<<<<<<<<<<<') - step['score'] = 'OK' - - # if 语句结果赋值 - if step['control'] == '^': - if_result = True - - if after_function: - replace_dict({'after_function': after_function}) - # 操作后,等待0.2秒 - sleep(0.2) - except Exception as exception: - - if g.platform.lower() in ('desktop',) and step['keyword'].upper() in web_keywords: - file_name = label + now() + '#Failure' +'.png' - step['snapshot']['Failure'] = str(snap.snapshot_folder / file_name) - try: - if w.frame != 0: - g.driver.switch_to.default_content() - w.frame = 0 - g.driver.get_screenshot_as_file(step['snapshot']['Failure']) - except: - logger.exception( - '*** save the screenshot is failure ***') - - elif g.platform.lower() in ('ios', 'android') and step['keyword'].upper() in mobile_keywords: - file_name = label + now() + '#Failure' +'.png' - step['snapshot']['Failure'] = str(snap.snapshot_folder / file_name) - try: - g.driver.switch_to.context('NATIVE_APP') - w.current_context = 'NATIVE_APP' - g.driver.get_screenshot_as_file(u'%s' %step['snapshot']['Failure']) - except: - logger.exception('*** save the screenshot is failure ***') - - logger.exception('Exception:') - logger.error('>>>>>>>>>>>>>>>>>>>> failure <<<<<<<<<<<<<<<<<<<<') - step['score'] = 'NO' - - step['remark'] += str(exception) - step['end_timestamp'] = timestamp() - - # if 语句结果赋值 - if step['control'] == '^': - if_result = False - continue - - self.testcase['result'] = 'failure' - self.testcase['report'] = 'step-%s|%s|%s: %s' % ( - step['no'], step['keyword'], step['element'], exception) - break - - # 统计结束时间 - step['end_timestamp'] = timestamp() - - steps = [] - i = 0 - for k in self.snippet_steps: - steps += self.testcase['steps'][i:k] + self.snippet_steps[k] - i = k - steps += self.testcase['steps'][i:] - self.testcase['steps'] = steps diff --git a/sweetest/sweetest/testsuite.py b/sweetest/sweetest/testsuite.py deleted file mode 100644 index 0e258b3..0000000 --- a/sweetest/sweetest/testsuite.py +++ /dev/null @@ -1,232 +0,0 @@ -import time -from copy import deepcopy -from sweetest.globals import g, timestamp -from sweetest.windows import w -from sweetest.testcase import TestCase -from sweetest.log import logger -from sweetest.utility import replace - - -class TestSuite: - def __init__(self, testsuite, sheet_name, report, conditions={}): - self.testsuite = testsuite - self.sheet_name = sheet_name - self.report = report - self.conditions = conditions - self.result = {} - - # base 在整个测试套件中首先执行一次 - self.base_testcase = {} - # setup 在每个测试用例执行之前执行一次 - self.setup_testcase = {} - for testcase in self.testsuite: - if testcase['condition'].lower() == 'base': - self.base_testcase = testcase - elif testcase['condition'].lower() == 'setup': - self.setup_testcase = testcase - testcase['flag'] = 'N' # setup 用例只在执行其他普通用例之前执行 - elif testcase['condition'].lower() == 'snippet': - g.snippet[testcase['id']] = testcase - testcase['flag'] = 'N' - elif testcase.get('set'): - testcase['flag'] = 'N' - if testcase['set'] not in g.caseset: - g.caseset[testcase['set']] = [testcase] - else: - g.caseset[testcase['set']].append(testcase) - - def testsuite_start(self): - self.result['no'] = g.no - g.no += 1 - self.result['testsuite'] = self.sheet_name - self.result['start_timestamp'] = timestamp() - - def testsuite_end(self): - self.result['end_timestamp'] = timestamp() - g.testsuite_data[self.sheet_name] = self.result - - def setup(self, testcase, case): - if self.setup_testcase: - logger.info('*** SETUP testcase ↓ ***') - logger.info('-'*50) - else: - logger.info('...No SETUP testcase need to run...') - - def run_setup(testcase): - if testcase: - tc = TestCase(testcase) - tc.run() - if testcase['result'] == 'success': - flag = 'Y' - else: - flag = 'N' - else: - flag = 'O' - return flag - setup_flag = run_setup(deepcopy(self.setup_testcase)) - - if setup_flag == 'N': - base_flag = run_setup(deepcopy(self.base_testcase)) - if base_flag == 'Y': - setup_flag = run_setup(deepcopy(self.setup_testcase)) - if setup_flag == 'N': - testcase['result'] = 'blocked' - case.block('Blocked', 'SETUP is not success') - logger.info('-'*50) - logger.info(f'>>> Run the testcase: {testcase["id"]}|{testcase["title"]}') - logger.warn('>>>>>>>>>>>>>>>>>>>> blocked <<<<<<<<<<<<<<<<<<<< SETUP is not success') - return False - elif base_flag == 'O': - testcase['result'] = 'blocked' - case.block('Blocked', 'SETUP is not success') - logger.info('-'*50) - logger.info(f'>>> Run the testcase: {testcase["id"]}|{testcase["title"]}') - logger.warn('>>>>>>>>>>>>>>>>>>>> blocked <<<<<<<<<<<<<<<<<<<< SETUP is not success') - return False - - return True - - def run_testcase(self, testcase): - # 根据筛选条件,把不需要执行的测试用例跳过 - flag = False - for k, v in self.conditions.items(): - if not isinstance(v, list): - v = [v] - if testcase[k] not in v: - testcase['result'] = 'skipped' - flag = True - if flag: - return - - if testcase['condition'].lower() == 'base': - logger.info('*** BASE testcase ↓ ***') - - if testcase['condition'].lower() == 'setup': - return - - # 统计开始时间 - testcase['start_timestamp'] = timestamp() - # xml 测试报告-测试用例初始化 - if testcase['flag'] != 'N': - # 如果前置条件失败了,直接设置为阻塞 - if self.blocked_flag: - testcase['result'] = 'blocked' - testcase['end_timestamp'] = timestamp() - return - - case = self.report.create_case( - testcase['title'], testcase['id']) - case.start() - case.priority = testcase['priority'] - # 用例上下文 - self.previous = self.current - self.current = testcase - else: - testcase['result'] = 'skipped' - # case.skip('Skip', 'Autotest Flag is N') - # logger.info('Run the testcase: %s|%s skipped, because the flag=N or the condition=snippet' % ( - # testcase['id'], testcase['title'])) - # 统计结束时间 - testcase['end_timestamp'] = timestamp() - return - - if testcase['condition'].lower() not in ('base', 'setup'): - if testcase['condition'].lower() == 'sub': - if self.previous['result'] != 'success': - testcase['result'] = 'blocked' - case.block( - 'Blocked', 'Main or pre Sub testcase is not success') - logger.info('-'*50) - logger.info(f'>>> Run the testcase: {testcase["id"]}|{testcase["title"]}') - logger.warn('>>>>>>>>>>>>>>>>>>>> blocked <<<<<<<<<<<<<<<<<<<< Main or pre Sub TestCase is not success') - # 统计结束时间 - testcase['end_timestamp'] = timestamp() - return - # 如果前置条件为 skip,则此用例不执行前置条件 - elif testcase['condition'].lower() == 'skip': - pass - else: - result = self.setup(testcase, case) - # if result == 'N': - if not result: - # 统计结束时间 - testcase['end_timestamp'] = timestamp() - return - - try: - testcase['title'] = replace(testcase['title']) # 在多次跑用例集合时,可以在用例标题中使用变量区分 - testcase['id'] = replace(testcase['id']) # 在多次跑用例集合时,可以在用例 id 中使用变量区分 - tc = TestCase(testcase) - logger.info('-'*50) - tc.run() - - # 统计结束时间 - testcase['end_timestamp'] = timestamp() - - if testcase['result'] == 'success': - case.succeed() - elif testcase['result'] == 'failure': - case.fail('Failure', testcase['report']) - if testcase['condition'].lower() == 'base': - logger.warn('Run the testcase: %s|%s Failure, BASE is not success. Break the AutoTest' % ( - testcase['id'], testcase['title'])) - self.blocked_flag = True - return - if testcase['condition'].lower() == 'setup': - logger.warn('Run the testcase: %s|%s failure, SETUP is not success. Break the AutoTest' % ( - testcase['id'], testcase['title'])) - self.blocked_flag = True - return - except Exception as exception: - case.error('Error', 'Remark:%s |||Exception:%s' % - (testcase['remark'], exception)) - logger.exception('Run the testcase: %s|%s failure' % - (testcase['id'], testcase['title'])) - if testcase['condition'].lower() == 'base': - logger.warn('Run the testcase: %s|%s error, BASE is not success. Break the AutoTest' % ( - testcase['id'], testcase['title'])) - self.blocked_flag = True - return - if testcase['condition'].lower() == 'setup': - logger.warn('Run the testcase: %s|%s error, SETUP is not success. Break the AutoTest' % ( - testcase['id'], testcase['title'])) - self.blocked_flag = True - return - - - def run(self): - - self.testsuite_start() - - # 当前测试用例 - self.current = {'result': 'success'} - # 上一个测试用例 - self.previous = {} - - # 前置条件执行失败标志,即未执行用例阻塞标志 - self.blocked_flag = False - - for testcase in self.testsuite: - self.run_testcase(testcase) - - for key in g.db: - try: - g.db[key].connect.close() - except: - pass - - self.report.finish() - self.testsuite += g.casesets # 把用例组合执行结果添加到末尾 - - # 2.清理环境 - try: - if g.platform.lower() in ('desktop',): - # w.close() - g.driver.quit() - logger.info('--- Quit th Driver: %s' % g.browserName) - except: - logger.exception('Clear the env is fail') - - self.testsuite_end() - - diff --git a/sweetest/sweetest/utility.py b/sweetest/sweetest/utility.py deleted file mode 100644 index 7239c8b..0000000 --- a/sweetest/sweetest/utility.py +++ /dev/null @@ -1,492 +0,0 @@ -from selenium.webdriver.common.keys import Keys -from pathlib import Path -import xlrd -import xlsxwriter -from openpyxl import Workbook, load_workbook -import csv -import re -import json -import time -import random -from sweetest.config import header -from sweetest.globals import g - - -path = Path('lib') -if path.is_dir(): - from lib import * -else: - from sweetest.lib import * - - -class Excel: - def __init__(self, file_name, mode='r'): - if mode == 'r': - self.workbook = xlrd.open_workbook(file_name) - elif mode == 'w': - self.workbook = xlsxwriter.Workbook(file_name) - else: - raise Exception( - 'Error: init Excel class with error mode: %s' % mode) - - def get_sheet(self, sheet_name): - names = [] - if isinstance(sheet_name, str): - if sheet_name.endswith('*'): - for name in self.workbook.sheet_names(): - if sheet_name[:-1] in name: - names.append(name) - else: - names.append(sheet_name) - elif isinstance(sheet_name, list): - names = sheet_name - else: - raise Exception('Error: invalidity sheet_name: %s' % sheet_name) - - return names - - def read(self, sheet_name): - ''' - sheet_name:Excel 中标签页名称 - return:[[],[]……] - ''' - sheet = self.workbook.sheet_by_name(sheet_name) - nrows = sheet.nrows - data = [] - for i in range(nrows): - data.append(sheet.row_values(i)) - return data - - def write(self, data, sheet_name): - sheet = self.workbook.add_worksheet(sheet_name) - - red = self.workbook.add_format({'bg_color': 'red', 'color': 'white'}) - gray = self.workbook.add_format({'bg_color': 'gray', 'color': 'white'}) - green = self.workbook.add_format( - {'bg_color': 'green', 'color': 'white'}) - blue = self.workbook.add_format({'bg_color': 'blue', 'color': 'white'}) - orange = self.workbook.add_format( - {'bg_color': 'orange', 'color': 'white'}) - for i in range(len(data)): - for j in range(len(data[i])): - if str(data[i][j]) == 'failure': - sheet.write(i, j, str(data[i][j]), red) - elif str(data[i][j]) == 'NO': - sheet.write(i, j, str(data[i][j]), gray) - elif str(data[i][j]) == 'blocked': - sheet.write(i, j, str(data[i][j]), orange) - elif str(data[i][j]) == 'skipped': - sheet.write(i, j, str(data[i][j]), blue) - elif str(data[i][j]) == 'success': - sheet.write(i, j, str(data[i][j]), green) - else: - sheet.write(i, j, data[i][j]) - - def close(self): - self.workbook.close() - - -class Excel_01(): - def __init__(self, file_name, mode='r'): - if mode == 'r': - self.workbook = load_workbook(file_name) - elif mode == 'w': - self.workbook = Workbook() #.Workbook(file_name) - else: - raise Exception( - 'Error: init Excel class with error mode: %s' % mode) - - def get_sheet(self, sheet_name): - names = [] - if isinstance(sheet_name, str): - if sheet_name.endswith('*'): - for name in self.workbook.sheet_names(): - if sheet_name[:-1] in name: - names.append(name) - else: - names.append(sheet_name) - elif isinstance(sheet_name, list): - names = sheet_name - else: - raise Exception('Error: invalidity sheet_name: %s' % sheet_name) - - return names - - def read(self, sheet_name): - ''' - sheet_name:Excel 中标签页名称 - return:[[],[]……] - ''' - sheet = self.workbook.sheet_by_name(sheet_name) - nrows = sheet.nrows - data = [] - for i in range(nrows): - data.append(sheet.row_values(i)) - return data - - def write(self, data, sheet_name): - sheet = self.workbook.add_worksheet(sheet_name) - - red = self.workbook.add_format({'bg_color': 'red', 'color': 'white'}) - gray = self.workbook.add_format({'bg_color': 'gray', 'color': 'white'}) - green = self.workbook.add_format( - {'bg_color': 'green', 'color': 'white'}) - blue = self.workbook.add_format({'bg_color': 'blue', 'color': 'white'}) - orange = self.workbook.add_format( - {'bg_color': 'orange', 'color': 'white'}) - for i in range(len(data)): - for j in range(len(data[i])): - if str(data[i][j]) == 'failure': - sheet.write(i, j, str(data[i][j]), red) - elif str(data[i][j]) == 'NO': - sheet.write(i, j, str(data[i][j]), gray) - elif str(data[i][j]) == 'blocked': - sheet.write(i, j, str(data[i][j]), orange) - elif str(data[i][j]) == 'skipped': - sheet.write(i, j, str(data[i][j]), blue) - elif str(data[i][j]) == 'success': - sheet.write(i, j, str(data[i][j]), green) - else: - sheet.write(i, j, data[i][j]) - - def close(self): - self.workbook.close() - - -def data2dict(data): - # def list_list2list_dict(data): - ''' - 把带头标题的二维数组,转换成以标题为 key 的 dict 的 list - ''' - list_dict_data = [] - key = [] - g.header_custom = {} # 用户自定义的标题 - for d in data[0]: - k = d.strip().split('#')[0] - # 如果为中文,则替换成英文 - h = header.get(k, k).lower() - key.append(h) - g.header_custom[h] = d.strip() - - if not g.header_custom.get('expected'): - g.header_custom['expected'] = '' - - for d in data[1:]: - dict_data = {} - for i in range(len(key)): - if isinstance(d[i], str): - dict_data[key[i]] = str(d[i]).strip() - else: - dict_data[key[i]] = d[i] - list_dict_data.append(dict_data) - return list_dict_data - - -def replace_dict(data): - # 变量替换 - for key in data: - data[key] = replace(data[key]) - - -def replace_list(data): - # 变量替换 - for i in range(len(data)): - data[i] = replace(data[i]) - - -def replace_old(data): - # 正则匹配出 data 中所有 <> 中的变量,返回列表 - keys = re.findall(r'<(.*?)>', data) - for k in keys: - # 正则匹配出 k 中的 + - ** * // / % , ( ) 返回列表 - values = re.split(r'(\+|-|\*\*|\*|//|/|%|,|\(|\)|\'|\")', k) - for j, v in enumerate(values): - #切片操作处理,正则匹配出 [] 中内容 - s = v.split('[', 1) - index = '' - if len(s) == 2: - v = s[0] - index = '[' + s[1] - - if v in g.var: - # 如果在 g.var 中是 list - if isinstance(g.var[v], list): - if index: - # list 切片取值(值应该是动态赋值的变量,如自定义脚本的返回值) - values[j] = eval('g.var[v]' + index) - else: - if len(g.var[v]) == 1: - values[j] = g.var[v][0] - g.var['_last_'] = True - else: - values[j] = g.var[v].pop(0) - elif isinstance(g.var[v], dict) and index: - # 如果是 dict 取键值 - values[j] = eval('g.var[v]' + index) - else: - # 如果在 g.var 中是值,则直接赋值 - values[j] = g.var[v] - if index: - values[j] = eval('g.var[v]' + index) - # 如果值不在 g.var,且只有一个元素,则尝试 eval,比如,,<1>,<9.999> - elif len(values) == 1: - try: - values[j] = eval(values[j]) - except: - pass - - # 如果 values 长度大于 1,说明有算术运算符,则用 eval 运算 - # 注意,先要把元素中的数字变为字符串 - if len(values) > 1: - values = eval(''.join([str(x) for x in values])) - # 如果 values 长度为 1,则直接赋值,注意此值可能是数字 - else: - values = values[0] - # 如果 data 就是一个 <>,如 data = '',则直接赋值为 values,此值可能是数字 - if data == '<' + keys[0] + '>': - data = values - # 如果有键盘操作,则需要 eval 处理 - if isinstance(data, str) and 'Keys.' in data: - data = eval(data) - # 否则需要替换,此时变量强制转换为为字符串 - else: - data = data.replace('<' + k + '>', str(values)) - return data - - -def replace(data): - - if data.startswith("'''") and data.endswith("'''"): - return data[3:-3] - - left_angle = 'dsfw4rwfdfstg43' - right_angle = '3dsdtgsgt43trfdf' - left_delimiter = '<' - right_delimiter = '>' - data = data.replace(r'\<', left_angle).replace(r'\>', right_angle) - - if '<<' in data and '>>' in data: - left_delimiter = '<<' - right_delimiter = '>>' - - # 正则匹配出 data 中所有 <> 中的变量,返回列表 - keys = re.findall(r'%s' %(left_delimiter+'(.*?)'+right_delimiter), data) - _vars = {} - - for k in keys: - k = k.replace(left_angle, '<').replace(right_angle, '>') - # 正则匹配出 k 中的 + - ** * // / % , ( ) 返回列表 - values = re.split(r'(\+|-|\*\*|\*|//|/|%|,|\(|\))', k) - for v in values: - #切片操作处理,正则匹配出 [] 中内容 - s = v.split('[', 1) - index = '' - if len(s) == 2: - v = s[0] - index = '[' + s[1] - - if v not in _vars: - if v in g.var: - # 如果在 g.var 中是值,则直接赋值 - _vars[v] = g.var[v] - elif v in g.test_data: - # 是测试数据文件中的值 - if len(g.test_data[v]) == 0: # 为空 list 报错 - raise Exception('The key:%s is no value in data csv' %v) - elif len(g.test_data[v]) == 1: # 最后一个 list 元素,赋值 - _vars[v] = g.test_data[v][0] - g.var['_last_'] = True # 处理循环次数为 N 的情况,此标志标明循环到最后一个变量 - else: # 大于 1 个元素,pop 赋值 - _vars[v] = g.test_data[v].pop(0) - - try: - value = eval(k, globals(), _vars) - except NameError: - value = left_delimiter+ k + right_delimiter - - if data == left_delimiter+ keys[0] + right_delimiter: - data = value - # 否则需要替换,此时变量强制转换为为字符串 - else: - data = data.replace(left_delimiter + k + right_delimiter, str(value)) - - if isinstance(data, str): - data = data.replace(left_angle, '<').replace(right_angle, '>') - - return data - - -def test_replace(): - g.var = {'a': 1, 'b': 'B'} - for d in ('', '', 'abc', 'abc', '', ''): - print('data:%s' % d) - data = replace(d) - print(repr(data)) - - -def read_csv(csv_file, encoding=None): - data = [] - with open(csv_file, encoding=encoding) as f: - reader = csv.reader(f) - for line in reader: - data.append(line) - return data - - -def write_csv(csv_file, data, encoding=None): - with open(csv_file, 'w', encoding=encoding, newline='') as f: - writer = csv.writer(f) - writer.writerows(data) - - -def get_record(data_file): - encoding = None - try: - data = read_csv(data_file, encoding='utf-8') - encoding = 'utf-8' - except: - data = read_csv(data_file) - - def read_data(): - num = len(data[0])-1 if flag else len(data[0]) - for i in range(num): - if d[i]: - k = data[0][i] - if record.get(k, None): - # if isinstance(record[k], str): - # record[k] = [record[k]] - record[k].append(d[i]) - else: - record[k] = [d[i]] - if record[k][-1] == '"': # 空字符转换 - record[k][-1] = '' - - record = {} - flag = False - if data[0][-1].lower() == 'flag': - flag = True - - for d in data[1:]: - if not flag: - read_data() - elif d[-1] == 'N': - read_data() - elif d[-1] != 'Y': - read_data() - d[-1] = 'Y' - write_csv(data_file, data, encoding=encoding) - break - return record - - -def str2int(s): - s = str(s).replace(',', '').split('.', 1) - if len(s) == 2: - dot = s[-1] - assert int(dot) == 0 - return int(s[0]) - - -def zero(s): - if s and s[-1] == '0': - s = s[:-1] - s = zero(s) - return s - - -def str2float(s, n=None): - s = str(s).replace(',', '') - number = s.split('.', 1) - if n: - f = float(s) - return round(f, n), n - - dot = '0' - if len(number) == 2: - dot = number[-1] - dot = zero(dot) - f = float(number[0] + '.' + dot) - - return round(f, len(dot)), len(dot) - - -def f(v, e, n=2): - ''' - 判断2个 float 数值是否相同,类型可以是 str 或 float - v: 实际值,如:12.345, '1234.56', '1,234.5600' - e: 预期结果, 示例值同 v - n: 小数点精确位数 - ''' - v = str(v).replace(',', '') - e = str(e).replace(',', '') - _v = round(float(v), n) - _e = round(float(e), n) - assert round(_v, n) == round(_e, n) - - -def mkdir(p): - path = Path(p) - if not path.is_dir(): - path.mkdir() - - -def json2dict(s): - if isinstance(s, dict): - return s - s = str(s) - d = {} - try: - d = json.loads(s) - except: - try: - d = eval(s) - except: - s = s.replace('true', 'True').replace('false', 'False').replace( - 'null', 'None').replace('none', 'None') - d = eval(s) - return d - - -def compare(data, real): - if isinstance(data, str): - - if data.startswith('#'): - assert data[1:] != str(real) - return - elif data.startswith(':'): - exec('v=real;'+data[1:]) - return - - assert isinstance(real, str) - - if data.startswith('*'): - assert data[1:] in real - return - elif data.startswith('^'): - assert real.startswith(data[1:]) - return - elif data.startswith('$'): - assert real.endswith(data[1:]) - return - - elif data.startswith('\\'): - data = data[1:] - - assert data == real - - elif isinstance(data, int): - assert isinstance(real, int) - assert data == real - elif isinstance(data, float): - assert isinstance(real, float) - data, p1 = str2float(data) - real, p2 = str2float(real) - p = min(p1, p2) - assert round(data, p) == round(real, p) - else: - assert data == real - - -def timestamp(): - # js 格式的时间戳 - return int(time.time() * 1000) diff --git a/sweetest/sweetest/windows.py b/sweetest/sweetest/windows.py deleted file mode 100644 index 11d4f09..0000000 --- a/sweetest/sweetest/windows.py +++ /dev/null @@ -1,148 +0,0 @@ -from sweetest.globals import g -from sweetest.log import logger - - -class Windows: - def __init__(self): - self.init() - - def init(self): - # 当前窗口名字,如:'新版门户首页窗口', 'HOME' - self.current_window = '' - # 所有窗口名字--窗口handle映射表,如: - # {'新版门户首页窗口': 'CDwindow-3a12c86f-1986-4c02-ba7b-5a0ed94c5963', 'HOME': 'CDwindow-a3f0c44c-d269-4ff0-af38-c31ad70c69e3'} - self.windows = {} - # 当前frame名字 - self.frame = 0 - # 所有页面--窗口名字映射表,如:{'门户首页': '新版门户首页窗口'} - self.pages = {} - # 新开窗口标志 - self.new_window_flag = True - # App context - self.current_context = 'NATIVE_APP' - - def switch_window(self, page): - all_handles = g.driver.window_handles - for key in list(self.windows.keys()): - if self.windows[key] not in all_handles: - self.current_window = '' - self.windows.pop(key) - - if self.new_window_flag: - if page in list(self.pages): - page = '通用' - g.current_page = '通用' - self.new_window_flag = False - - if page != '通用': - if page not in list(self.pages): - # 如果当前页未注册,则需要先清除和当前窗口绑定的页面 - for k in list(self.pages): - if self.current_window == self.pages[k]: - self.pages.pop(k) - # 在把当前窗口捆定到当前页面 - self.pages[page] = self.current_window - - elif self.pages[page] != self.current_window: - # 如果当前窗口为 HOME,则关闭之 - if self.current_window == 'HOME': - g.driver.close() - self.windows.pop('HOME') - # 再切换到需要操作的窗口 - tw = self.windows[self.pages[page]] - logger.info('--- Switch Windows: %s' % repr(tw)) - g.driver.switch_to_window(tw) - self.current_window = self.pages[page] - logger.info('--- Current Windows: %s' % - repr(self.current_window)) - - def switch_frame(self, frame): - if frame.strip(): - frame = [x.strip() for x in frame.split('|')] - if frame != self.frame: - if self.frame != 0: - g.driver.switch_to.default_content() - for f in frame: - logger.info('--- Frame Value: %s' % repr(f)) - if f.startswith('#'): - f = int(f[1:]) - elif '#' in f: - from sweetest.testcase import elements_format - from sweetest.locator import locating_element - element = elements_format('通用', f)[2] - f = locating_element(element) - logger.info('--- Switch Frame: %s' % repr(f)) - g.driver.switch_to.frame(f) - self.frame = frame - else: - if self.frame != 0: - g.driver.switch_to.default_content() - self.frame = 0 - - def open(self, step): - # 查看当前窗口是否已经注册到 windows 映射表 - c = self.windows.get(self.current_window, '') - # 如果已经存在,则需要清除和当前窗口绑定的页面 - if c: - for k in list(self.pages): - if self.current_window == self.pages[k]: - self.pages.pop(k) - # 并从映射表里剔除 - self.windows.pop(self.current_window) - - # 获取当前窗口handle - handle = g.driver.current_window_handle - # 注册窗口名称和handle - self.register(step, handle) - - def register(self, step, handle): - # 如果有提供新窗口名字,则使用该名字,否则使用默认名字:HOME - #new_window = step['data'].get('新窗口', 'HOME') - # 新窗口 变为 标签页名,兼容原有格式 - new_window = 'HOME' - for k in ('新窗口', '标签页名', 'tabname', '#tab_name'): - if step['data'].get(k): - new_window = step['data'].get(k) - # 已存在同名的窗口,则 - if new_window in self.windows: - # 1. 清除和当前窗口同名的旧窗口绑定的页面 - for k in list(self.pages): - if new_window == self.pages[k]: - self.pages.pop(k) - - # 2. 切换到同名旧窗口去关闭它 - g.driver.switch_to_window(self.windows[new_window]) - g.driver.close() - # 3. 并从窗口资源池 g.windows 里剔除 - self.windows.pop(new_window) - # 然后切回当前窗口 - g.driver.switch_to_window(handle) - # 再添加到窗口资源池 g.windows - self.windows[new_window] = handle - # 把当前窗口名字改为新窗口名称 - self.current_window = new_window - # 新窗口标志置为是 - self.new_window_flag = True - - def close(self): - all_handles = g.driver.window_handles - for handle in all_handles: - # 切换到每一个窗口,并关闭它 - g.driver.switch_to_window(handle) - g.driver.close() - logger.info('--- Close th Windows: %s' % repr(handle)) - - def switch_context(self, context): - if context.strip() == '': - context = 'NATIVE_APP' - # logger.info('--- ALL Contexts:%s' % g.driver.contexts) - # logger.info('--- Input Context:%s' % repr(context)) - if context != self.current_context: - if context == '': - context = None - logger.info('--- Switch Context:%s' % repr(context)) - g.driver.switch_to.context(context) - self.current_context = context - - -w = Windows() diff --git a/sweetest/testcase/Baidu-TestCase.xlsx b/sweetest/testcase/Baidu-TestCase.xlsx deleted file mode 100644 index 69a4f34987f87b9e7814d1d159d1214db2b8f977..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12536 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 Q0H8xZ3{V<3GXJ>yA3omzIRF3v diff --git a/sweetest/testcase/Notepad-TestCase.xlsx b/sweetest/testcase/Notepad-TestCase.xlsx deleted file mode 100644 index e34898ec9345ee98d5d810d8505b1290c91c25d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11446 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