├── .gitattributes ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── TESTING.md ├── requirements.txt ├── rfhub ├── __init__.py ├── __main__.py ├── app.py ├── blueprints │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── keywords.py │ │ └── libraries.py │ ├── dashboard │ │ ├── __init__.py │ │ └── templates │ │ │ └── dashboard.html │ └── doc │ │ ├── __init__.py │ │ ├── static │ │ ├── doc.css │ │ └── doc.js │ │ └── templates │ │ ├── base.html │ │ ├── home.html │ │ ├── library.html │ │ ├── libraryNames.html │ │ ├── search.html │ │ └── twocolumn.html ├── kwdb.py ├── static │ ├── css │ │ └── bootstrap.min.css │ ├── favicon.ico │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff │ └── js │ │ ├── bootstrap.js │ │ ├── bootstrap.min.js │ │ ├── jquery.min.js │ │ ├── lodash.min.js │ │ └── query-string.js └── version.py ├── setup.py └── tests ├── README.md ├── acceptance ├── __init__.robot ├── api │ ├── __init__.robot │ ├── query.robot │ └── smoke.robot ├── doc │ ├── __init__.robot │ ├── coreFeatures.robot │ └── search.robot ├── option_root.robot ├── options.robot └── space-separated.robot ├── conf └── default.args ├── keywords ├── APIKeywords.robot ├── KWDBKeywords.robot └── miscKeywords.robot └── unit ├── data ├── onekeyword.robot ├── threekeywords.resource └── twokeywords.robot └── kwdb.robot /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | # See https://help.github.com/articles/dealing-with-line-endings/ 3 | * text=auto 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Django stuff: 41 | *.log 42 | *.pot 43 | 44 | # Sphinx documentation 45 | docs/_build/ 46 | 47 | # emacs crud 48 | \#*\# 49 | \.\#* 50 | *.elc 51 | *~ 52 | 53 | # robot logs. 54 | log.html 55 | report.html 56 | output.xml 57 | selenium-screenshot-*.png 58 | 59 | # mac crud 60 | .DS_Store 61 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.6 4 | sudo: required 5 | dist: trusty 6 | addons: 7 | apt: 8 | sources: 9 | - google-chrome 10 | packages: 11 | - google-chrome-stable 12 | # - chromium-chromedriver 13 | install: 14 | - pip install -r requirements.txt 15 | - python setup.py develop 16 | before_script: 17 | - wget "http://chromedriver.storage.googleapis.com/2.46/chromedriver_linux64.zip" 18 | - unzip chromedriver_linux64.zip 19 | - sudo mv chromedriver /usr/local/bin 20 | - "export DISPLAY=:99.0" 21 | - "sh -e /etc/init.d/xvfb start" 22 | - sleep 3 23 | script: 24 | - robot -A tests/conf/default.args tests 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.0 - 2019-12-29 4 | - Now requires python 3 to run 5 | - Upgraded Jinja2 due to security vulnerability 6 | - deprecated --root option; /doc is now the default root 7 | 8 | ### Issues closed 9 | - Issue #78 - Need to support python3 10 | 11 | ## 0.8.3 - 2015-06-26 12 | 13 | ### Issues closed 14 | - Issue #44 - Ctrl-c won't stop the hub on windows 15 | - Issue #45 - add -P/--pythonpath option 16 | - Issue #46 - add -A/--argumentfile option 17 | 18 | ### Other changes 19 | - status of this package has been upgraded to "Beta" 20 | 21 | ## 0.7 - 2015-03-18 22 | 23 | The big change is support for multiple resource or library files 24 | with the same name. 25 | 26 | This has a backward-incompatibility. URLs used to be of the form 27 | /doc/library_name/keyword_name, but that prevented you from having 28 | more than one library or resource file with the same name. In this 29 | version, each library or resource file is assigned an id, and that 30 | is used in place of the library name. 31 | 32 | The id is guaranteed to be unique while rfhub is running, but could 33 | change when you stop and restart the hub. 34 | 35 | ### Issues closed 36 | - Issue #41 - Files with duplicate names aren't handled properly 37 | - Issue #17 - should be able to search for keywords by name 38 | 39 | ### Other changes 40 | - Version and scope are only displayed for libraries 41 | - For resource files, the file path is displayed for a particular 42 | resource file in place of the version and scope 43 | - The file path appears as a tooltip over names in the nav panel 44 | - Added -v/--version command line argument 45 | - Use bullet points next to keyword names in nav panel 46 | - This CHANGELOG.md file was added 47 | 48 | ## 0.6 - 2014-10-19 49 | 50 | ### Issues closed 51 | - Issue #37 - support for .tsv files 52 | - Issue #18 - "in:" for search 53 | 54 | ## 0.5 - 2014-08-29 55 | 56 | ### Issues closed 57 | - Issue #33 - keyword queries can specify which fields to return 58 | 59 | ### Other changes 60 | - added favicon 61 | - switch to tornado web server rather than flask dev server 62 | 63 | ## 0.4 - 2014-08-24 64 | 65 | ### Other changes 66 | - incremental search 67 | - added the id attribute so that the pages are easier to test 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include rfhub/blueprints/doc/static * 3 | recursive-include rfhub/blueprints/doc/templates *.html 4 | recursive-include rfhub/blueprints/dashboard/templates *.html 5 | recursive-include rfhub/static * 6 | recursive-exclude rfhub *~ #*# 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Robot Framework Hub 2 | 3 | This project implements a simple web server for viewing robot 4 | framework keyword documentation. This uses flask to provide 5 | both a RESTful interface and a browser-based UI for accessing 6 | test assets. 7 | 8 | It's crazy easy to get started. To install and run from a PyPi 9 | package, do the following: 10 | 11 | ``` 12 | $ pip install robotframework-hub 13 | $ python -m rfhub 14 | ``` 15 | 16 | Note: robotframework-hub requires python 3.6 or greater 17 | 18 | To run from source it's the same, except that instead of 19 | installing, you cd to the folder that has this file. 20 | 21 | That's it! You can now browse documentation by visiting the url 22 | http://localhost:7070/doc/ 23 | 24 | Want to browse your local robotframework assets? Just include 25 | the path to your test suites or resource files on the command 26 | line: 27 | 28 | ``` 29 | $ python -m rfhub /path/to/test/suite 30 | ``` 31 | 32 | 33 | ## Websites 34 | 35 | Source code, screenshots, and additional documentation can be found here: 36 | 37 | * Source code: https://github.com/boakley/robotframework-hub 38 | * Project wiki: https://github.com/boakley/robotframework-hub/wiki 39 | 40 | ## Acknowledgements 41 | 42 | A huge thank-you to Echo Global Logistics (echo.com) for supporting 43 | the development of this package. 44 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | To run the acceptance tests, cd to the folder with this file and run the following command: 2 | 3 | robot -A tests/conf/default.args tests 4 | 5 | If you want to run a single suite, you can use the --suite option. For example, 6 | either of the following commands will run just the search suite: 7 | 8 | robot -A tests/conf/default.args --suite tests.acceptance.doc.search tests 9 | robot -A tests/conf/default.args --suite search tests 10 | 11 | The output files will be placed in tests/results. 12 | 13 | Note: The tests start up a hub running on port 7071, so you don't have 14 | to stop any currently running hub (though it also means you can't run 15 | two tests concurrently). 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.1.1 2 | Jinja2==2.10.3 3 | MarkupSafe==0.23 4 | PyYAML==5.1 5 | Werkzeug==0.15.3 6 | argh==0.25.0 7 | itsdangerous==0.24 8 | tornado>=6.0.3 9 | pathtools3==0.2.1 10 | requests==2.20.0 11 | robotframework>=2.8.5 12 | robotframework-requests==0.5.0 13 | robotframework-seleniumlibrary>=4.0.0 14 | watchdog==0.9.0 15 | # wsgiref==0.1.2 16 | -------------------------------------------------------------------------------- /rfhub/__init__.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | from .version import __version__ 3 | 4 | from rfhub import kwdb 5 | 6 | # this will be defined once the app starts 7 | KWDB = None 8 | -------------------------------------------------------------------------------- /rfhub/__main__.py: -------------------------------------------------------------------------------- 1 | from rfhub import app 2 | import sys 3 | from .version import __version__ 4 | 5 | if sys.version_info < (3,6): 6 | print("rfhub {} requires python 3.6 or above".format(__version__)) 7 | sys.exit(1) 8 | 9 | app.hub = app.RobotHub() 10 | app.hub.start() 11 | -------------------------------------------------------------------------------- /rfhub/app.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from rfhub.kwdb import KeywordTable 3 | from rfhub.version import __version__ 4 | from robot.utils.argumentparser import ArgFileParser 5 | from tornado.httpserver import HTTPServer 6 | from tornado.wsgi import WSGIContainer 7 | import tornado.ioloop 8 | import argparse 9 | from rfhub import blueprints 10 | import flask 11 | import importlib 12 | import inspect 13 | import os 14 | import robot.errors 15 | import signal 16 | import sys 17 | 18 | 19 | class RobotHub(object): 20 | """Robot hub - website for REST and HTTP access to robot files""" 21 | def __init__(self): 22 | 23 | self.args = self._parse_args() 24 | 25 | if self.args.version: 26 | print(__version__) 27 | sys.exit(0) 28 | 29 | self.kwdb = KeywordTable(poll=self.args.poll) 30 | self.app = flask.Flask(__name__) 31 | 32 | with self.app.app_context(): 33 | current_app.kwdb = self.kwdb 34 | 35 | for lib in self.args.library: 36 | try: 37 | self.kwdb.add_library(lib) 38 | except robot.errors.DataError as e: 39 | sys.stderr.write("unable to load library '%s': %s\n" %(lib,e)) 40 | 41 | self._load_keyword_data(self.args.path, self.args.no_installed_keywords) 42 | 43 | self.app.add_url_rule("/", "home", self._root) 44 | self.app.add_url_rule("/ping", "ping", self._ping) 45 | self.app.add_url_rule("/favicon.ico", "favicon", self._favicon) 46 | self.app.register_blueprint(blueprints.api, url_prefix="/api") 47 | self.app.register_blueprint(blueprints.doc, url_prefix="/doc") 48 | 49 | def start(self): 50 | """Start the app""" 51 | if self.args.debug: 52 | self.app.run(port=self.args.port, debug=self.args.debug, host=self.args.interface) 53 | else: 54 | root = "http://%s:%s" % (self.args.interface, self.args.port) 55 | print("tornado web server running on " + root) 56 | self.shutdown_requested = False 57 | http_server = HTTPServer(WSGIContainer(self.app)) 58 | http_server.listen(port=self.args.port, address=self.args.interface) 59 | 60 | signal.signal(signal.SIGINT, self.signal_handler) 61 | tornado.ioloop.PeriodicCallback(self.check_shutdown_flag, 500).start() 62 | tornado.ioloop.IOLoop.instance().start() 63 | 64 | def signal_handler(self, *args): 65 | """Handle SIGINT by setting a flag to request shutdown""" 66 | self.shutdown_requested = True 67 | 68 | def check_shutdown_flag(self): 69 | """Shutdown the server if the flag has been set""" 70 | if self.shutdown_requested: 71 | tornado.ioloop.IOLoop.instance().stop() 72 | print("web server stopped.") 73 | 74 | def _parse_args(self): 75 | parser = argparse.ArgumentParser() 76 | parser.add_argument("-l", "--library", action="append", default=[], 77 | help="load the given LIBRARY (eg: -l DatabaseLibrary)") 78 | parser.add_argument("-i", "--interface", default="127.0.0.1", 79 | help="use the given network interface (default=127.0.0.1)") 80 | parser.add_argument("-p", "--port", default=7070, type=int, 81 | help="run on the given PORT (default=7070)") 82 | parser.add_argument("-A", "--argumentfile", action=ArgfileAction, 83 | help="read arguments from the given file") 84 | parser.add_argument("-P", "--pythonpath", action=PythonPathAction, 85 | help="additional locations to search for libraries.") 86 | parser.add_argument("-M", "--module", action=ModuleAction, 87 | help="give the name of a module that exports one or more classes") 88 | parser.add_argument("-D", "--debug", action="store_true", default=False, 89 | help="turn on debug mode") 90 | parser.add_argument("--no-installed-keywords", action="store_true", default=False, 91 | help="do not load some common installed keyword libraries, such as BuiltIn") 92 | parser.add_argument("--poll", action="store_true", default=False, 93 | help="use polling behavior instead of events to reload keywords on changes (useful in VMs)") 94 | parser.add_argument("--root", action="store", default="/doc", 95 | help="(deprecated) Redirect root url (http://localhost:port/) to this url (eg: /doc)") 96 | parser.add_argument("--version", action="store_true", default=False, 97 | help="Display version number and exit") 98 | parser.add_argument("path", nargs="*", 99 | help="zero or more paths to folders, libraries or resource files") 100 | return parser.parse_args() 101 | 102 | def _favicon(self): 103 | static_dir = os.path.join(self.app.root_path, 'static') 104 | return flask.send_from_directory(os.path.join(self.app.root_path, 'static'), 105 | 'favicon.ico', mimetype='image/vnd.microsoft.icon') 106 | 107 | def _root(self): 108 | return flask.redirect(self.args.root) 109 | 110 | def _ping(self): 111 | """This function is called via the /ping url""" 112 | return "pong" 113 | 114 | def _load_keyword_data(self, paths, no_install_keywords): 115 | if not no_install_keywords: 116 | self.kwdb.add_installed_libraries() 117 | 118 | for path in paths: 119 | try: 120 | self.kwdb.add(path) 121 | except Exception as e: 122 | print("Error adding keywords in %s: %s" % (path, str(e))) 123 | 124 | class ArgfileAction(argparse.Action): 125 | '''Called when the argument parser encounters --argumentfile''' 126 | def __call__ (self, parser, namespace, values, option_string = None): 127 | path = os.path.abspath(os.path.expanduser(values)) 128 | if not os.path.exists(path): 129 | raise Exception("Argument file doesn't exist: %s" % values) 130 | 131 | ap = ArgFileParser(["--argumentfile","-A"]) 132 | args = ap.process(["-A", values]) 133 | parser.parse_args(args, namespace) 134 | 135 | class PythonPathAction(argparse.Action): 136 | """Add a path to PYTHONPATH""" 137 | def __call__(self, parser, namespace, arg, option_string = None): 138 | sys.path.insert(0, arg) 139 | 140 | class ModuleAction(argparse.Action): 141 | '''Handle the -M / --module option 142 | 143 | This finds all class objects in the given module. Since page 144 | objects are modules of , they will be appended to the "library" 145 | attribute of the namespace and eventually get processed like other 146 | libraries. 147 | 148 | Note: classes that set the class attribute 149 | '__show_in_rfhub' to False will not be included. 150 | 151 | This works by importing the module given as an argument to the 152 | option, and then looking for all members of the module that 153 | are classes 154 | 155 | For example, if you give the option "pages.MyApp", this will 156 | attempt to import the module "pages.MyApp", and search for the classes 157 | that are exported from that module. For each class it finds it will 158 | append "pages.MyApp." (eg: pages.MyApp.ExamplePage) to 159 | the list of libraries that will eventually be processed. 160 | ''' 161 | 162 | def __call__(self, parser, namespace, arg, option_string = None): 163 | try: 164 | module = importlib.import_module(name=arg) 165 | for name, obj in inspect.getmembers(module): 166 | if inspect.isclass(obj): 167 | # Pay Attention! The attribute we're looking for 168 | # takes advantage of name mangling, meaning that 169 | # the attribute is unique to the class and won't 170 | # be inherited (which is important!). See 171 | # https://docs.python.org/2/tutorial/classes.html#private-variables-and-class-local-references 172 | 173 | attr = "_%s__show_in_rfhub" % obj.__name__ 174 | if getattr(obj, attr, True): 175 | libname = "%s.%s" % (module.__name__, name) 176 | namespace.library.append(libname) 177 | 178 | except ImportError as e: 179 | print("unable to import '%s' : %s" % (arg,e)) 180 | -------------------------------------------------------------------------------- /rfhub/blueprints/__init__.py: -------------------------------------------------------------------------------- 1 | from rfhub.blueprints.api import blueprint as api 2 | from rfhub.blueprints.dashboard import blueprint as dashboard 3 | from rfhub.blueprints.doc import blueprint as doc 4 | -------------------------------------------------------------------------------- /rfhub/blueprints/api/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | API blueprint 3 | 4 | This blueprint provides the /api interface. 5 | 6 | ''' 7 | 8 | from flask import Blueprint 9 | from . import keywords 10 | from . import libraries 11 | 12 | blueprint = Blueprint('api', __name__) 13 | 14 | endpoints = [ 15 | keywords.ApiEndpoint(blueprint), 16 | libraries.ApiEndpoint(blueprint) 17 | ] 18 | 19 | -------------------------------------------------------------------------------- /rfhub/blueprints/api/keywords.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This provides the view functions for the /api/keywords endpoints 3 | ''' 4 | 5 | import flask 6 | from flask import current_app 7 | from robot.libdocpkg.htmlwriter import DocToHtml 8 | 9 | class ApiEndpoint(object): 10 | def __init__(self, blueprint): 11 | blueprint.add_url_rule("/keywords/", view_func = self.get_keywords) 12 | blueprint.add_url_rule("/keywords/", view_func = self.get_library_keywords) 13 | blueprint.add_url_rule("/keywords//", view_func = self.get_library_keyword) 14 | 15 | def get_library_keywords(self,collection_id): 16 | 17 | query_pattern = flask.request.args.get('pattern', "*").strip().lower() 18 | keywords = current_app.kwdb.get_keywords(query_pattern) 19 | 20 | req_fields = flask.request.args.get('fields', "*").strip().lower() 21 | if (req_fields == "*"): 22 | fields = ("collection_id","library", "name","synopsis","doc","htmldoc","args", 23 | "doc_keyword_url", "api_keyword_url", "api_library_url") 24 | else: 25 | fields = [x.strip() for x in req_fields.split(",")] 26 | 27 | result = [] 28 | for (keyword_collection_id, keyword_collection_name, 29 | keyword_name, keyword_doc, keyword_args) in keywords: 30 | if collection_id == "" or collection_id == keyword_collection_id: 31 | data = {} 32 | if ("collection_id" in fields): data["collection_id"] = keyword_collection_id 33 | if ("library" in fields): data["library"] = keyword_collection_name 34 | if ("name" in fields): data["name"] = keyword_name 35 | if ("synopsis" in fields): data["synopsis"] = keyword_doc.strip().split("\n")[0] 36 | if ("doc" in fields): data["doc"] = keyword_doc 37 | if ("args" in fields): data["args"] = keyword_args 38 | 39 | if ("doc_keyword_url" in fields): 40 | data["doc_keyword_url"] = flask.url_for("doc.doc_for_library", 41 | collection_id=keyword_collection_id, 42 | keyword=keyword_name) 43 | if ("api_keyword_url" in fields): 44 | data["api_keyword_url"] = flask.url_for(".get_library_keyword", 45 | collection_id=keyword_collection_id, 46 | keyword=keyword_name) 47 | 48 | if ("api_library_url" in fields): 49 | data["api_library_url"] = flask.url_for(".get_library_keywords", 50 | collection_id=keyword_collection_id) 51 | if ("htmldoc" in fields): 52 | try: 53 | data["htmldoc"] = DocToHtml("ROBOT")(keyword_doc) 54 | except Exception as e: 55 | data["htmldoc"] = ""; 56 | htmldoc = "bummer", e 57 | 58 | 59 | result.append(data) 60 | 61 | return flask.jsonify(keywords=result) 62 | 63 | def get_keywords(self): 64 | # caller wants a list of keywords 65 | collection_id = flask.request.args.get('collection_id', "") 66 | return self.get_library_keywords(collection_id) 67 | 68 | def get_library_keyword(self, collection_id, keyword): 69 | kwdb = current_app.kwdb 70 | 71 | # if collection_id is a name, redirect? 72 | collections = kwdb.get_collections(pattern=collection_id.strip().lower()) 73 | if len(collections) == 1: 74 | collection_id = collections[0]["collection_id"] 75 | else: 76 | # need to redirect to a disambiguation page 77 | flask.abort(404) 78 | 79 | try: 80 | keyword = kwdb.get_keyword(collection_id, keyword) 81 | 82 | except Exception as e: 83 | current_app.logger.warning(e) 84 | flask.abort(404) 85 | 86 | if keyword: 87 | lib_url = flask.url_for(".get_library", collection_id=keyword["collection_id"]) 88 | keyword["library_url"] = lib_url 89 | return flask.jsonify(keyword) 90 | else: 91 | flask.abort(404) 92 | 93 | 94 | -------------------------------------------------------------------------------- /rfhub/blueprints/api/libraries.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This provides the view functions for the /api/libraries endpoints 3 | ''' 4 | 5 | import flask 6 | from flask import current_app 7 | 8 | class ApiEndpoint(object): 9 | def __init__(self, blueprint): 10 | blueprint.add_url_rule("/libraries/", view_func = self.get_libraries) 11 | blueprint.add_url_rule("/libraries/", view_func = self.get_library) 12 | 13 | def get_libraries(self): 14 | kwdb = current_app.kwdb 15 | 16 | query_pattern = flask.request.args.get('pattern', "*").strip().lower() 17 | libraries = kwdb.get_collections(query_pattern) 18 | 19 | return flask.jsonify(libraries=libraries) 20 | 21 | def get_library(self, collection_id): 22 | # if collection_id is a library _name_, redirect 23 | print("get_library: collection_id=", collection_id) 24 | kwdb = current_app.kwdb 25 | collection = kwdb.get_collection(collection_id) 26 | return flask.jsonify(collection=collection) 27 | -------------------------------------------------------------------------------- /rfhub/blueprints/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | import flask 2 | from flask import current_app 3 | 4 | blueprint = flask.Blueprint('dashboard', __name__, 5 | template_folder="templates") 6 | 7 | @blueprint.route("/") 8 | def home(): 9 | 10 | return flask.render_template("dashboard.html") 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /rfhub/blueprints/dashboard/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 |
20 |
21 |

