1# coding=utf-8
2"""
3Tools for running BDD frameworks in python.
4You probably need to extend BddRunner (see its doc).
5
6You may also need "get_what_to_run_by_env" that gets folder (current or passed as first argument)
7"""
8import os
9import time
10import abc
11import tcmessages
12from _jb_utils import VersionAgnosticUtils
13
14__author__ = 'Ilya.Kazakevich'
15
16
17def fix_win_drive(feature_path):
18    """
19    Workaround to fix issues like http://bugs.python.org/issue7195 on windows.
20    Pass feature dir or file path as argument.
21    This function does nothing on non-windows platforms, so it could be run safely.
22
23    :param feature_path: path to feature (c:/fe.feature or /my/features)
24    """
25    current_disk = (os.path.splitdrive(os.getcwd()))[0]
26    feature_disk = (os.path.splitdrive(feature_path))[0]
27    if current_disk and feature_disk and current_disk != feature_disk:
28        os.chdir(feature_disk)
29
30
31def get_what_to_run_by_env(environment):
32    """
33    :type environment dict
34    :param environment: os.environment (files and folders should be separated with | and passed to PY_STUFF_TO_RUN).
35    Scenarios optionally could be passed as SCENARIOS (names or order numbers, depends on runner)
36    :return: tuple (base_dir, scenarios[], what_to_run(list of feature files or folders))) where dir is current or first argument from env, checking it exists
37    :rtype tuple of (str, iterable)
38    """
39    if "PY_STUFF_TO_RUN" not in environment:
40        what_to_run = ["."]
41    else:
42        what_to_run = str(environment["PY_STUFF_TO_RUN"]).split("|")
43
44    scenarios = []
45    if "SCENARIOS" in environment:
46        scenarios = str(environment["SCENARIOS"]).split("|")
47
48    if not what_to_run:
49        what_to_run = ["."]
50
51    for path in what_to_run:
52        assert os.path.exists(path), "{0} does not exist".format(path)
53
54    base_dir = what_to_run[0]
55    if os.path.isfile(what_to_run[0]):
56        base_dir = os.path.dirname(what_to_run[0])  # User may point to the file directly
57    return base_dir, scenarios, what_to_run
58
59
60def get_location(base_dir, location_file, location_line):
61    """
62    Generates location that PyCharm resolves to file
63    :param base_dir: base directory to resolve relative path against
64    :param location_file: path to file
65    :param location_line: line number
66    """
67    my_file = str(location_file).lstrip("/\\")
68    return "file:///{0}:{1}".format(os.path.normpath(os.path.join(base_dir, my_file)), location_line)
69
70
71class BddRunner(object):
72    """
73    Extends this class, implement abstract methods and use its API to implement new BDD frameworks.
74    Call "run()" to launch it.
75    This class does the following:
76    * Gets features to run (using "_get_features_to_run()") and calculates steps in it
77    * Reports steps to Intellij or TC
78    * Calls "_run_tests()" where *you* should install all hooks you need into your BDD and use "self._" functions
79    to report tests and features. It actually wraps tcmessages but adds some stuff like duration count etc
80    :param base_dir:
81    """
82    __metaclass__ = abc.ABCMeta
83
84    def __init__(self, base_dir):
85        """
86        :type base_dir str
87        :param base_dir base directory of your project
88        """
89        super(BddRunner, self).__init__()
90        self.tc_messages = tcmessages.TeamcityServiceMessages()
91        """
92        tcmessages TeamCity/Intellij test API. See TeamcityServiceMessages
93        """
94        self.__base_dir = base_dir
95        self.__last_test_start_time = None  # TODO: Doc when use
96        self.__last_test_name = None
97
98    def run(self):
99        """"
100        Runs runner. To be called right after constructor.
101        """
102        number_of_tests = self._get_number_of_tests()
103        self.tc_messages.testCount(number_of_tests)
104        self.tc_messages.testMatrixEntered()
105        if number_of_tests == 0:  # Nothing to run, so no need to report even feature/scenario start. (See PY-13623)
106            return
107        self._run_tests()
108
109    def __gen_location(self, location):
110        """
111        Generates location in format, supported by tcmessages
112        :param location object with "file" (relative to base_dir) and "line" fields.
113        :return: location in format file:line (as supported in tcmessages)
114        """
115        return get_location(self.__base_dir, location.file, location.line)
116
117    def _test_undefined(self, test_name, location):
118        """
119        Mark test as undefined
120        :param test_name: name of test
121        :type test_name str
122        :param location its location
123
124        """
125        if test_name != self.__last_test_name:
126            self._test_started(test_name, location)
127        self._test_failed(test_name, message="Test undefined", details="Please define test")
128
129    def _test_skipped(self, test_name, reason, location):
130        """
131        Mark test as skipped
132        :param test_name: name of test
133        :param reason: why test was skipped
134        :type reason str
135        :type test_name str
136        :param location its location
137
138        """
139        if test_name != self.__last_test_name:
140            self._test_started(test_name, location)
141        self.tc_messages.testIgnored(test_name, "Skipped: {0}".format(reason))
142        self.__last_test_name = None
143        pass
144
145
146    def _test_failed(self, name, message, details, duration=None):
147        """
148        Report test failure
149        :param name: test name
150        :type name str
151        :param message: failure message
152        :type message basestring
153        :param details: failure details (probably stacktrace)
154        :type details str
155        :param duration how long test took
156        :type duration int
157        """
158        self.tc_messages.testFailed(name,
159                                    message=VersionAgnosticUtils().to_unicode(message),
160                                    details=details,
161                                    duration=duration)
162        self.__last_test_name = None
163
164    def _test_passed(self, name, duration=None):
165        """
166        Reports test passed
167        :param name: test name
168        :type name str
169        :param duration: time (in seconds) test took. Pass None if you do not know (we'll try to calculate it)
170        :type duration int
171        :return:
172        """
173        duration_to_report = duration
174        if self.__last_test_start_time and not duration:  # And not provided
175            duration_to_report = int(time.time() - self.__last_test_start_time)
176        self.tc_messages.testFinished(name, duration=int(duration_to_report))
177        self.__last_test_start_time = None
178        self.__last_test_name = None
179
180    def _test_started(self, name, location):
181        """
182        Reports test launched
183        :param name: test name
184        :param location object with "file" (relative to base_dir) and "line" fields.
185        :type name str
186        """
187        self.__last_test_start_time = time.time()
188        self.__last_test_name = name
189        self.tc_messages.testStarted(name, self.__gen_location(location))
190
191    def _feature_or_scenario(self, is_started, name, location):
192        """
193        Reports feature or scenario launched or stopped
194        :param is_started: started or finished?
195        :type is_started bool
196        :param name: scenario or feature name
197        :param location object with "file" (relative to base_dir) and "line" fields.
198        """
199        if is_started:
200            self.tc_messages.testSuiteStarted(name, self.__gen_location(location))
201        else:
202            self.tc_messages.testSuiteFinished(name)
203
204    def _background(self, is_started, location):
205        """
206        Reports background or stopped
207        :param is_started: started or finished?
208        :type is_started bool
209        :param location object with "file" (relative to base_dir) and "line" fields.
210        """
211        self._feature_or_scenario(is_started, "Background", location)
212
213    def _get_number_of_tests(self):
214        """"
215        Gets number of tests using "_get_features_to_run()" to obtain number of features to calculate.
216        Supports backgrounds as well.
217         :return number of steps
218         :rtype int
219        """
220        num_of_steps = 0
221        for feature in self._get_features_to_run():
222            if feature.background:
223                num_of_steps += len(list(feature.background.steps)) * len(list(feature.scenarios))
224            for scenario in feature.scenarios:
225                num_of_steps += len(list(scenario.steps))
226        return num_of_steps
227
228    @abc.abstractmethod
229    def _get_features_to_run(self):
230        """
231        Implement it! Return list of features to run. Each "feature" should have "scenarios".
232         Each "scenario" should have "steps". Each "feature" may have "background" and each "background" should have
233          "steps". Duck typing.
234        :rtype list
235        :returns list of features
236        """
237        return []
238
239    @abc.abstractmethod
240    def _run_tests(self):
241        """
242        Implement it! It should launch tests using your BDD. Use "self._" functions to report results.
243        """
244        pass
245