# -*- coding: utf-8 -*- from __future__ import absolute_import import os import base64 import copy from pathlib import Path import six from behave.model_core import Status from behave.formatter.base import Formatter from helpers.reporting.screenshot_manager import ScreenshotManager try: import json except ImportError: import simplejson as json # ----------------------------------------------------------------------------- # CLASS: JSONFormatter # ----------------------------------------------------------------------------- class CucumberJSONFormatter(Formatter): default_output_dir = Path('cucumber_results/split') name = 'json' description = 'JSON dump of test run' dumps_kwargs = {} json_number_types = six.integer_types + (float,) json_scalar_types = json_number_types + (six.text_type, bool, type(None)) def __init__(self, stream_opener, config): print("CucumberJSONFormatter") super(CucumberJSONFormatter, self).__init__(stream_opener, config) # We do NOT keep a single shared stream opened for the whole run. # We open a NEW stream per feature inside feature() and close in eof(). self.stream = None self.feature_count = 0 self.current_feature = None self.current_feature_data = None self._step_index = 0 self.current_background = None self.current_background_data = None def reset(self): self.current_feature = None self.current_feature_data = None self._step_index = 0 self.current_background = None @staticmethod def _to_cucumber_status(status) -> str: """ Map Behave status to Cucumber JSON status supported by net.masterthought cucumber-reports plugin. """ # status can be a behave.model_core.Status or a string name = getattr(status, "name", str(status)).lower() mapping = { "passed": "passed", "failed": "failed", "skipped": "skipped", "untested": "skipped", "undefined": "undefined", "pending": "pending", # IMPORTANT: Jenkins plugin doesn't support "error" "error": "failed", } return mapping.get(name, "failed") # -- FORMATTER API: def uri(self, uri): pass def feature(self, feature): self.reset() self.current_feature = feature # Open a file per feature and start the JSON array immediately. output_dir = Path( os.getenv("CUCUMBER_OUTPUT_DIR", self.default_output_dir) ) output_dir.mkdir(parents=True, exist_ok=True) feature_name = Path(feature.location.filename).stem out_file = output_dir / f'cucumber_{feature_name}.json' # Open stream for this feature self.stream = open(out_file, 'w', encoding='utf-8') self.write_json_header() self.current_feature_data = { 'id': self.generate_id(feature), 'uri': feature.location.filename, 'line': feature.location.line, 'description': '', 'keyword': feature.keyword, 'name': feature.name, 'tags': self.write_tags(feature.tags), 'status': self._to_cucumber_status(feature.status), } element = self.current_feature_data if feature.description: element['description'] = ( self.format_description(feature.description)) def background(self, background): element = { 'type': 'background', 'keyword': background.keyword, 'name': background.name, 'location': six.text_type(background.location), 'steps': [], } self._step_index = 0 self.current_background = element def scenario(self, scenario): if self.current_background is not None: self.add_feature_element(copy.deepcopy(self.current_background)) element = self.add_feature_element( { 'type': 'scenario', 'id': self.generate_id(self.current_feature, scenario), 'line': scenario.location.line, 'description': '', 'keyword': scenario.keyword, 'name': scenario.name, 'tags': self.write_tags(scenario.tags), 'location': six.text_type(scenario.location), 'steps': [], 'status': self._to_cucumber_status(scenario.status) if scenario.status else 'skipped', } ) if scenario.description: element['description'] = ( self.format_description(scenario.description)) self._step_index = 0 @classmethod def make_table(cls, table): table_data = {'headings': table.headings, 'rows': [list(row) for row in table.rows]} return table_data def step(self, step): s = { 'keyword': step.keyword, 'step_type': step.step_type, 'name': step.name, 'line': step.location.line, 'result': {'status': 'skipped', 'duration': 0}, } if step.text: s['doc_string'] = {'value': step.text, 'line': step.text.line} if step.table: s['rows'] = [{'cells': [heading for heading in step.table.headings]}] s['rows'] += [{'cells': [cell for cell in row.cells]} for row in step.table] if self.current_feature.background is not None: element = self.current_feature_data['elements'][-2] if (len(element['steps']) >= len(self.current_feature.background.steps)): element = self.current_feature_element else: element = self.current_feature_element element['steps'].append(s) def match(self, match): if match.location: match_data = {'location': six.text_type(match.location) or ''} self.current_step['match'] = match_data def result(self, result): self.current_step['result'] = { 'status': self._to_cucumber_status(result.status), 'duration': int(round(result.duration * 1000.0 * 1000.0 * 1000.0)), } if result.error_message and result.status in (Status.failed, Status.error): error_message = result.error_message result_element = self.current_step['result'] result_element['error_message'] = error_message step_name = self.current_step['name'] sm = ScreenshotManager() screenshot_path = sm.get_screenshot_path(step_name) if screenshot_path: p = Path(screenshot_path) self.embedding(mime_type="image/png", data=p.read_bytes(), filename=step_name) self._step_index += 1 def embedding(self, mime_type, data, filename): step = self.current_step if 'embeddings' not in step: step['embeddings'] = [] step['embeddings'].append( { 'mime_type': mime_type, 'data': base64.b64encode(data).decode("utf-8").replace("\n", ""), 'filename': filename, } ) def eof(self): """ End of feature """ if not self.current_feature_data: return self.update_status_data() # Write the SINGLE feature into the file, # then close JSON array and close stream. self.write_json_feature(self.current_feature_data) self.current_feature_data = None self.feature_count += 1 # Close the JSON array + file for this feature self.write_json_footer() self.stream.flush() self.stream.close() self.stream = None def close(self): # Do nothing here. We already closed each feature file in eof(). return # -- JSON-DATA COLLECTION: def add_feature_element(self, element): assert self.current_feature_data is not None if 'elements' not in self.current_feature_data: self.current_feature_data['elements'] = [] self.current_feature_data['elements'].append(element) return element @property def current_feature_element(self): assert self.current_feature_data is not None return self.current_feature_data['elements'][-1] @property def current_step(self): step_index = self._step_index if self.current_feature.background is not None: element = self.current_feature_data['elements'][-2] if step_index >= len(self.current_feature.background.steps): step_index -= len(self.current_feature.background.steps) element = self.current_feature_element else: element = self.current_feature_element return element['steps'][step_index] def update_status_data(self): assert self.current_feature assert self.current_feature_data self.current_feature_data['status'] = ( self._to_cucumber_status(self.current_feature.status)) def write_tags(self, tags): return [{'name': tag, 'line': tag.line if hasattr(tag, 'line') else 1} for tag in tags] def generate_id(self, feature, scenario=None): def convert(name): return name.lower().replace(' ', '-') _id = convert(feature.name) if scenario is not None: _id += ';' + convert(scenario.name) return _id def format_description(self, lines): description = '\n'.join(lines) description = '
%s
' % description return description # -- JSON-WRITER: def write_json_header(self): # For per-feature file: always start a new array self.stream.write("[\n") def write_json_footer(self): # For per-feature file: always close the array self.stream.write("\n]\n") def write_json_feature(self, feature_data): # For per-feature file: write the single feature object self.stream.write(json.dumps(feature_data, **self.dumps_kwargs)) self.stream.flush() def write_json_feature_separator(self): # Not used in per-feature mode, but keep for compatibility self.stream.write(",\n\n") # ----------------------------------------------------------------------------- # CLASS: PrettyJSONFormatter # ----------------------------------------------------------------------------- class PrettyCucumberJSONFormatter(CucumberJSONFormatter): """ Provides readable/comparable textual JSON output. """ print("PrettyCucumberJSONFormatter") name = 'json.pretty' description = 'JSON dump of test run (human readable)' dumps_kwargs = {'indent': 2, 'sort_keys': True}