Robot Framework Hub

22 |

You are viewing a very (very!) early preview of 23 | the robot hub -- a web site and web service that provides 24 | web-based access to your test assets.

25 | 26 |

In its present form, the hub has two primary services: 27 | 28 |

    29 |
  • A web application for viewing keyword documentation 30 | (view documentation for built-in, installed, and local 31 | keyword libraries and resource files).
  • 32 |
  • A web api for fetching keyword information. This 33 | can be used by other tools that need access to your 34 | test assests. For example, a text editor can use information 35 | from this API in order to provide auto-completion. 36 |
37 |

38 | 39 |

Click to go to keyword documentation

40 |
41 |

Note: if you don't want to see this page when you go to 42 | the rfhub root (eg: "http://localhost:7070/"), 43 | you can start rfhub with the "--root /doc" 44 | option to have it always start on the documentation page. 45 |

46 |

$ rfhub --root /doc
47 |

48 |
49 |
50 | 51 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/__init__.py: -------------------------------------------------------------------------------- 1 | """Flask blueprint for showing keyword documentation""" 2 | 3 | import flask 4 | from flask import current_app 5 | import json 6 | from rfhub.version import __version__ 7 | 8 | blueprint = flask.Blueprint('doc', __name__, 9 | template_folder="templates", 10 | static_folder="static") 11 | 12 | @blueprint.route("/") 13 | @blueprint.route("/keywords/") 14 | def doc(): 15 | """Show a list of libraries, along with the nav panel on the left""" 16 | kwdb = current_app.kwdb 17 | 18 | libraries = get_collections(kwdb, libtype="library") 19 | resource_files = get_collections(kwdb, libtype="resource") 20 | hierarchy = get_navpanel_data(kwdb) 21 | 22 | return flask.render_template("home.html", 23 | data={"libraries": libraries, 24 | "version": __version__, 25 | "libdoc": None, 26 | "hierarchy": hierarchy, 27 | "resource_files": resource_files 28 | }) 29 | 30 | 31 | @blueprint.route("/index") 32 | def index(): 33 | """Show a list of available libraries, and resource files""" 34 | kwdb = current_app.kwdb 35 | 36 | libraries = get_collections(kwdb, libtype="library") 37 | resource_files = get_collections(kwdb, libtype="resource") 38 | 39 | return flask.render_template("libraryNames.html", 40 | data={"libraries": libraries, 41 | "version": __version__, 42 | "resource_files": resource_files 43 | }) 44 | 45 | 46 | @blueprint.route("/search/") 47 | def search(): 48 | """Show all keywords that match a pattern""" 49 | pattern = flask.request.args.get('pattern', "*").strip().lower() 50 | 51 | # if the pattern contains "in:" (eg: in:builtin), 52 | # filter results to only that (or those) collections 53 | # This was kind-of hacked together, but seems to work well enough 54 | collections = [c["name"].lower() for c in current_app.kwdb.get_collections()] 55 | words = [] 56 | filters = [] 57 | if pattern.startswith("name:"): 58 | pattern = pattern[5:].strip() 59 | mode = "name" 60 | else: 61 | mode="both" 62 | 63 | for word in pattern.split(" "): 64 | if word.lower().startswith("in:"): 65 | filters.extend([name for name in collections if name.startswith(word[3:])]) 66 | else: 67 | words.append(word) 68 | pattern = " ".join(words) 69 | 70 | keywords = [] 71 | for keyword in current_app.kwdb.search(pattern, mode): 72 | kw = list(keyword) 73 | collection_id = kw[0] 74 | collection_name = kw[1].lower() 75 | if len(filters) == 0 or collection_name in filters: 76 | url = flask.url_for(".doc_for_library", collection_id=kw[0], keyword=kw[2]) 77 | row_id = "row-%s.%s" % (keyword[1].lower(), keyword[2].lower().replace(" ","-")) 78 | keywords.append({"collection_id": keyword[0], 79 | "collection_name": keyword[1], 80 | "name": keyword[2], 81 | "synopsis": keyword[3], 82 | "version": __version__, 83 | "url": url, 84 | "row_id": row_id 85 | }) 86 | 87 | keywords.sort(key=lambda kw: kw["name"]) 88 | return flask.render_template("search.html", 89 | data={"keywords": keywords, 90 | "version": __version__, 91 | "pattern": pattern 92 | }) 93 | 94 | 95 | # Flask docs imply I can leave the slash off (which I want 96 | # to do for the .../keyword variant). When I do, a URL like 97 | # /doc/BuiltIn/Evaluate gets redirected to the one with a 98 | # trailing slash, which then gives a 404 since the slash 99 | # is invalid. WTF? 100 | @blueprint.route("/keywords///") 101 | @blueprint.route("/keywords//") 102 | def doc_for_library(collection_id, keyword=""): 103 | kwdb = current_app.kwdb 104 | 105 | keywords = [] 106 | for (keyword_id, name, args, doc) in kwdb.get_keyword_data(collection_id): 107 | # args is a json list; convert it to actual list, and 108 | # then convert that to a string 109 | args = ", ".join(json.loads(args)) 110 | doc = doc_to_html(doc) 111 | target = name == keyword 112 | keywords.append((name, args, doc, target)) 113 | 114 | # this is the introduction documentation for the library 115 | libdoc = kwdb.get_collection(collection_id) 116 | libdoc["doc"] = doc_to_html(libdoc["doc"], libdoc["doc_format"]) 117 | 118 | # this data is necessary for the nav panel 119 | hierarchy = get_navpanel_data(kwdb) 120 | 121 | return flask.render_template("library.html", 122 | data={"keywords": keywords, 123 | "version": __version__, 124 | "libdoc": libdoc, 125 | "hierarchy": hierarchy, 126 | "collection_id": collection_id 127 | }) 128 | 129 | def get_collections(kwdb, libtype="*"): 130 | """Get list of collections from kwdb, then add urls necessary for hyperlinks""" 131 | collections = kwdb.get_collections(libtype=libtype) 132 | for result in collections: 133 | url = flask.url_for(".doc_for_library", collection_id=result["collection_id"]) 134 | result["url"] = url 135 | 136 | return collections 137 | 138 | def get_navpanel_data(kwdb): 139 | """Get navpanel data from kwdb, and add urls necessary for hyperlinks""" 140 | data = kwdb.get_keyword_hierarchy() 141 | for library in data: 142 | library["url"] = flask.url_for(".doc_for_library", collection_id=library["collection_id"]) 143 | for keyword in library["keywords"]: 144 | url = flask.url_for(".doc_for_library", 145 | collection_id=library["collection_id"], 146 | keyword=keyword["name"]) 147 | keyword["url"] = url 148 | 149 | return data 150 | 151 | 152 | def doc_to_html(doc, doc_format="ROBOT"): 153 | """Convert documentation to HTML""" 154 | from robot.libdocpkg.htmlwriter import DocToHtml 155 | return DocToHtml(doc_format)(doc) 156 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/static/doc.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | margin: 0; 3 | padding-top: 60px; 4 | overflow: hidden; 5 | height: 100%; 6 | } 7 | 8 | #footer { 9 | width:100%; 10 | height:1.5em; 11 | position:absolute; 12 | bottom:0; 13 | left:0; 14 | background:#000; 15 | color: #999; 16 | font-size: 90%; 17 | } 18 | 19 | #version { 20 | position: absolute; 21 | right: 0; 22 | margin-right: 10px; 23 | } 24 | 25 | #left { 26 | position: absolute; 27 | top: 60px; 28 | bottom: 0; 29 | height: calc(100% - 3em); 30 | width: 20%; 31 | overflow-y: scroll; 32 | } 33 | 34 | #fullpage { 35 | position: absolute; 36 | top: 60px; 37 | bottom: 0; 38 | right: 0; 39 | overflow-y: scroll; 40 | height: 90%; 41 | width: 100%; 42 | } 43 | 44 | #right { 45 | position: absolute; 46 | top: 60px; 47 | bottom: 0; 48 | right: 0; 49 | overflow-y: scroll; 50 | height: 90%; 51 | width: 80%; 52 | } 53 | 54 | table.padded-table { 55 | margin-left: 10px; 56 | } 57 | table td { 58 | padding-left: 10px; 59 | padding-right: 10px; 60 | } 61 | table th { 62 | padding-left: 10px; 63 | } 64 | 65 | .keyword, .overview { 66 | margin-left: 20px; 67 | } 68 | 69 | .search-container { 70 | margin: 10px; 71 | } 72 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/static/doc.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | jQuery.fn.scrollTo = function(elem, speed) { 3 | $(this).animate({ 4 | scrollTop: $(this).scrollTop() - $(this).offset().top + $(elem).offset().top - 10 5 | }, speed == undefined ? 500 : speed); 6 | return this; 7 | }; 8 | 9 | $('label.tree-toggler').click(function () { 10 | $(this).parent().children('ul.tree').toggle(200); 11 | }); 12 | 13 | function endsWith(str, suffix) { 14 | return str.indexOf(suffix, str.length - suffix.length) !== -1; 15 | } 16 | 17 | function renderKeywords(pattern, element) { 18 | if (! _.isEmpty(pattern)) { 19 | $.get(element.data('url_search'), { pattern: pattern }) 20 | .done(function (responseData) { 21 | $('#right').html(responseData); 22 | }); 23 | } else { 24 | $.get(element.data('url_index')) 25 | .done(function (responseData) { 26 | $('#right').html(responseData); 27 | }); 28 | } 29 | } 30 | 31 | function refreshKeywords(e) { 32 | var element = $(e.target); 33 | var pattern = element.val(); 34 | var url = element.data('url_keywords'); 35 | if (!_.isEmpty(pattern)) { 36 | url = url + '?pattern=' + pattern; 37 | } 38 | history.pushState({ pattern: pattern }, '', url); 39 | renderKeywords(pattern, element); 40 | } 41 | 42 | function setSearchFieldValue(newValue) { 43 | $('#search-pattern').val(newValue); 44 | } 45 | 46 | $('#search-pattern').on('input change', _.debounce(refreshKeywords, 200)); 47 | 48 | $(window).on('popstate', function (e) { 49 | var state = e.originalEvent.state; 50 | var pattern = state === null ? '' : state.pattern; 51 | renderKeywords(pattern); 52 | setSearchFieldValue(pattern); 53 | }); 54 | 55 | var params = queryString.parse(location.search); 56 | if (! _.isEmpty(params.pattern)) { 57 | renderKeywords(params.pattern); 58 | setSearchFieldValue(params.pattern); 59 | } 60 | if ($('.selected').length > 0) { 61 | $("#right").scrollTo(".selected"); 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Keyword Documentation - Robot Framework Hub 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 62 | 63 |
64 | {% block body %} 65 | {% endblock %} 66 |
67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "twocolumn.html" %} 2 | {% block content %} 3 | {% include 'libraryNames.html' %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/templates/library.html: -------------------------------------------------------------------------------- 1 | {% extends "twocolumn.html" %} 2 | {% block content %} 3 |

{{data.libdoc.name}}

4 | {% if data.libdoc.type == "library" %} 5 | Version: {% if data.libdoc.version %}{{ data.libdoc.version }}{% else %}undefined{% endif %} 6 | • 7 | Scope: {% if data.libdoc.scope %}{{data.libdoc.scope}}{% else %}undefined{% endif %}
8 | {% endif %} 9 | {% if data.libdoc.path != None %} 10 | {{data.libdoc.path}} 11 | {% endif %} 12 | 13 | 14 | {% if data.libdoc.doc %} 15 |

Introduction

16 | {% autoescape false %} 17 | {{ data.libdoc.doc }} 18 | {% endautoescape %} 19 | {% endif %} 20 |

Keywords

21 | 22 | 23 | {% for item in data.keywords %} 24 | {% autoescape false %} 25 | 26 | 27 | 28 | 29 | 30 | {% endautoescape %} 31 | {% endfor %} 32 |
KeywordsArgumentsDocumentation
{{item[0]}}{{item[1]}}{{item[2]}}
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/templates/libraryNames.html: -------------------------------------------------------------------------------- 1 |
2 |

Libraries

3 | 4 | {% for item in data["libraries"] %} 5 | 6 | {% endfor %} 7 |
{{item.name}}{{item.synopsis}}
8 |
9 | 10 |
11 |

Resource Files

12 | 13 | {% for item in data["resource_files"] %} 14 | 15 | {% endfor %} 16 |
{{item.name}}{{item.synopsis}}
17 |
18 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/templates/search.html: -------------------------------------------------------------------------------- 1 |
2 |

Search results

3 |

Searching for '{{data.pattern}}' found {{data.keywords|length}} keywords

4 | 5 | 6 | 7 | 8 | 9 | {% for item in data.keywords %} 10 | 11 | 12 | 13 | {% if item.synopsis == "" %} 14 | 15 | {% else %} 16 | 17 | {% endif %} 18 | 19 | {% endfor %} 20 | 21 |
Library/ResourceKeyword NameSynopsis
{{item.collection_name}}{{item.name}}no documentation available{{item.synopsis}}
22 |
23 | -------------------------------------------------------------------------------- /rfhub/blueprints/doc/templates/twocolumn.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block body %} 3 |
4 |
    5 | {% for collection in data.hierarchy %} 6 | {% if collection.collection_id|string == data.collection_id|string %} 7 |
  • 8 | 10 |
      11 | {% else %} 12 |
    • 13 | 15 |
        16 | {% endif %} 17 |
      • 18 | Overview 19 |
      • 20 | {% for kw in collection.keywords %} 21 |
      • 22 | {{kw.name}} 23 |
      • 24 | {% endfor %} 25 |
      26 |
    • 27 | {% endfor %} 28 |
    29 |
30 | 31 | 34 | 35 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /rfhub/kwdb.py: -------------------------------------------------------------------------------- 1 | """keywordtable - an SQLite database of keywords 2 | 3 | Keywords can be loaded from resource files, test suite files, 4 | and libdoc-formatted xml, or from python libraries. These 5 | are referred to as "collections". 6 | 7 | """ 8 | 9 | import sqlite3 10 | import os 11 | from robot.libdocpkg import LibraryDocumentation 12 | import robot.libraries 13 | import logging 14 | import json 15 | import re 16 | import sys 17 | from operator import itemgetter 18 | 19 | from watchdog.observers import Observer 20 | from watchdog.observers.polling import PollingObserver 21 | from watchdog.events import PatternMatchingEventHandler 22 | 23 | """ 24 | Note: It seems to be possible for watchdog to fire an event 25 | when a file is modified, but before the file is _finished_ 26 | being modified (ie: you get the event when some other program 27 | writes the first byte, rather than waiting until the other 28 | program closes the file) 29 | 30 | For that reason, we might want to mark a collection as 31 | "dirty", and only reload after some period of time has 32 | elapsed? I haven't yet experienced this problem, but 33 | I haven't done extensive testing. 34 | """ 35 | 36 | class WatchdogHandler(PatternMatchingEventHandler): 37 | patterns = ["*.robot", "*.txt", "*.py", "*.tsv", "*.resource"] 38 | def __init__(self, kwdb, path): 39 | PatternMatchingEventHandler.__init__(self) 40 | self.kwdb = kwdb 41 | self.path = path 42 | 43 | def on_created(self, event): 44 | # monitor=False because we're already monitoring 45 | # ancestor of the file that was created. Duh. 46 | self.kwdb.add(event.src_path, monitor=False) 47 | 48 | def on_deleted(self, event): 49 | # FIXME: need to implement this 50 | pass 51 | 52 | def on_modified(self, event): 53 | self.kwdb.on_change(event.src_path, event.event_type) 54 | 55 | class KeywordTable(object): 56 | """A SQLite database of keywords""" 57 | 58 | def __init__(self, dbfile=":memory:", poll=False): 59 | self.db = sqlite3.connect(dbfile, check_same_thread=False) 60 | self.log = logging.getLogger(__name__) 61 | self._create_db() 62 | # self.log.warning("I'm warnin' ya!") 63 | 64 | # set up watchdog observer to monitor changes to 65 | # keyword files (or more correctly, to directories 66 | # of keyword files) 67 | self.observer = PollingObserver() if poll else Observer() 68 | self.observer.start() 69 | 70 | def add(self, name, monitor=True): 71 | """Add a folder, library (.py) or resource file (.robot, .tsv, .txt, .resource) to the database 72 | """ 73 | 74 | if os.path.isdir(name): 75 | if (not os.path.basename(name).startswith(".")): 76 | self.add_folder(name) 77 | 78 | elif os.path.isfile(name): 79 | if ((self._looks_like_resource_file(name)) or 80 | (self._looks_like_libdoc_file(name)) or 81 | (self._looks_like_library_file(name))): 82 | self.add_file(name) 83 | else: 84 | # let's hope it's a library name! 85 | self.add_library(name) 86 | 87 | def on_change(self, path, event_type): 88 | """Respond to changes in the file system 89 | 90 | This method will be given the path to a file that 91 | has changed on disk. We need to reload the keywords 92 | from that file 93 | """ 94 | # I can do all this work in a sql statement, but 95 | # for debugging it's easier to do it in stages. 96 | sql = """SELECT collection_id 97 | FROM collection_table 98 | WHERE path == ? 99 | """ 100 | cursor = self._execute(sql, (path,)) 101 | results = cursor.fetchall() 102 | # there should always be exactly one result, but 103 | # there's no harm in using a loop to process the 104 | # single result 105 | for result in results: 106 | collection_id = result[0] 107 | # remove all keywords in this collection 108 | sql = """DELETE from keyword_table 109 | WHERE collection_id == ? 110 | """ 111 | cursor = self._execute(sql, (collection_id,)) 112 | self._load_keywords(collection_id, path=path) 113 | 114 | def _load_keywords(self, collection_id, path=None, libdoc=None): 115 | """Load a collection of keywords 116 | 117 | One of path or libdoc needs to be passed in... 118 | """ 119 | if libdoc is None and path is None: 120 | raise(Exception("You must provide either a path or libdoc argument")) 121 | 122 | if libdoc is None: 123 | libdoc = LibraryDocumentation(path) 124 | 125 | if len(libdoc.keywords) > 0: 126 | for keyword in libdoc.keywords: 127 | self._add_keyword(collection_id, keyword.name, keyword.doc, keyword.args) 128 | 129 | def add_file(self, path): 130 | """Add a resource file or library file to the database""" 131 | libdoc = LibraryDocumentation(path) 132 | if len(libdoc.keywords) > 0: 133 | if libdoc.doc.startswith("Documentation for resource file"): 134 | # bah! The file doesn't have an file-level documentation 135 | # and libdoc substitutes some placeholder text. 136 | libdoc.doc = "" 137 | 138 | collection_id = self.add_collection(path, libdoc.name, libdoc.type, 139 | libdoc.doc, libdoc.version, 140 | libdoc.scope, libdoc.named_args, 141 | libdoc.doc_format) 142 | self._load_keywords(collection_id, libdoc=libdoc) 143 | 144 | def add_library(self, name): 145 | """Add a library to the database 146 | 147 | This method is for adding a library by name (eg: "BuiltIn") 148 | rather than by a file. 149 | """ 150 | libdoc = LibraryDocumentation(name) 151 | if len(libdoc.keywords) > 0: 152 | # FIXME: figure out the path to the library file 153 | collection_id = self.add_collection(None, libdoc.name, libdoc.type, 154 | libdoc.doc, libdoc.version, 155 | libdoc.scope, libdoc.named_args, 156 | libdoc.doc_format) 157 | self._load_keywords(collection_id, libdoc=libdoc) 158 | 159 | def add_folder(self, dirname, watch=True): 160 | """Recursively add all files in a folder to the database 161 | 162 | By "all files" I mean, "all files that are resource files 163 | or library files". It will silently ignore files that don't 164 | look like they belong in the database. Pity the fool who 165 | uses non-standard suffixes. 166 | 167 | N.B. folders with names that begin with '." will be skipped 168 | """ 169 | 170 | ignore_file = os.path.join(dirname, ".rfhubignore") 171 | exclude_patterns = [] 172 | try: 173 | with open(ignore_file, "r") as f: 174 | exclude_patterns = [] 175 | for line in f.readlines(): 176 | line = line.strip() 177 | if (re.match(r'^\s*#', line)): continue 178 | if len(line.strip()) > 0: 179 | exclude_patterns.append(line) 180 | except: 181 | # should probably warn the user? 182 | pass 183 | 184 | for filename in os.listdir(dirname): 185 | path = os.path.join(dirname, filename) 186 | (basename, ext) = os.path.splitext(filename.lower()) 187 | 188 | try: 189 | if (os.path.isdir(path)): 190 | if (not basename.startswith(".")): 191 | if os.access(path, os.R_OK): 192 | self.add_folder(path, watch=False) 193 | else: 194 | if (ext in (".xml", ".robot", ".txt", ".py", ".tsv", ".resource")): 195 | if os.access(path, os.R_OK): 196 | self.add(path) 197 | except Exception as e: 198 | # I really need to get the logging situation figured out. 199 | print("bummer:", str(e)) 200 | 201 | # FIXME: 202 | # instead of passing a flag around, I should just keep track 203 | # of which folders we're watching, and don't add wathers for 204 | # any subfolders. That will work better in the case where 205 | # the user accidentally starts up the hub giving the same 206 | # folder, or a folder and it's children, on the command line... 207 | if watch: 208 | # add watcher on normalized path 209 | dirname = os.path.abspath(dirname) 210 | event_handler = WatchdogHandler(self, dirname) 211 | self.observer.schedule(event_handler, dirname, recursive=True) 212 | 213 | def add_collection(self, path, c_name, c_type, c_doc, c_version="unknown", 214 | c_scope="", c_namedargs="yes", c_doc_format="ROBOT"): 215 | """Insert data into the collection table""" 216 | if path is not None: 217 | # We want to store the normalized form of the path in the 218 | # database 219 | path = os.path.abspath(path) 220 | 221 | cursor = self.db.cursor() 222 | cursor.execute(""" 223 | INSERT INTO collection_table 224 | (name, type, version, scope, namedargs, path, doc, doc_format) 225 | VALUES 226 | (?,?,?,?,?,?,?,?) 227 | """, (c_name, c_type, c_version, c_scope, c_namedargs, path, c_doc, c_doc_format)) 228 | collection_id = cursor.lastrowid 229 | return collection_id 230 | 231 | def add_installed_libraries(self, extra_libs = ["SeleniumLibrary", 232 | "SudsLibrary", 233 | "RequestsLibrary"]): 234 | """Add any installed libraries that we can find 235 | 236 | We do this by looking in the `libraries` folder where 237 | robot is installed. If you have libraries installed 238 | in a non-standard place, this won't pick them up. 239 | """ 240 | 241 | libdir = os.path.dirname(robot.libraries.__file__) 242 | loaded = [] 243 | for filename in os.listdir(libdir): 244 | if filename.endswith(".py") or filename.endswith(".pyc"): 245 | libname, ext = os.path.splitext(filename) 246 | if (libname.lower() not in loaded and 247 | not self._should_ignore(libname)): 248 | 249 | try: 250 | self.add(libname) 251 | loaded.append(libname.lower()) 252 | except Exception as e: 253 | # need a better way to log this... 254 | self.log.debug("unable to add library: " + str(e)) 255 | 256 | # I hate how I implemented this, but I don't think there's 257 | # any way to find out which installed python packages are 258 | # robot libraries. 259 | for library in extra_libs: 260 | if (library.lower() not in loaded and 261 | not self._should_ignore(library)): 262 | try: 263 | self.add(library) 264 | loaded.append(library.lower()) 265 | except Exception as e: 266 | self.log.debug("unable to add external library %s: %s" % \ 267 | (library, str(e))) 268 | 269 | def get_collection(self, collection_id): 270 | """Get a specific collection""" 271 | sql = """SELECT collection.collection_id, collection.type, 272 | collection.name, collection.path, 273 | collection.doc, 274 | collection.version, collection.scope, 275 | collection.namedargs, 276 | collection.doc_format 277 | FROM collection_table as collection 278 | WHERE collection_id == ? OR collection.name like ? 279 | """ 280 | cursor = self._execute(sql, (collection_id, collection_id)) 281 | # need to handle the case where we get more than one result... 282 | sql_result = cursor.fetchone() 283 | return { 284 | "collection_id": sql_result[0], 285 | "type": sql_result[1], 286 | "name": sql_result[2], 287 | "path": sql_result[3], 288 | "doc": sql_result[4], 289 | "version": sql_result[5], 290 | "scope": sql_result[6], 291 | "namedargs": sql_result[7], 292 | "doc_format": sql_result[8] 293 | } 294 | return sql_result 295 | 296 | def get_collections(self, pattern="*", libtype="*"): 297 | """Returns a list of collection name/summary tuples""" 298 | 299 | sql = """SELECT collection.collection_id, collection.name, collection.doc, 300 | collection.type, collection.path 301 | FROM collection_table as collection 302 | WHERE name like ? 303 | AND type like ? 304 | ORDER BY collection.name 305 | """ 306 | 307 | cursor = self._execute(sql, (self._glob_to_sql(pattern), 308 | self._glob_to_sql(libtype))) 309 | sql_result = cursor.fetchall() 310 | 311 | return [{"collection_id": result[0], 312 | "name": result[1], 313 | "synopsis": result[2].split("\n")[0], 314 | "type": result[3], 315 | "path": result[4] 316 | } for result in sql_result] 317 | 318 | def get_keyword_data(self, collection_id): 319 | sql = """SELECT keyword.keyword_id, keyword.name, keyword.args, keyword.doc 320 | FROM keyword_table as keyword 321 | WHERE keyword.collection_id == ? 322 | ORDER BY keyword.name 323 | """ 324 | cursor = self._execute(sql, (collection_id,)) 325 | return cursor.fetchall() 326 | 327 | def get_keyword(self, collection_id, name): 328 | """Get a specific keyword from a library""" 329 | sql = """SELECT keyword.name, keyword.args, keyword.doc 330 | FROM keyword_table as keyword 331 | WHERE keyword.collection_id == ? 332 | AND keyword.name like ? 333 | """ 334 | cursor = self._execute(sql, (collection_id,name)) 335 | # We're going to assume no library has duplicate keywords 336 | # While that in theory _could_ happen, it never _should_, 337 | # and you get what you deserve if it does. 338 | row = cursor.fetchone() 339 | if row is not None: 340 | return {"name": row[0], 341 | "args": json.loads(row[1]), 342 | "doc": row[2], 343 | "collection_id": collection_id 344 | } 345 | return {} 346 | 347 | def get_keyword_hierarchy(self, pattern="*"): 348 | """Returns all keywords that match a glob-style pattern 349 | 350 | The result is a list of dictionaries, sorted by collection 351 | name. 352 | 353 | The pattern matching is insensitive to case. The function 354 | returns a list of (library_name, keyword_name, 355 | keyword_synopsis tuples) sorted by keyword name 356 | 357 | """ 358 | 359 | sql = """SELECT collection.collection_id, collection.name, collection.path, 360 | keyword.name, keyword.doc 361 | FROM collection_table as collection 362 | JOIN keyword_table as keyword 363 | WHERE collection.collection_id == keyword.collection_id 364 | AND keyword.name like ? 365 | ORDER by collection.name, collection.collection_id, keyword.name 366 | """ 367 | cursor = self._execute(sql, (self._glob_to_sql(pattern),)) 368 | libraries = [] 369 | current_library = None 370 | for row in cursor.fetchall(): 371 | (c_id, c_name, c_path, k_name, k_doc) = row 372 | if c_id != current_library: 373 | current_library = c_id 374 | libraries.append({"name": c_name, "collection_id": c_id, "keywords": [], "path": c_path}) 375 | libraries[-1]["keywords"].append({"name": k_name, "doc": k_doc}) 376 | return libraries 377 | 378 | def search(self, pattern="*", mode="both"): 379 | """Perform a pattern-based search on keyword names and documentation 380 | 381 | The pattern matching is insensitive to case. The function 382 | returns a list of tuples of the form library_id, library_name, 383 | keyword_name, keyword_synopsis, sorted by library id, 384 | library name, and then keyword name 385 | 386 | If a pattern begins with "name:", only the keyword names will 387 | be searched. Otherwise, the pattern is searched for in both 388 | the name and keyword documentation. 389 | 390 | You can limit the search to a single library by specifying 391 | "in:" followed by the name of the library or resource 392 | file. For example, "screenshot in:SeleniumLibrary" will only 393 | search for the word 'screenshot' in the SeleniumLibrary. 394 | 395 | """ 396 | pattern = self._glob_to_sql(pattern) 397 | 398 | COND = "(keyword.name like ? OR keyword.doc like ?)" 399 | args = [pattern, pattern] 400 | if mode == "name": 401 | COND = "(keyword.name like ?)" 402 | args = [pattern,] 403 | 404 | sql = """SELECT collection.collection_id, collection.name, keyword.name, keyword.doc 405 | FROM collection_table as collection 406 | JOIN keyword_table as keyword 407 | WHERE collection.collection_id == keyword.collection_id 408 | AND %s 409 | ORDER by collection.collection_id, collection.name, keyword.name 410 | """ % COND 411 | 412 | cursor = self._execute(sql, args) 413 | result = [(row[0], row[1], row[2], row[3].strip().split("\n")[0]) 414 | for row in cursor.fetchall()] 415 | return list(set(result)) 416 | 417 | def get_keywords(self, pattern="*"): 418 | """Returns all keywords that match a glob-style pattern 419 | 420 | The pattern matching is insensitive to case. The function 421 | returns a list of (library_name, keyword_name, 422 | keyword_synopsis tuples) sorted by keyword name 423 | 424 | """ 425 | 426 | sql = """SELECT collection.collection_id, collection.name, 427 | keyword.name, keyword.doc, keyword.args 428 | FROM collection_table as collection 429 | JOIN keyword_table as keyword 430 | WHERE collection.collection_id == keyword.collection_id 431 | AND keyword.name like ? 432 | ORDER by collection.name, keyword.name 433 | """ 434 | pattern = self._glob_to_sql(pattern) 435 | cursor = self._execute(sql, (pattern,)) 436 | result = [(row[0], row[1], row[2], row[3], row[4]) 437 | for row in cursor.fetchall()] 438 | return list(sorted(set(result), key=itemgetter(2))) 439 | 440 | def reset(self): 441 | """Remove all data from the database, but leave the tables intact""" 442 | self._execute("DELETE FROM collection_table") 443 | self._execute("DELETE FROM keyword_table") 444 | 445 | def _looks_like_library_file(self, name): 446 | return name.endswith(".py") 447 | 448 | def _looks_like_libdoc_file(self, name): 449 | """Return true if an xml file looks like a libdoc file""" 450 | # inefficient since we end up reading the file twice, 451 | # but it's fast enough for our purposes, and prevents 452 | # us from doing a full parse of files that are obviously 453 | # not libdoc files 454 | if name.lower().endswith(".xml"): 455 | with open(name, "r") as f: 456 | # read the first few lines; if we don't see 457 | # what looks like libdoc data, return false 458 | data = f.read(200) 459 | index = data.lower().find(" 0: 461 | return True 462 | return False 463 | 464 | def _looks_like_resource_file(self, name): 465 | """Return true if the file has a keyword table but not a testcase table""" 466 | # inefficient since we end up reading the file twice, 467 | # but it's fast enough for our purposes, and prevents 468 | # us from doing a full parse of files that are obviously 469 | # not robot files 470 | 471 | if (re.search(r'__init__.(txt|robot|html|tsv)$', name)): 472 | # These are initialize files, not resource files 473 | return False 474 | 475 | found_keyword_table = False 476 | if (name.lower().endswith(".robot") or 477 | name.lower().endswith(".txt") or 478 | name.lower().endswith(".tsv") or 479 | name.lower().endswith(".resource")): 480 | 481 | with open(name, "r") as f: 482 | data = f.read() 483 | for match in re.finditer(r'^\*+\s*(Test Cases?|(?:User )?Keywords?)', 484 | data, re.MULTILINE|re.IGNORECASE): 485 | if (re.match(r'Test Cases?', match.group(1), re.IGNORECASE)): 486 | # if there's a test case table, it's not a keyword file 487 | return False 488 | 489 | if (not found_keyword_table and 490 | re.match(r'(User )?Keywords?', match.group(1), re.IGNORECASE)): 491 | found_keyword_table = True 492 | return found_keyword_table 493 | 494 | def _should_ignore(self, name): 495 | """Return True if a given library name should be ignored 496 | 497 | This is necessary because not all files we find in the library 498 | folder are libraries. I wish there was a public robot API 499 | for "give me a list of installed libraries"... 500 | """ 501 | _name = name.lower() 502 | return (_name.startswith("deprecated") or 503 | _name.startswith("_") or 504 | _name in ("remote", "reserved", 505 | "dialogs_py", "dialogs_ipy", "dialogs_jy")) 506 | 507 | def _execute(self, *args): 508 | """Execute an SQL query 509 | 510 | This exists because I think it's tedious to get a cursor and 511 | then use a cursor. 512 | """ 513 | cursor = self.db.cursor() 514 | cursor.execute(*args) 515 | return cursor 516 | 517 | def _add_keyword(self, collection_id, name, doc, args): 518 | """Insert data into the keyword table 519 | 520 | 'args' should be a list, but since we can't store a list in an 521 | sqlite database we'll make it json we can can convert it back 522 | to a list later. 523 | """ 524 | argstring = json.dumps(args) 525 | self.db.execute(""" 526 | INSERT INTO keyword_table 527 | (collection_id, name, doc, args) 528 | VALUES 529 | (?,?,?,?) 530 | """, (collection_id, name, doc, argstring)) 531 | 532 | def _create_db(self): 533 | 534 | if not self._table_exists("collection_table"): 535 | self.db.execute(""" 536 | CREATE TABLE collection_table 537 | (collection_id INTEGER PRIMARY KEY AUTOINCREMENT, 538 | name TEXT COLLATE NOCASE, 539 | type COLLATE NOCASE, 540 | version TEXT, 541 | scope TEXT, 542 | namedargs TEXT, 543 | path TEXT, 544 | doc TEXT, 545 | doc_format TEXT) 546 | """) 547 | self.db.execute(""" 548 | CREATE INDEX collection_index 549 | ON collection_table (name) 550 | """) 551 | 552 | if not self._table_exists("keyword_table"): 553 | self.db.execute(""" 554 | CREATE TABLE keyword_table 555 | (keyword_id INTEGER PRIMARY KEY AUTOINCREMENT, 556 | name TEXT COLLATE NOCASE, 557 | collection_id INTEGER, 558 | doc TEXT, 559 | args TEXT) 560 | """) 561 | self.db.execute(""" 562 | CREATE INDEX keyword_index 563 | ON keyword_table (name) 564 | """) 565 | 566 | 567 | def _glob_to_sql(self, string): 568 | """Convert glob-like wildcards to SQL wildcards 569 | 570 | * becomes % 571 | ? becomes _ 572 | % becomes \% 573 | \\ remains \\ 574 | \* remains \* 575 | \? remains \? 576 | 577 | This also adds a leading and trailing %, unless the pattern begins with 578 | ^ or ends with $ 579 | """ 580 | 581 | # What's with the chr(1) and chr(2) nonsense? It's a trick to 582 | # hide \* and \? from the * and ? substitutions. This trick 583 | # depends on the substitutiones being done in order. chr(1) 584 | # and chr(2) were picked because I know those characters 585 | # almost certainly won't be in the input string 586 | table = ((r'\\', chr(1)), (r'\*', chr(2)), (r'\?', chr(3)), 587 | (r'%', r'\%'), (r'?', '_'), (r'*', '%'), 588 | (chr(1), r'\\'), (chr(2), r'\*'), (chr(3), r'\?')) 589 | 590 | for (a, b) in table: 591 | string = string.replace(a,b) 592 | 593 | string = string[1:] if string.startswith("^") else "%" + string 594 | string = string[:-1] if string.endswith("$") else string + "%" 595 | 596 | return string 597 | 598 | def _table_exists(self, name): 599 | cursor = self.db.execute(""" 600 | SELECT name FROM sqlite_master 601 | WHERE type='table' AND name='%s' 602 | """ % name) 603 | return len(cursor.fetchall()) > 0 604 | -------------------------------------------------------------------------------- /rfhub/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boakley/robotframework-hub/0b59ffaf802d30a2b5a560c60fbe1350d9d3699a/rfhub/static/favicon.ico -------------------------------------------------------------------------------- /rfhub/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boakley/robotframework-hub/0b59ffaf802d30a2b5a560c60fbe1350d9d3699a/rfhub/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /rfhub/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boakley/robotframework-hub/0b59ffaf802d30a2b5a560c60fbe1350d9d3699a/rfhub/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /rfhub/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boakley/robotframework-hub/0b59ffaf802d30a2b5a560c60fbe1350d9d3699a/rfhub/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /rfhub/static/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one(a.support.transition.end,function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b()})}(jQuery),+function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype.close=function(b){function c(){f.trigger("closed.bs.alert").remove()}var d=a(this),e=d.attr("data-target");e||(e=d.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,""));var f=a(e);b&&b.preventDefault(),f.length||(f=d.hasClass("alert")?d:d.parent()),f.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(f.removeClass("in"),a.support.transition&&f.hasClass("fade")?f.one(a.support.transition.end,c).emulateTransitionEnd(150):c())};var d=a.fn.alert;a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("bs.alert");e||d.data("bs.alert",e=new c(this)),"string"==typeof b&&e[b].call(d)})},a.fn.alert.Constructor=c,a.fn.alert.noConflict=function(){return a.fn.alert=d,this},a(document).on("click.bs.alert.data-api",b,c.prototype.close)}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.isLoading=!1};b.DEFAULTS={loadingText:"loading..."},b.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",f.resetText||d.data("resetText",d[e]()),d[e](f[b]||this.options[b]),setTimeout(a.proxy(function(){"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},b.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}a&&this.$element.toggleClass("active")};var c=a.fn.button;a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof c&&c;e||d.data("bs.button",e=new b(this,f)),"toggle"==c?e.toggle():c&&e.setState(c)})},a.fn.button.Constructor=b,a.fn.button.noConflict=function(){return a.fn.button=c,this},a(document).on("click.bs.button.data-api","[data-toggle^=button]",function(b){var c=a(b.target);c.hasClass("btn")||(c=c.closest(".btn")),c.button("toggle"),b.preventDefault()})}(jQuery),+function(a){"use strict";var b=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,"hover"==this.options.pause&&this.$element.on("mouseenter",a.proxy(this.pause,this)).on("mouseleave",a.proxy(this.cycle,this))};b.DEFAULTS={interval:5e3,pause:"hover",wrap:!0},b.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},b.prototype.getActiveIndex=function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},b.prototype.to=function(b){var c=this,d=this.getActiveIndex();return b>this.$items.length-1||0>b?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){c.to(b)}):d==b?this.pause().cycle():this.slide(b>d?"next":"prev",a(this.$items[b]))},b.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},b.prototype.next=function(){return this.sliding?void 0:this.slide("next")},b.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},b.prototype.slide=function(b,c){var d=this.$element.find(".item.active"),e=c||d[b](),f=this.interval,g="next"==b?"left":"right",h="next"==b?"first":"last",i=this;if(!e.length){if(!this.options.wrap)return;e=this.$element.find(".item")[h]()}if(e.hasClass("active"))return this.sliding=!1;var j=a.Event("slide.bs.carousel",{relatedTarget:e[0],direction:g});return this.$element.trigger(j),j.isDefaultPrevented()?void 0:(this.sliding=!0,f&&this.pause(),this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid.bs.carousel",function(){var b=a(i.$indicators.children()[i.getActiveIndex()]);b&&b.addClass("active")})),a.support.transition&&this.$element.hasClass("slide")?(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),d.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid.bs.carousel")},0)}).emulateTransitionEnd(1e3*d.css("transition-duration").slice(0,-1))):(d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid.bs.carousel")),f&&this.cycle(),this)};var c=a.fn.carousel;a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c),g="string"==typeof c?c:f.slide;e||d.data("bs.carousel",e=new b(this,f)),"number"==typeof c?e.to(c):g?e[g]():f.interval&&e.pause().cycle()})},a.fn.carousel.Constructor=b,a.fn.carousel.noConflict=function(){return a.fn.carousel=c,this},a(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",function(b){var c,d=a(this),e=a(d.attr("data-target")||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"")),f=a.extend({},e.data(),d.data()),g=d.attr("data-slide-to");g&&(f.interval=!1),e.carousel(f),(g=d.attr("data-slide-to"))&&e.data("bs.carousel").to(g),b.preventDefault()}),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var b=a(this);b.carousel(b.data())})})}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.transitioning=null,this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.DEFAULTS={toggle:!0},b.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},b.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b=a.Event("show.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.$parent&&this.$parent.find("> .panel > .in");if(c&&c.length){var d=c.data("bs.collapse");if(d&&d.transitioning)return;c.collapse("hide"),d||c.data("bs.collapse",null)}var e=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[e](0),this.transitioning=1;var f=function(){this.$element.removeClass("collapsing").addClass("collapse in")[e]("auto"),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return f.call(this);var g=a.camelCase(["scroll",e].join("-"));this.$element.one(a.support.transition.end,a.proxy(f,this)).emulateTransitionEnd(350)[e](this.$element[0][g])}}},b.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse").removeClass("in"),this.transitioning=1;var d=function(){this.transitioning=0,this.$element.trigger("hidden.bs.collapse").removeClass("collapsing").addClass("collapse")};return a.support.transition?void this.$element[c](0).one(a.support.transition.end,a.proxy(d,this)).emulateTransitionEnd(350):d.call(this)}}},b.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()};var c=a.fn.collapse;a.fn.collapse=function(c){return this.each(function(){var d=a(this),e=d.data("bs.collapse"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c);!e&&f.toggle&&"show"==c&&(c=!c),e||d.data("bs.collapse",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.collapse.Constructor=b,a.fn.collapse.noConflict=function(){return a.fn.collapse=c,this},a(document).on("click.bs.collapse.data-api","[data-toggle=collapse]",function(b){var c,d=a(this),e=d.attr("data-target")||b.preventDefault()||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,""),f=a(e),g=f.data("bs.collapse"),h=g?"toggle":d.data(),i=d.attr("data-parent"),j=i&&a(i);g&&g.transitioning||(j&&j.find('[data-toggle=collapse][data-parent="'+i+'"]').not(d).addClass("collapsed"),d[f.hasClass("in")?"addClass":"removeClass"]("collapsed")),f.collapse(h)})}(jQuery),+function(a){"use strict";function b(b){a(d).remove(),a(e).each(function(){var d=c(a(this)),e={relatedTarget:this};d.hasClass("open")&&(d.trigger(b=a.Event("hide.bs.dropdown",e)),b.isDefaultPrevented()||d.removeClass("open").trigger("hidden.bs.dropdown",e))})}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}var d=".dropdown-backdrop",e="[data-toggle=dropdown]",f=function(b){a(b).on("click.bs.dropdown",this.toggle)};f.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(''}),b.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),b.prototype.constructor=b,b.prototype.getDefaults=function(){return b.DEFAULTS},b.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content")[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},b.prototype.hasContent=function(){return this.getTitle()||this.getContent()},b.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},b.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")},b.prototype.tip=function(){return this.$tip||(this.$tip=a(this.options.template)),this.$tip};var c=a.fn.popover;a.fn.popover=function(c){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof c&&c;(e||"destroy"!=c)&&(e||d.data("bs.popover",e=new b(this,f)),"string"==typeof c&&e[c]())})},a.fn.popover.Constructor=b,a.fn.popover.noConflict=function(){return a.fn.popover=c,this}}(jQuery),+function(a){"use strict";function b(c,d){var e,f=a.proxy(this.process,this);this.$element=a(a(c).is("body")?window:c),this.$body=a("body"),this.$scrollElement=this.$element.on("scroll.bs.scroll-spy.data-api",f),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||(e=a(c).attr("href"))&&e.replace(/.*(?=#[^\s]+$)/,"")||"")+" .nav li > a",this.offsets=a([]),this.targets=a([]),this.activeTarget=null,this.refresh(),this.process()}b.DEFAULTS={offset:10},b.prototype.refresh=function(){var b=this.$element[0]==window?"offset":"position";this.offsets=a([]),this.targets=a([]);{var c=this;this.$body.find(this.selector).map(function(){var d=a(this),e=d.data("target")||d.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[b]().top+(!a.isWindow(c.$scrollElement.get(0))&&c.$scrollElement.scrollTop()),e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){c.offsets.push(this[0]),c.targets.push(this[1])})}},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.$scrollElement[0].scrollHeight||this.$body[0].scrollHeight,d=c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(b>=d)return g!=(a=f.last()[0])&&this.activate(a);if(g&&b<=e[0])return g!=(a=f[0])&&this.activate(a);for(a=e.length;a--;)g!=f[a]&&b>=e[a]&&(!e[a+1]||b<=e[a+1])&&this.activate(f[a])},b.prototype.activate=function(b){this.activeTarget=b,a(this.selector).parentsUntil(this.options.target,".active").removeClass("active");var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")};var c=a.fn.scrollspy;a.fn.scrollspy=function(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return a.fn.scrollspy=c,this},a(window).on("load",function(){a('[data-spy="scroll"]').each(function(){var b=a(this);b.scrollspy(b.data())})})}(jQuery),+function(a){"use strict";var b=function(b){this.element=a(b)};b.prototype.show=function(){var b=this.element,c=b.closest("ul:not(.dropdown-menu)"),d=b.data("target");if(d||(d=b.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),!b.parent("li").hasClass("active")){var e=c.find(".active:last a")[0],f=a.Event("show.bs.tab",{relatedTarget:e});if(b.trigger(f),!f.isDefaultPrevented()){var g=a(d);this.activate(b.parent("li"),c),this.activate(g,g.parent(),function(){b.trigger({type:"shown.bs.tab",relatedTarget:e})})}}},b.prototype.activate=function(b,c,d){function e(){f.removeClass("active").find("> .dropdown-menu > .active").removeClass("active"),b.addClass("active"),g?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu")&&b.closest("li.dropdown").addClass("active"),d&&d()}var f=c.find("> .active"),g=d&&a.support.transition&&f.hasClass("fade");g?f.one(a.support.transition.end,e).emulateTransitionEnd(150):e(),f.removeClass("in")};var c=a.fn.tab;a.fn.tab=function(c){return this.each(function(){var d=a(this),e=d.data("bs.tab");e||d.data("bs.tab",e=new b(this)),"string"==typeof c&&e[c]()})},a.fn.tab.Constructor=b,a.fn.tab.noConflict=function(){return a.fn.tab=c,this},a(document).on("click.bs.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"]',function(b){b.preventDefault(),a(this).tab("show")})}(jQuery),+function(a){"use strict";var b=function(c,d){this.options=a.extend({},b.DEFAULTS,d),this.$window=a(window).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(c),this.affixed=this.unpin=this.pinnedOffset=null,this.checkPosition()};b.RESET="affix affix-top affix-bottom",b.DEFAULTS={offset:0},b.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(b.RESET).addClass("affix");var a=this.$window.scrollTop(),c=this.$element.offset();return this.pinnedOffset=c.top-a},b.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},b.prototype.checkPosition=function(){if(this.$element.is(":visible")){var c=a(document).height(),d=this.$window.scrollTop(),e=this.$element.offset(),f=this.options.offset,g=f.top,h=f.bottom;"top"==this.affixed&&(e.top+=d),"object"!=typeof f&&(h=g=f),"function"==typeof g&&(g=f.top(this.$element)),"function"==typeof h&&(h=f.bottom(this.$element));var i=null!=this.unpin&&d+this.unpin<=e.top?!1:null!=h&&e.top+this.$element.height()>=c-h?"bottom":null!=g&&g>=d?"top":!1;if(this.affixed!==i){this.unpin&&this.$element.css("top","");var j="affix"+(i?"-"+i:""),k=a.Event(j+".bs.affix");this.$element.trigger(k),k.isDefaultPrevented()||(this.affixed=i,this.unpin="bottom"==i?this.getPinnedOffset():null,this.$element.removeClass(b.RESET).addClass(j).trigger(a.Event(j.replace("affix","affixed"))),"bottom"==i&&this.$element.offset({top:c-h-this.$element.height()}))}}};var c=a.fn.affix;a.fn.affix=function(c){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof c&&c;e||d.data("bs.affix",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.affix.Constructor=b,a.fn.affix.noConflict=function(){return a.fn.affix=c,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var b=a(this),c=b.data();c.offset=c.offset||{},c.offsetBottom&&(c.offset.bottom=c.offsetBottom),c.offsetTop&&(c.offset.top=c.offsetTop),b.affix(c)})})}(jQuery); -------------------------------------------------------------------------------- /rfhub/static/js/lodash.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Lo-Dash 2.4.1 (Custom Build) lodash.com/license | Underscore.js 1.5.2 underscorejs.org/LICENSE 4 | * Build: `lodash modern -o ./dist/lodash.js` 5 | */ 6 | ;(function(){function n(n,t,e){e=(e||0)-1;for(var r=n?n.length:0;++ea||typeof i=="undefined")return 1;if(ie?0:e);++r=b&&i===n,l=[];if(f){var p=o(r);p?(i=t,r=p):f=false}for(;++ui(r,p)&&l.push(p);return f&&c(r),l}function ut(n,t,e,r){r=(r||0)-1;for(var u=n?n.length:0,o=[];++r=b&&f===n,h=u||v?a():s; 18 | for(v&&(h=o(h),f=t);++if(h,y))&&((u||v)&&h.push(y),s.push(g))}return v?(l(h.k),c(h)):u&&l(h),s}function lt(n){return function(t,e,r){var u={};e=J.createCallback(e,r,3),r=-1;var o=t?t.length:0;if(typeof o=="number")for(;++re?Ie(0,o+e):e)||0,Te(n)?i=-1o&&(o=a)}}else t=null==t&&kt(n)?r:J.createCallback(t,e,3),St(n,function(n,e,r){e=t(n,e,r),e>u&&(u=e,o=n)});return o}function Dt(n,t,e,r){if(!n)return e;var u=3>arguments.length;t=J.createCallback(t,r,4);var o=-1,i=n.length;if(typeof i=="number")for(u&&(e=n[++o]);++oarguments.length;return t=J.createCallback(t,r,4),Et(n,function(n,r,o){e=u?(u=false,n):t(e,n,r,o)}),e}function Tt(n){var t=-1,e=n?n.length:0,r=Xt(typeof e=="number"?e:0);return St(n,function(n){var e=at(0,++t);r[t]=r[e],r[e]=n}),r}function Ft(n,t,e){var r;t=J.createCallback(t,e,3),e=-1;var u=n?n.length:0;if(typeof u=="number")for(;++er?Ie(0,u+r):r||0}else if(r)return r=zt(t,e),t[r]===e?r:-1;return n(t,e,r)}function qt(n,t,e){if(typeof t!="number"&&null!=t){var r=0,u=-1,o=n?n.length:0;for(t=J.createCallback(t,e,3);++u>>1,e(n[r])e?0:e);++t=v; 29 | m?(i&&(i=ve(i)),s=f,a=n.apply(l,o)):i||(i=_e(r,v))}return m&&c?c=ve(c):c||t===h||(c=_e(u,t)),e&&(m=true,a=n.apply(l,o)),!m||c||i||(o=l=null),a}}function Ut(n){return n}function Gt(n,t,e){var r=true,u=t&&bt(t);t&&(e||u.length)||(null==e&&(e=t),o=Q,t=n,n=J,u=bt(t)),false===e?r=false:wt(e)&&"chain"in e&&(r=e.chain);var o=n,i=dt(o);St(u,function(e){var u=n[e]=t[e];i&&(o.prototype[e]=function(){var t=this.__chain__,e=this.__wrapped__,i=[e];if(be.apply(i,arguments),i=u.apply(n,i),r||t){if(e===i&&wt(i))return this; 30 | i=new o(i),i.__chain__=t}return i})})}function Ht(){}function Jt(n){return function(t){return t[n]}}function Qt(){return this.__wrapped__}e=e?Y.defaults(G.Object(),e,Y.pick(G,A)):G;var Xt=e.Array,Yt=e.Boolean,Zt=e.Date,ne=e.Function,te=e.Math,ee=e.Number,re=e.Object,ue=e.RegExp,oe=e.String,ie=e.TypeError,ae=[],fe=re.prototype,le=e._,ce=fe.toString,pe=ue("^"+oe(ce).replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/toString| for [^\]]+/g,".*?")+"$"),se=te.ceil,ve=e.clearTimeout,he=te.floor,ge=ne.prototype.toString,ye=vt(ye=re.getPrototypeOf)&&ye,me=fe.hasOwnProperty,be=ae.push,_e=e.setTimeout,de=ae.splice,we=ae.unshift,je=function(){try{var n={},t=vt(t=re.defineProperty)&&t,e=t(n,n,n)&&t 31 | }catch(r){}return e}(),ke=vt(ke=re.create)&&ke,xe=vt(xe=Xt.isArray)&&xe,Ce=e.isFinite,Oe=e.isNaN,Ne=vt(Ne=re.keys)&&Ne,Ie=te.max,Se=te.min,Ee=e.parseInt,Re=te.random,Ae={};Ae[$]=Xt,Ae[T]=Yt,Ae[F]=Zt,Ae[B]=ne,Ae[q]=re,Ae[W]=ee,Ae[z]=ue,Ae[P]=oe,Q.prototype=J.prototype;var De=J.support={};De.funcDecomp=!vt(e.a)&&E.test(s),De.funcNames=typeof ne.name=="string",J.templateSettings={escape:/<%-([\s\S]+?)%>/g,evaluate:/<%([\s\S]+?)%>/g,interpolate:N,variable:"",imports:{_:J}},ke||(nt=function(){function n(){}return function(t){if(wt(t)){n.prototype=t; 32 | var r=new n;n.prototype=null}return r||e.Object()}}());var $e=je?function(n,t){M.value=t,je(n,"__bindData__",M)}:Ht,Te=xe||function(n){return n&&typeof n=="object"&&typeof n.length=="number"&&ce.call(n)==$||false},Fe=Ne?function(n){return wt(n)?Ne(n):[]}:H,Be={"&":"&","<":"<",">":">",'"':""","'":"'"},We=_t(Be),qe=ue("("+Fe(We).join("|")+")","g"),ze=ue("["+Fe(Be).join("")+"]","g"),Pe=ye?function(n){if(!n||ce.call(n)!=q)return false;var t=n.valueOf,e=vt(t)&&(e=ye(t))&&ye(e);return e?n==e||ye(n)==e:ht(n) 33 | }:ht,Ke=lt(function(n,t,e){me.call(n,e)?n[e]++:n[e]=1}),Le=lt(function(n,t,e){(me.call(n,e)?n[e]:n[e]=[]).push(t)}),Me=lt(function(n,t,e){n[e]=t}),Ve=Rt,Ue=vt(Ue=Zt.now)&&Ue||function(){return(new Zt).getTime()},Ge=8==Ee(d+"08")?Ee:function(n,t){return Ee(kt(n)?n.replace(I,""):n,t||0)};return J.after=function(n,t){if(!dt(t))throw new ie;return function(){return 1>--n?t.apply(this,arguments):void 0}},J.assign=U,J.at=function(n){for(var t=arguments,e=-1,r=ut(t,true,false,1),t=t[2]&&t[2][t[1]]===n?1:r.length,u=Xt(t);++e=b&&o(r?e[r]:s)))}var p=e[0],h=-1,g=p?p.length:0,y=[];n:for(;++h(m?t(m,v):f(s,v))){for(r=u,(m||s).push(v);--r;)if(m=i[r],0>(m?t(m,v):f(e[r],v)))continue n;y.push(v)}}for(;u--;)(m=i[u])&&c(m);return l(i),l(s),y},J.invert=_t,J.invoke=function(n,t){var e=p(arguments,2),r=-1,u=typeof t=="function",o=n?n.length:0,i=Xt(typeof o=="number"?o:0);return St(n,function(n){i[++r]=(u?t:n[t]).apply(n,e)}),i},J.keys=Fe,J.map=Rt,J.mapValues=function(n,t,e){var r={}; 39 | return t=J.createCallback(t,e,3),h(n,function(n,e,u){r[e]=t(n,e,u)}),r},J.max=At,J.memoize=function(n,t){function e(){var r=e.cache,u=t?t.apply(this,arguments):m+arguments[0];return me.call(r,u)?r[u]:r[u]=n.apply(this,arguments)}if(!dt(n))throw new ie;return e.cache={},e},J.merge=function(n){var t=arguments,e=2;if(!wt(n))return n;if("number"!=typeof t[2]&&(e=t.length),3e?Ie(0,r+e):Se(e,r-1))+1);r--;)if(n[r]===t)return r;return-1},J.mixin=Gt,J.noConflict=function(){return e._=le,this},J.noop=Ht,J.now=Ue,J.parseInt=Ge,J.random=function(n,t,e){var r=null==n,u=null==t;return null==e&&(typeof n=="boolean"&&u?(e=n,n=1):u||typeof t!="boolean"||(e=t,u=true)),r&&u&&(t=1),n=+n||0,u?(t=n,n=0):t=+t||0,e||n%1||t%1?(e=Re(),Se(n+e*(t-n+parseFloat("1e-"+((e+"").length-1))),t)):at(n,t) 50 | },J.reduce=Dt,J.reduceRight=$t,J.result=function(n,t){if(n){var e=n[t];return dt(e)?n[t]():e}},J.runInContext=s,J.size=function(n){var t=n?n.length:0;return typeof t=="number"?t:Fe(n).length},J.some=Ft,J.sortedIndex=zt,J.template=function(n,t,e){var r=J.templateSettings;n=oe(n||""),e=_({},e,r);var u,o=_({},e.imports,r.imports),r=Fe(o),o=xt(o),a=0,f=e.interpolate||S,l="__p+='",f=ue((e.escape||S).source+"|"+f.source+"|"+(f===N?x:S).source+"|"+(e.evaluate||S).source+"|$","g");n.replace(f,function(t,e,r,o,f,c){return r||(r=o),l+=n.slice(a,c).replace(R,i),e&&(l+="'+__e("+e+")+'"),f&&(u=true,l+="';"+f+";\n__p+='"),r&&(l+="'+((__t=("+r+"))==null?'':__t)+'"),a=c+t.length,t 51 | }),l+="';",f=e=e.variable,f||(e="obj",l="with("+e+"){"+l+"}"),l=(u?l.replace(w,""):l).replace(j,"$1").replace(k,"$1;"),l="function("+e+"){"+(f?"":e+"||("+e+"={});")+"var __t,__p='',__e=_.escape"+(u?",__j=Array.prototype.join;function print(){__p+=__j.call(arguments,'')}":";")+l+"return __p}";try{var c=ne(r,"return "+l).apply(v,o)}catch(p){throw p.source=l,p}return t?c(t):(c.source=l,c)},J.unescape=function(n){return null==n?"":oe(n).replace(qe,gt)},J.uniqueId=function(n){var t=++y;return oe(null==n?"":n)+t 52 | },J.all=Ot,J.any=Ft,J.detect=It,J.findWhere=It,J.foldl=Dt,J.foldr=$t,J.include=Ct,J.inject=Dt,Gt(function(){var n={};return h(J,function(t,e){J.prototype[e]||(n[e]=t)}),n}(),false),J.first=Bt,J.last=function(n,t,e){var r=0,u=n?n.length:0;if(typeof t!="number"&&null!=t){var o=u;for(t=J.createCallback(t,e,3);o--&&t(n[o],o,n);)r++}else if(r=t,null==r||e)return n?n[u-1]:v;return p(n,Ie(0,u-r))},J.sample=function(n,t,e){return n&&typeof n.length!="number"&&(n=xt(n)),null==t||e?n?n[at(0,n.length-1)]:v:(n=Tt(n),n.length=Se(Ie(0,t),n.length),n) 53 | },J.take=Bt,J.head=Bt,h(J,function(n,t){var e="sample"!==t;J.prototype[t]||(J.prototype[t]=function(t,r){var u=this.__chain__,o=n(this.__wrapped__,t,r);return u||null!=t&&(!r||e&&typeof t=="function")?new Q(o,u):o})}),J.VERSION="2.4.1",J.prototype.chain=function(){return this.__chain__=true,this},J.prototype.toString=function(){return oe(this.__wrapped__)},J.prototype.value=Qt,J.prototype.valueOf=Qt,St(["join","pop","shift"],function(n){var t=ae[n];J.prototype[n]=function(){var n=this.__chain__,e=t.apply(this.__wrapped__,arguments); 54 | return n?new Q(e,n):e}}),St(["push","reverse","sort","unshift"],function(n){var t=ae[n];J.prototype[n]=function(){return t.apply(this.__wrapped__,arguments),this}}),St(["concat","slice","splice"],function(n){var t=ae[n];J.prototype[n]=function(){return new Q(t.apply(this.__wrapped__,arguments),this.__chain__)}}),J}var v,h=[],g=[],y=0,m=+new Date+"",b=75,_=40,d=" \t\x0B\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000",w=/\b__p\+='';/g,j=/\b(__p\+=)''\+/g,k=/(__e\(.*?\)|\b__t\))\+'';/g,x=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,C=/\w*$/,O=/^\s*function[ \n\r\t]+\w/,N=/<%=([\s\S]+?)%>/g,I=RegExp("^["+d+"]*0+(?=.$)"),S=/($^)/,E=/\bthis\b/,R=/['\n\r\t\u2028\u2029\\]/g,A="Array Boolean Date Function Math Number Object RegExp String _ attachEvent clearTimeout isFinite isNaN parseInt setTimeout".split(" "),D="[object Arguments]",$="[object Array]",T="[object Boolean]",F="[object Date]",B="[object Function]",W="[object Number]",q="[object Object]",z="[object RegExp]",P="[object String]",K={}; 55 | K[B]=false,K[D]=K[$]=K[T]=K[F]=K[W]=K[q]=K[z]=K[P]=true;var L={leading:false,maxWait:0,trailing:false},M={configurable:false,enumerable:false,value:null,writable:false},V={"boolean":false,"function":true,object:true,number:false,string:false,undefined:false},U={"\\":"\\","'":"'","\n":"n","\r":"r","\t":"t","\u2028":"u2028","\u2029":"u2029"},G=V[typeof window]&&window||this,H=V[typeof exports]&&exports&&!exports.nodeType&&exports,J=V[typeof module]&&module&&!module.nodeType&&module,Q=J&&J.exports===H&&H,X=V[typeof global]&&global;!X||X.global!==X&&X.window!==X||(G=X); 56 | var Y=s();typeof define=="function"&&typeof define.amd=="object"&&define.amd?(G._=Y, define(function(){return Y})):H&&J?Q?(J.exports=Y)._=Y:H._=Y:G._=Y}).call(this); 57 | -------------------------------------------------------------------------------- /rfhub/static/js/query-string.js: -------------------------------------------------------------------------------- 1 | /*! 2 | query-string 3 | Parse and stringify URL query strings 4 | https://github.com/sindresorhus/query-string 5 | by Sindre Sorhus 6 | MIT License 7 | */ 8 | (function () { 9 | 'use strict'; 10 | var queryString = {}; 11 | 12 | queryString.parse = function (str) { 13 | if (typeof str !== 'string') { 14 | return {}; 15 | } 16 | 17 | str = str.trim().replace(/^(\?|#)/, ''); 18 | 19 | if (!str) { 20 | return {}; 21 | } 22 | 23 | return str.trim().split('&').reduce(function (ret, param) { 24 | var parts = param.replace(/\+/g, ' ').split('='); 25 | var key = parts[0]; 26 | var val = parts[1]; 27 | 28 | key = decodeURIComponent(key); 29 | // missing `=` should be `null`: 30 | // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters 31 | val = val === undefined ? null : decodeURIComponent(val); 32 | 33 | if (!ret.hasOwnProperty(key)) { 34 | ret[key] = val; 35 | } else if (Array.isArray(ret[key])) { 36 | ret[key].push(val); 37 | } else { 38 | ret[key] = [ret[key], val]; 39 | } 40 | 41 | return ret; 42 | }, {}); 43 | }; 44 | 45 | queryString.stringify = function (obj) { 46 | return obj ? Object.keys(obj).map(function (key) { 47 | var val = obj[key]; 48 | 49 | if (Array.isArray(val)) { 50 | return val.map(function (val2) { 51 | return encodeURIComponent(key) + '=' + encodeURIComponent(val2); 52 | }).join('&'); 53 | } 54 | 55 | return encodeURIComponent(key) + '=' + encodeURIComponent(val); 56 | }).join('&') : ''; 57 | }; 58 | 59 | if (typeof define === 'function' && define.amd) { 60 | define(function() { return queryString; }); 61 | } else if (typeof module !== 'undefined' && module.exports) { 62 | module.exports = queryString; 63 | } else { 64 | window.queryString = queryString; 65 | } 66 | })(); 67 | -------------------------------------------------------------------------------- /rfhub/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.1" 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | To push a new version to PyPi, update the version number 3 | in rfhub/version.py and then run the following commands: 4 | 5 | $ python setup.py sdist 6 | $ python3 -m twine upload dist/* 7 | 8 | 9 | """ 10 | from setuptools import setup 11 | 12 | filename = 'rfhub/version.py' 13 | exec(open(filename).read()) 14 | 15 | setup( 16 | name = 'robotframework-hub', 17 | version = __version__, 18 | author = 'Bryan Oakley', 19 | author_email = 'bryan.oakley@gmail.com', 20 | url = 'https://github.com/boakley/robotframework-hub/', 21 | keywords = 'robotframework', 22 | license = 'Apache License 2.0', 23 | description = 'Webserver for robot framework assets', 24 | long_description = open('README.md').read(), 25 | long_description_content_type = "text/markdown", 26 | zip_safe = True, 27 | include_package_data = True, 28 | install_requires = ['Flask', 'watchdog', 'robotframework', 'tornado'], 29 | classifiers = [ 30 | "Development Status :: 5 - Production/Stable", 31 | "License :: OSI Approved :: Apache Software License", 32 | "Operating System :: OS Independent", 33 | "Framework :: Robot Framework", 34 | "Programming Language :: Python :: 3", 35 | "Topic :: Software Development :: Testing", 36 | "Topic :: Software Development :: Quality Assurance", 37 | "Intended Audience :: Developers", 38 | ], 39 | packages =[ 40 | 'rfhub', 41 | 'rfhub.blueprints', 42 | 'rfhub.blueprints.api', 43 | 'rfhub.blueprints.doc', 44 | 'rfhub.blueprints.dashboard', 45 | ], 46 | scripts =[], 47 | entry_points={ 48 | 'console_scripts': [ 49 | "rfhub = rfhub.__main__:main" 50 | ] 51 | } 52 | ) 53 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | To run the tests in this folder, cd to the root of the project and run 2 | the following command: 3 | 4 | robot -A tests/conf/default.args tests 5 | 6 | The report and log files will be placed in tests/results 7 | 8 | -------------------------------------------------------------------------------- /tests/acceptance/__init__.robot: -------------------------------------------------------------------------------- 1 | *** Variables *** 2 | | # this may be overridden in default.args 3 | | ${PORT} | 7071 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/acceptance/api/__init__.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Resource | tests/keywords/miscKeywords.robot 3 | 4 | | Suite Setup | Start rfhub | --port | ${PORT} 5 | | Suite Teardown | Stop rfhub 6 | -------------------------------------------------------------------------------- /tests/acceptance/api/query.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Library | RequestsLibrary 3 | | Library | Collections 4 | | Resource | ${KEYWORD_DIR}/APIKeywords.robot 5 | | Suite Setup | Create session | rfhub | url=http://${host}:${port} 6 | | Suite Teardown | Delete All Sessions 7 | | Force Tags | api 8 | 9 | 10 | *** Variables *** 11 | | @{all data keys} 12 | | ... | api_keyword_url | api_library_url | args | doc 13 | | ... | doc_keyword_url | library | collection_id 14 | | ... | name | synopsis 15 | 16 | *** Test Cases *** 17 | | Query with no ?fields parameter returns all expected fields 18 | | | [Setup] | Run keywords 19 | | | ... | Do a GET on | /api/keywords?pattern=none+shall+pass 20 | | | ... | AND | Get first returned keyword 21 | | | 22 | | | :FOR | ${key} | IN | @{all data keys} 23 | | | | dictionary should contain key | ${KEYWORD} | ${key} 24 | 25 | | Query with explicit fields (?fields=name,synopsis) 26 | | | [Setup] | Run keywords 27 | | | ... | Do a GET on | /api/keywords?pattern=none+shall+pass&fields=name,synopsis 28 | | | ... | AND | Get first returned keyword 29 | | | 30 | | | ${expected keys}= | create list | name | synopsis 31 | | | ${keys}= | Get dictionary keys | ${KEYWORD} 32 | | | Sort list | ${keys} 33 | | | lists should be equal | ${keys} | ${expected keys} 34 | | | ... | Expected ${expected keys} but got ${keys} 35 | 36 | *** Keywords *** 37 | | Get first returned keyword 38 | | | [Documentation] 39 | | | ... | Returns the first keyword from the result of the most recent GET 40 | | | ... | This pulls out the first keyword from the test variable ${JSON} 41 | | | ... | and stores it in the test variable ${KEYWORD} 42 | | | 43 | | | ${keywords list}= | Get from dictionary | ${JSON} | keywords 44 | | | ${KEYWORD}= | Get from list | ${keywords list} | 0 45 | | | Set test variable | ${KEYWORD} 46 | -------------------------------------------------------------------------------- /tests/acceptance/api/smoke.robot: -------------------------------------------------------------------------------- 1 | This is an experiment, to create a data driven test where the test 2 | name is the test data. Clever, or too clever? It certainly reads nice. 3 | 4 | *** Settings *** 5 | | Library | RequestsLibrary 6 | | Library | Collections 7 | | Resource | ${KEYWORD_DIR}/APIKeywords.robot 8 | | Suite Setup | Create session | rfhub | url=http://${host}:${port} 9 | | Suite Teardown | Delete All Sessions 10 | | Test Template | Verify URL return codes 11 | | Force Tags | smoke | api 12 | 13 | *** Keywords *** 14 | | Verify URL return codes 15 | | | [Arguments] | ${expected return code} 16 | | | Do a GET on | ${TEST_NAME} 17 | | | Status code should be | ${expected return code} 18 | 19 | *** Test Cases *** 20 | | # url | # expected response code 21 | | /api/keywords/ | 200 22 | | /api/keywords | 200 23 | | /api/keywords?library=builtin | 200 24 | | /api/keywords?pattern=Should* | 200 25 | | /api/keywords/builtin/Should%20be%20equal | 200 26 | | /keyword | 404 27 | | /api/keywords/unknown_library/unknown_keyword | 404 28 | | /api/keywords/builtin/unknown_keyword | 404 29 | | /api/libraries | 200 30 | | /api/libraries/BuiltIn | 200 31 | 32 | # need to move these to a separate file since 33 | # this template keyword expects JSON output 34 | #| /doc | 200 35 | #| /doc/keywords/BuiltIn | 200 36 | #| /doc/keywords/BuiltIn#Evaluate | 200 37 | 38 | -------------------------------------------------------------------------------- /tests/acceptance/doc/__init__.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Resource | tests/keywords/miscKeywords.robot 3 | 4 | | Suite Setup | Start rfhub | --port | ${PORT} 5 | | # we want to control precisely which libraries are loaded, 6 | | # so we aren't so dependent on what is actually installed 7 | | ... | --no-installed-keywords 8 | | ... | tests/keywords 9 | | ... | BuiltIn 10 | | ... | Collections 11 | | ... | Easter 12 | | ... | Screenshot 13 | | ... | SeleniumLibrary 14 | | Suite Teardown | Stop rfhub 15 | 16 | *** Variables *** 17 | | ${HOST} | localhost 18 | | ${PORT} | 7071 19 | | ${ROOT} | http://${HOST}:${PORT} -------------------------------------------------------------------------------- /tests/acceptance/doc/coreFeatures.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Library | Collections 3 | | Library | SeleniumLibrary 4 | | Library | Dialogs 5 | | Resource | ${KEYWORD_DIR}/APIKeywords.robot 6 | | Suite Setup | Run keywords 7 | | ... | Create session | rfhub | url=http://${host}:${port} | AND 8 | | ... | Open Browser | ${ROOT} | ${BROWSER} 9 | | Suite Teardown | Run keywords 10 | | ... | Delete All Sessions | AND 11 | | ... | Close all browsers 12 | 13 | *** Variables *** 14 | | ${ROOT} | http://${HOST}:${PORT} 15 | 16 | *** Test Cases *** 17 | | Nav panel shows correct number of collections 18 | | | [Documentation] 19 | | | ... | Verify that the nav panel has the correct number of items 20 | | | 21 | | | [Tags] | navpanel 22 | | | [Setup] | Run keywords 23 | | | ... | Get list of libraries via the API | AND 24 | | | ... | Go to | ${ROOT}/doc/ 25 | | | 26 | | | ${actual} | Get element count | //*[@id="left"]/ul/li/label 27 | | | Should Be Equal As Integers | ${actual} | 11 28 | | | ... | Expected 11 items in navlist, found ${actual} 29 | 30 | | Nav panel shows all libraries 31 | | | [Documentation] 32 | | | ... | Verify that the nav panel shows all of the libraries 33 | | | 34 | | | [Tags] | navpanel 35 | | | [Setup] | Run keywords 36 | | | ... | Get list of libraries via the API | AND 37 | | | ... | Go to | ${ROOT}/doc/ 38 | | | 39 | | | :FOR | ${lib} | IN | @{libraries} 40 | | | | Page should contain element 41 | | | | ... | //*[@id="left"]/ul/li/label[./text()='${lib["name"]}'] 42 | | | | ... | limit=1 43 | 44 | | Main panel shows correct number of libraries 45 | | | [Documentation] 46 | | | ... | Verify that the main panel has the correct number of items 47 | | | 48 | | | [Tags] | navpanel 49 | | | [Setup] | Run keywords 50 | | | ... | Get list of libraries via the API | AND 51 | | | ... | Go to | ${ROOT}/doc/ 52 | | | 53 | | | ${actual} | Get element count | //*[@id="right"]/div[1]/table/tbody/tr/td/a 54 | | | # why 5? Because we explicitly load 5 libraries in the suite setup 55 | | | Should Be Equal As Integers | ${actual} | 5 56 | | | ... | Expected 5 items in navlist, found ${actual} 57 | 58 | 59 | | Main panel shows all libraries 60 | | | [Documentation] 61 | | | ... | Verify that the main panel shows all of the libraries 62 | | | 63 | | | [Setup] | Run keywords 64 | | | ... | Get list of libraries via the API | AND 65 | | | ... | Go to | ${ROOT}/doc 66 | | | 67 | | | :FOR | ${lib} | IN | @{libraries} 68 | | | | ${name} | Get from dictionary | ${lib} | name 69 | | | | Page should contain element 70 | | | | ... | //*[@id="right"]//a[./text()='${name}'] 71 | | | | ... | limit=1 72 | 73 | | Main panel shows all library descriptions 74 | | | [Documentation] 75 | | | ... | Verify that the main panel shows all of the library descriptions 76 | | | 77 | | | [Setup] | Run keywords 78 | | | ... | Get list of libraries via the API | AND 79 | | | ... | Go to | ${ROOT}/doc 80 | | | 81 | | | ${section}= | Set variable | 82 | | | :FOR | ${lib} | IN | @{libraries} 83 | | | | ${expected}= | Get from dictionary | ${lib} | synopsis 84 | | | | ${actual}= | Get text | //*[@id="right"]//a[text()='${lib["name"]}']/../following-sibling::td 85 | | | | Should be equal | ${expected} | ${actual} 86 | 87 | *** Keywords *** 88 | | Get list of libraries via the API 89 | | | [Documentation] 90 | | | ... | Uses the hub API to get a list of libraries. 91 | | | ... | The libraries are stored in a suite-level variable 92 | | | 93 | | | # N.B. 'Do a git on' stores the response in a test variable named ${JSON} 94 | | | Do a get on | /api/libraries 95 | | | ${libraries}= | Get From Dictionary | ${JSON} | libraries 96 | | | Set suite variable | ${libraries} 97 | -------------------------------------------------------------------------------- /tests/acceptance/doc/search.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Library | SeleniumLibrary 3 | 4 | | Suite Setup | Open Browser | ${ROOT} | ${BROWSER} 5 | | Suite Teardown | Close all browsers 6 | | Force Tags | browser-${BROWSER} 7 | 8 | *** Variables *** 9 | | ${HOST} | localhost 10 | | ${PORT} | 7071 11 | | ${ROOT} | http://${HOST}:${PORT} 12 | 13 | *** Test Cases *** 14 | | Base URL 15 | | | [Documentation] 16 | | | ... | Objective: verify base url returns a page 17 | | | [Tags] | smoke 18 | | | Go to | ${ROOT}/doc 19 | | | Location should be | ${ROOT}/doc/ 20 | | | Element should contain | //*[@id="right"]/div[@id='summary-libraries']/h1 | Libraries 21 | | | Element should contain | //*[@id="right"]/div[@id='summary-resources']/h1 | Resource Files 22 | | | Capture page screenshot 23 | 24 | | Search via search box redirects to /doc/keywords 25 | | | [Documentation] 26 | | | ... | Objective: search via the search box and verify that the correct page is loaded 27 | | | [Tags] | smoke 28 | | | Go to | ${ROOT}/doc 29 | | | Search for | none shall pass 30 | | | Location should be | ${ROOT}/doc/keywords/?pattern=none%20shall%20pass 31 | 32 | | Search summary, no results (searching for X found 0 keywords) 33 | | | [Documentation] 34 | | | ... | Objective: visit a bookmarked search page and verify that 35 | | | ... | the right number of search terms was found 36 | | | [Tags] | smoke 37 | | | 38 | | | Go to | ${ROOT}/doc 39 | | | Search for | -xyzzy- 40 | | | Page should contain | Searching for '-xyzzy-' found 0 keywords 41 | 42 | | Search summary, one result (searching for X found 1 keywords) 43 | | | [Documentation] 44 | | | ... | Objective: visit a bookmarked search page and verify that 45 | | | ... | the right number of search terms was found 46 | | | ... | 47 | | | ... | This test assumes the "Easter" library is installed. 48 | | | [Tags] | smoke 49 | | | 50 | | | Go to | ${ROOT}/doc 51 | | | Search for | none shall pass 52 | | | Page should contain | Searching for 'none shall pass' found 1 keywords 53 | 54 | | Search summary, multiple results (searching for X found Y keywords) 55 | | | [Documentation] 56 | | | ... | Objective: visit a bookmarked search page and verify that 57 | | | ... | the right number of search terms was found 58 | | | [Tags] | smoke 59 | | | 60 | | | Go to | ${ROOT}/doc 61 | | | Search for | rfhub 62 | | | Page should contain | Searching for 'rfhub' found 2 keywords 63 | 64 | | Correct number of search results - zero results 65 | | | [Documentation] 66 | | | ... | Objective: validate that we get the proper number of rows in 67 | | | ... | the table of keywords 68 | | | 69 | | | Go to | ${ROOT}/doc 70 | | | Search for | -xyzzy- 71 | | | ${count}= | Get element count | xpath://table[@id='keyword-table']/tbody/tr 72 | | | Should be equal as integers | ${count} | 0 73 | | | ... | Expected zero rows in the table body, got ${count} instead 74 | 75 | | Correct number of search results - 1 result 76 | | | [Documentation] 77 | | | ... | Objective: validate that we get the proper number of rows in 78 | | | ... | the table of keywords 79 | | | 80 | | | Go to | ${ROOT}/doc 81 | | | Search for | none shall pass 82 | | | ${count}= | Get element count | xpath://table[@id='keyword-table']/tbody/tr 83 | | | Should be equal as integers | ${count} | 1 84 | | | ... | Expected one row in the table body, got ${count} instead 85 | 86 | | Correct number of search results - several results 87 | | | [Documentation] 88 | | | ... | Objective: validate that we get the proper number of rows in 89 | | | ... | the table of keywords 90 | | | 91 | | | Go to | ${ROOT}/doc 92 | | | # this should find two results, from our own miscKeywords file 93 | | | Search for | rfhub 94 | | | ${count}= | Get element count | xpath://table[@id='keyword-table']/tbody/tr 95 | | | Should be equal as integers | ${count} | 2 96 | | | ... | Expected two rows in the table body, got ${count} instead 97 | 98 | | Keyword search URL goes to search page 99 | | | [Documentation] 100 | | | ... | Objective: verify that the keyword search URL works 101 | | | [Tags] | smoke 102 | | | 103 | | | Go to | ${ROOT}/doc 104 | | | Search for | none shall pass 105 | | | Page should contain | Searching for 'none shall pass' found 1 keywords 106 | | | Page Should Contain Link | None Shall Pass 107 | 108 | | Using the name: prefix 109 | | | [Documentation] 110 | | | ... | Objective: verify the name: prefix works 111 | | | Go to | ${ROOT}/doc 112 | | | Search for | name:screenshot 113 | | | Page should contain | Searching for 'screenshot' found 6 keywords 114 | 115 | | Using the in: prefix 116 | | | [Documentation] 117 | | | ... | Objective: verify the in: prefix works 118 | | | Go to | ${ROOT}/doc 119 | | | Search for | screenshot in:Selenium2Library 120 | | | Page should contain | Searching for 'screenshot' found 4 keywords 121 | | | ... | Expected results to include exactly 4 keywords, but it didn't 122 | 123 | | Clicking search result link shows keyword 124 | | | [Documentation] 125 | | | ... | Objective: make sure that clicking a link causes the 126 | | | ... | correct library to be displayed and the clicked-on 127 | | | ... | keyword is scrolled into view 128 | | | 129 | | | Go to | ${ROOT}/doc 130 | | | Search for | none shall pass 131 | | | Click link | link=None Shall Pass 132 | | | # N.B. "6" is the expected collection_id of the "Easter" library 133 | | | # Perhaps that's a bad thing to assume, but since this test suite 134 | | | # controls which libraries are loaded, it's a reasonably safe bet. 135 | | | Wait Until Element Is Visible | id=kw-none-shall-pass 136 | | | Location should be | ${ROOT}/doc/keywords/6/None%20Shall%20Pass/ 137 | 138 | *** Keywords *** 139 | | Search for 140 | | | [Arguments] | ${pattern} 141 | | | [Documentation] 142 | | | ... | Perform a keyword search 143 | | | ... | This keyword inserts the given pattern into the search 144 | | | ... | box and then submits the search form 145 | | | Input text | id=search-pattern | ${pattern} 146 | | | # we know the search mechanism has a built-in delay, so wait 147 | | | # until it has a chance to start working 148 | | | Sleep | 500 ms 149 | | | Wait Until Element Is Visible | id=result-count 150 | -------------------------------------------------------------------------------- /tests/acceptance/option_root.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Library | SeleniumLibrary 3 | | Resource | tests/keywords/miscKeywords.robot 4 | 5 | *** Variables *** 6 | | ${ROOT} | http://${HOST}:${PORT} 7 | 8 | *** Test Cases *** 9 | | Specify --root /doc 10 | | | [Documentation] 11 | | | ... | Verify that --root /doc works properly 12 | | | [Setup] | run keywords 13 | | | ... | start rfhub | --port | ${PORT} | --root | /doc 14 | | | ... | AND | open browser | ${ROOT} | ${BROWSER} 15 | | | [Teardown] | run keywords 16 | | | ... | stop rfhub 17 | | | ... | AND | close all browsers 18 | | | go to | ${ROOT}/ 19 | | | location should be | ${ROOT}/doc/ 20 | 21 | | Use default root (no --root option) 22 | | | [Documentation] 23 | | | ... | Verify that when --root is not supplied, we go to dashboard 24 | | | [Setup] | run keywords 25 | | | ... | start rfhub | --port | ${PORT} 26 | | | ... | AND | open browser | ${ROOT} | ${BROWSER} 27 | | | [Teardown] | run keywords 28 | | | ... | stop rfhub 29 | | | ... | AND | close all browsers 30 | | | go to | ${ROOT}/ 31 | | | location should be | ${ROOT}/doc/ 32 | -------------------------------------------------------------------------------- /tests/acceptance/options.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Library | OperatingSystem 3 | 4 | *** Test Cases *** 5 | | Help for option --root 6 | | | [Documentation] 7 | | | ... | Verify that the help message includes help for --root 8 | | | Start the hub with options | --help 9 | | | Output should contain 10 | | | ... | --root ROOT 11 | | | ... | (deprecated) Redirect root url (http://localhost:port/) to this url 12 | 13 | | Help for option -l/--library 14 | | | [Documentation] 15 | | | ... | Verify that the help message includes help for -i/--interface 16 | | | 17 | | | Start the hub with options | --help 18 | | | Output should contain 19 | | | ... | -l LIBRARY, --library LIBRARY 20 | | | ... | load the given LIBRARY (eg: -l DatabaseLibrary) 21 | 22 | | Help for option -i/--interface 23 | | | [Documentation] 24 | | | ... | Verify that the help message includes help for -i/--interface 25 | | | 26 | | | Start the hub with options | --help 27 | | | Output should contain 28 | | | ... | -i INTERFACE, --interface INTERFACE 29 | | | ... | use the given network interface 30 | | | ... | (default=127.0.0.1) 31 | 32 | | Help for option -p / --port 33 | | | [Documentation] 34 | | | ... | Verify that the help message includes help for -p/--port 35 | | | 36 | | | Start the hub with options | --help 37 | | | Output should contain 38 | | | ... | -p PORT, --port PORT 39 | | | ... | run on the given PORT 40 | | | ... | (default=7070) 41 | 42 | | Help for option -no-installed-keywords 43 | | | [Documentation] 44 | | | ... | Verify that the help message includes help for --no-installed-keywords 45 | | | 46 | | | Start the hub with options | --help 47 | | | Output should contain 48 | | | ... | --no-installed-keywords 49 | | | ... | do not load some common installed keyword libraries 50 | 51 | | Help for option -M/--module 52 | | | [Documentation] 53 | | | ... | Verify that the help message includes help for -M/--module 54 | | | 55 | | | Start the hub with options | --help 56 | | | Output should contain 57 | | | ... | -M MODULE, --module MODULE 58 | | | ... | give the name of a module that exports one or more 59 | 60 | | Help for option --poll 61 | | | [Documentation] 62 | | | ... | Verify that the help message includes help for --poll 63 | | | 64 | | | Start the hub with options | --help 65 | | | Output should contain 66 | | | ... | --poll 67 | | | ... | use polling behavior instead of events to reload 68 | | | ... | keywords on changes (useful in VMs) 69 | 70 | *** Keywords *** 71 | | Start the hub with options 72 | | | [Arguments] | ${options} 73 | | | [Documentation] 74 | | | ... | Attempt to start the hub with the given options 75 | | | ... | 76 | | | ... | The stdout of the process will be in a test suite 77 | | | ... | variable named \${output} 78 | | | 79 | | | ${output} | Run | python -m rfhub ${options} 80 | | | Set test variable | ${output} 81 | 82 | | Output should contain 83 | | | [Arguments] | @{patterns} 84 | | | [Documentation] 85 | | | ... | Fail if the output from the previous command doesn't contain the given string 86 | | | ... | 87 | | | ... | This keyword assumes the output of the command is in 88 | | | ... | a test suite variable named \${output} 89 | | | ... | 90 | | | ... | Note: the help will be automatically wrapped, so 91 | | | ... | you can only search for relatively short strings. 92 | | | 93 | | | :FOR | ${pattern} | IN | @{patterns} 94 | | | | Run keyword if | '''${pattern}''' not in '''${output}''' 95 | | | | ... | Fail | expected '${pattern}'\n\ \ \ \ \ got '${output}' 96 | -------------------------------------------------------------------------------- /tests/acceptance/space-separated.robot: -------------------------------------------------------------------------------- 1 | *** Test cases *** 2 | Space-separated test case 3 | log hello, world 4 | 5 | | Pipe-separated test case 6 | | | log | hello, world 7 | -------------------------------------------------------------------------------- /tests/conf/default.args: -------------------------------------------------------------------------------- 1 | # default arguments; any other argument files should include 2 | # this file. 3 | --pythonpath . 4 | --variable BROWSER:chrome 5 | --variable KEYWORD_DIR:tests/keywords 6 | --variable HOST:localhost 7 | --variable PORT:7071 8 | 9 | --outputdir tests/results 10 | 11 | --exclude in-progress 12 | -------------------------------------------------------------------------------- /tests/keywords/APIKeywords.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Documentation 3 | | ... | Keywords related to the hub API 4 | | Library | RequestsLibrary 5 | 6 | *** Keywords *** 7 | 8 | | Do a GET on 9 | | | [Arguments] | ${url} 10 | | | [Documentation] 11 | | | ... | Perform a GET on the given URL. 12 | | | ... | 13 | | | ... | The response data will be stored in the suite variable 14 | | | ... | ${response}. The payload will be converted to JSON and 15 | | | ... | stored in the suite variable ${JSON}. 16 | | | ... | 17 | | | ... | Example: 18 | | | ... | \| \| \| Do a get on \| http://www.google.com \| blah blah blah 19 | | | ... | \| \| \| Do a get on \| blah blah blah blah blah 20 | | | 21 | | | ${response}= | GET Request | rfhub | ${url} 22 | | | ${JSON}= | Run keyword if | "${response.status_code}" == "200" 23 | | | ... | To JSON | ${response.content} 24 | | | Set test variable | ${JSON} 25 | | | Set test variable | ${response} 26 | 27 | | Status code should be 28 | | | [Arguments] | ${expected} 29 | | | [Documentation] 30 | | | ... | Verifies that the actual status code is the same as ${expected} 31 | | | 32 | | | Should be equal as integers | ${response.status_code} | ${expected} 33 | | | ... | Expected a status code of ${expected} but got ${response.status_code} 34 | | | ... | values=false 35 | -------------------------------------------------------------------------------- /tests/keywords/KWDBKeywords.robot: -------------------------------------------------------------------------------- 1 | *** Keywords *** 2 | | Create new KWDB instance 3 | | | [Documentation] 4 | | | ... | Creates and returns a new instance of the kwdb object. 5 | | | ... | This object will have no libraries added by default. The 6 | | | ... | object is available in the suite variable ${KWDB} 7 | | | ${KWDB}= | evaluate | rfhub.kwdb.KeywordTable() | rfhub 8 | | | Set test variable | ${KWDB} 9 | 10 | | Load installed keywords into KWDB 11 | | | [Documentation] 12 | | | ... | This calls a method to add all installed libraries into 13 | | | ... | the database referenced by the suite variable ${KWDB} 14 | | | Call method | ${KWDB} | add_installed_libraries 15 | 16 | | Get keywords from KWDB 17 | | | [Documentation] 18 | | | ... | This calls the get_keywords method of the kwdb object 19 | | | ... | referenced by ${KWDB}. It returns the data returned 20 | | | ... | by that method. 21 | | | ${keywords}= | Call method | ${KWDB} | get_keywords | * 22 | | | [Return] | ${keywords} 23 | 24 | | Load a resource file into KWDB 25 | | | [Arguments] | ${name or path} 26 | | | [Documentation] 27 | | | ... | Loads one library by name, or resoure file by path 28 | | | ... | to the database referenced by the suite variable ${KWDB} 29 | | | Call method | ${KWDB} | add | ${name or path} 30 | -------------------------------------------------------------------------------- /tests/keywords/miscKeywords.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Documentation 3 | | ... | Keywords that are of general use to all tests in this suite 4 | 5 | | Library | Process 6 | | Library | RequestsLibrary 7 | | Library | Dialogs 8 | 9 | *** Keywords *** 10 | | Start rfhub 11 | | | [Arguments] | @{options} 12 | | | [Documentation] 13 | | | ... | Starts rfhub on the port given in the variable \${PORT} 14 | | | ... | As a side effect this creates a suite variable named \${rfhub process}, 15 | | | ... | which is used by the 'Stop rfhub' keyword. 16 | | | 17 | | | # Make sure we use the same python executable used by the test runner 18 | | | ${python}= | Evaluate | sys.executable | sys 19 | | | ${rfhub process}= | Start process | ${python} | -m | rfhub | @{options} 20 | | | sleep | 5 seconds | # give the server a chance to start 21 | | | Set suite variable | ${rfhub process} 22 | | | Wait until keyword succeeds | 20 seconds | 1 second 23 | | | ... | Verify URL is reachable | /ping 24 | 25 | | Stop rfhub 26 | | | [Documentation] 27 | | | ... | Stops the rfhub process created by "Start rfhub" 28 | | | 29 | | | Terminate Process | ${rfhub process} 30 | | | ${result}= | Get process result 31 | | | Run keyword if | len('''${result.stderr}''') > 0 32 | | | ... | log | rfhub stderr: ${result.stderr} | DEBUG 33 | 34 | 35 | | Verify URL is reachable 36 | | | # This could be useful in more places than just API tests. 37 | | | # Maybe it should be moved to a different file... 38 | | | [Arguments] | ${URL} 39 | | | [Documentation] 40 | | | ... | Fail if the given URL doesn't return a status code of 200. 41 | | | Create Session | tmp | http://localhost:${PORT} 42 | | | ${response}= | Get Request | tmp | ${url} 43 | | | Should be equal as integers | ${response.status_code} | 200 44 | -------------------------------------------------------------------------------- /tests/unit/data/onekeyword.robot: -------------------------------------------------------------------------------- 1 | *** Keywords *** 2 | | Keyword #1 3 | | | Pass execution | Keyword #1 passed 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/unit/data/threekeywords.resource: -------------------------------------------------------------------------------- 1 | *** Keywords *** 2 | | Keyword #1 3 | | | [Documentation] | Documentation for Keyword #1 4 | | | Pass execution | Keyword #1 passed 5 | 6 | | Keyword #2 7 | | | [Documentation] | Documentation for Keyword #2 8 | | | Pass execution | Keyword #2 passed 9 | 10 | | Keyword #3 11 | | | [Documentation] | Documentation for Keyword #3 12 | | | Pass execution | Keyword #3 passed -------------------------------------------------------------------------------- /tests/unit/data/twokeywords.robot: -------------------------------------------------------------------------------- 1 | *** Keywords *** 2 | | Keyword #1 3 | | | [Documentation] | Documentation for Keyword #1 4 | | | Pass execution | Keyword #1 passed 5 | 6 | | Keyword #2 7 | | | [Documentation] | Documentation for Keyword #2 8 | | | Pass execution | Keyword #2 passed 9 | -------------------------------------------------------------------------------- /tests/unit/kwdb.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | | Documentation | Unit tests for the keyword database library 3 | | Library | Collections 4 | | Library | OperatingSystem 5 | | Resource | ${KEYWORD_DIR}/KWDBKeywords.robot 6 | | Force tags | kwdb 7 | | Suite Setup | Initialize suite variables 8 | 9 | *** Test Cases *** 10 | 11 | | Verify we can create an instance of the keyword database object 12 | | | [Tags] | smoke 13 | | | Create new KWDB instance 14 | | | Should be true | ${KWDB} | # "True" as in "the object exists" 15 | 16 | | Verify the initial keyword database is empty 17 | | | [Tags] | smoke 18 | | | Create new KWDB instance 19 | | | ${keywords}= | Get keywords from KWDB 20 | | | Length should be | ${keywords} | 0 21 | 22 | | Verify we can add a resource file 23 | | | [Documentation] 24 | | | ... | Verify that we can load a single resource file 25 | | | ... | This also verifies we can retrieve the expected number of keywords. 26 | | | [Tags] | smoke 27 | | | Create new KWDB instance 28 | | | Load a resource file into KWDB | ${DATA_DIR}/onekeyword.robot 29 | | | ${keywords}= | Get keywords from KWDB 30 | | | ${num keywords}= | Get length | ${keywords} 31 | | | Should be equal as integers | ${num keywords} | 1 32 | | | ... | Expected 1 keyword but found ${num keywords} | values=False 33 | 34 | | Verify we can add a resource file with .resource extension 35 | | | [Documentation] 36 | | | ... | Verify that we can load a single resource file with .resource extension. 37 | | | ... | This also verifies we can retrieve the expected number of keywords. 38 | | | [Tags] | smoke 39 | | | Create new KWDB instance 40 | | | Load a resource file into KWDB | ${DATA_DIR}/threekeywords.resource 41 | | | ${keywords}= | Get keywords from KWDB 42 | | | ${num keywords}= | Get length | ${keywords} 43 | | | Should be equal as integers | ${num keywords} | 3 44 | | | ... | Expected 3 keyword but found ${num keywords} | values=False 45 | 46 | | Verify we can add more than once resource file 47 | | | [Documentation] 48 | | | ... | Verify that we can load more than one resource file at a time. 49 | | | ... | This also verifies we can retrieve the expected number of keywords. 50 | | | [Tags] | smoke 51 | | | Create new KWDB instance 52 | | | Load a resource file into KWDB | ${DATA_DIR}/onekeyword.robot 53 | | | Load a resource file into KWDB | ${DATA_DIR}/twokeywords.robot 54 | | | ${keywords}= | Get keywords from KWDB 55 | | | ${num keywords}= | Get length | ${keywords} 56 | | | Should be equal as integers | ${num keywords} | 3 57 | | | ... | Expected 3 keywords but found ${num keywords} | values=False 58 | 59 | | Verify we can get a list of loaded resource files 60 | | | [Tags] | smoke 61 | | | Create new KWDB instance 62 | | | Load a resource file into KWDB | ${DATA_DIR}/onekeyword.robot 63 | | | Load a resource file into KWDB | ${DATA_DIR}/twokeywords.robot 64 | | | ${libraries}= | Call method | ${KWDB} | get_collections | * 65 | | | Length should be | ${libraries} | 2 66 | | | Dictionary should contain value | ${libraries[0]} | onekeyword 67 | | | Dictionary should contain value | ${libraries[1]} | twokeywords 68 | 69 | | Verify that a query returns the library name for each keyword 70 | | | [Documentation] 71 | | | ... | Verify that the library name is correct for each result from a query 72 | | | [Tags] | smoke 73 | | | Create new KWDB instance 74 | | | Load a resource file into KWDB | ${DATA_DIR}/twokeywords.robot 75 | | | ${keywords}= | Get keywords from KWDB 76 | | | ${num keywords}= | Get length | ${keywords} 77 | | | 78 | | | # the list of keywords will be made up of (id, library, keyword, doc, args) 79 | | | # tuples; make sure the library is correct for all items 80 | | | :FOR | ${kw} | IN | @{keywords} 81 | | | | Length should be | ${kw} | 5 82 | | | | ... | expected the result to contain 4 elements but it did not. 83 | | | | Should be equal | ${kw[1]} | twokeywords 84 | | | | ... | Expected the keyword library name to be "twokeywords" but it was "${kw[0]}" 85 | | | | ... | values=False 86 | 87 | | Verify that a query returns the expected keyword names 88 | | | [Documentation] 89 | | | ... | Verify that the keyword names returned from a query are correct. 90 | | | [Tags] | smoke 91 | | | Create new KWDB instance 92 | | | Load a resource file into KWDB | ${DATA_DIR}/twokeywords.robot 93 | | | # the returned value is a list of tuples; this gives us 94 | | | # a list of just keyword names 95 | | | ${keywords}= | Get keywords from KWDB 96 | | | ${keyword names}= | Evaluate | [x[2] for x in ${keywords}] 97 | | | List should contain value | ${keyword names} | Keyword #1 98 | | | List should contain value | ${keyword names} | Keyword #2 99 | 100 | | Verify that a query returns keyword documentation 101 | | | [Documentation] 102 | | | ... | Verify that documentation is returned for each keyword 103 | | | [Tags] | smoke 104 | | | Create new KWDB instance 105 | | | Load a resource file into KWDB | ${DATA_DIR}/twokeywords.robot 106 | | | ${keywords}= | Get keywords from KWDB 107 | | | # Assume these are in sorted order.... 108 | | | Should be equal | ${keywords[0][3]} | Documentation for Keyword #1 109 | | | Should be equal | ${keywords[1][3]} | Documentation for Keyword #2 110 | 111 | | Verify that we can fetch a single keyword 112 | | | [Tags] | smoke 113 | | | Create new KWDB instance 114 | | | Load a resource file into KWDB | ${DATA_DIR}/twokeywords.robot 115 | | | # we assume that since we only load one file, it has an id of 1... 116 | | | ${keyword}= | Call method | ${KWDB} | get_keyword | 1 | Keyword #1 117 | | | Should not be empty | ${keyword} 118 | 119 | | Verify that a query returns a keyword with the expected data 120 | | | [Tags] | smoke 121 | | | Create new KWDB instance 122 | | | Load a resource file into KWDB | ${DATA_DIR}/twokeywords.robot 123 | | | # we assume that since we only load one file, it has an id of 1... 124 | | | ${keyword}= | Call method | ${KWDB} | get_keyword | 1 | Keyword #1 125 | | | Dictionary should contain item | ${keyword} | name | Keyword #1 126 | | | Dictionary should contain item | ${keyword} | collection_id | 1 127 | | | Dictionary should contain item | ${keyword} | doc | Documentation for Keyword #1 128 | | | Dictionary should contain item | ${keyword} | args | [] 129 | | | # Do it again, for another keyword 130 | | | ${keyword}= | Call method | ${KWDB} | get_keyword | 1 | Keyword #2 131 | | | Dictionary should contain item | ${keyword} | name | Keyword #2 132 | | | Dictionary should contain item | ${keyword} | collection_id | 1 133 | | | Dictionary should contain item | ${keyword} | doc | Documentation for Keyword #2 134 | | | Dictionary should contain item | ${keyword} | args | [] 135 | 136 | *** Keywords *** 137 | 138 | | Initialize suite variables 139 | | | [Documentation] | Define some global variables used by the tests in this suite 140 | | | ${test dir}= | Evaluate | os.path.dirname(r"${SUITE SOURCE}") | os 141 | | | set suite variable | ${KEYWORD DIR} | ${test dir}/keywords 142 | | | set suite variable | ${DATA_DIR} | ${test dir}/data 143 | 144 | --------------------------------------------------------------------------------