cucu 1.3.16__py3-none-any.whl → 1.3.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of cucu might be problematic. Click here for more details.
- cucu/cli/core.py +15 -36
- cucu/db.py +96 -42
- cucu/environment.py +13 -15
- cucu/formatter/rundb.py +4 -6
- cucu/reporter/html.py +265 -453
- cucu/reporter/templates/feature.html +2 -2
- cucu/reporter/templates/flat.html +7 -7
- cucu/reporter/templates/index.html +18 -18
- cucu/reporter/templates/scenario.html +34 -34
- cucu/utils.py +55 -13
- {cucu-1.3.16.dist-info → cucu-1.3.17.dist-info}/METADATA +1 -1
- {cucu-1.3.16.dist-info → cucu-1.3.17.dist-info}/RECORD +14 -14
- {cucu-1.3.16.dist-info → cucu-1.3.17.dist-info}/WHEEL +0 -0
- {cucu-1.3.16.dist-info → cucu-1.3.17.dist-info}/entry_points.txt +0 -0
cucu/reporter/html.py
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import glob
|
|
2
|
-
import os
|
|
3
1
|
import shutil
|
|
4
2
|
import sys
|
|
5
3
|
import traceback
|
|
6
|
-
import
|
|
7
|
-
from datetime import datetime
|
|
4
|
+
from datetime import datetime, timezone
|
|
8
5
|
from pathlib import Path
|
|
9
6
|
from xml.sax.saxutils import escape as escape_
|
|
10
7
|
|
|
11
8
|
import jinja2
|
|
9
|
+
from playhouse import shortcuts
|
|
12
10
|
|
|
13
11
|
import cucu.db as db
|
|
14
12
|
from cucu import format_gherkin_table, logger
|
|
@@ -16,8 +14,6 @@ from cucu.ansi_parser import parse_log_to_html
|
|
|
16
14
|
from cucu.config import CONFIG
|
|
17
15
|
from cucu.utils import (
|
|
18
16
|
ellipsize_filename,
|
|
19
|
-
generate_short_id,
|
|
20
|
-
get_step_image_dir,
|
|
21
17
|
)
|
|
22
18
|
|
|
23
19
|
|
|
@@ -52,387 +48,247 @@ def process_tags(element):
|
|
|
52
48
|
element["tags"] = " ".join(prepared_tags)
|
|
53
49
|
|
|
54
50
|
|
|
55
|
-
|
|
51
|
+
def urlencode(string):
|
|
52
|
+
"""
|
|
53
|
+
handles encoding specific characters in the names of features/scenarios
|
|
54
|
+
so they can be used in a URL. NOTICE: we're not handling spaces since
|
|
55
|
+
the browser handles those already.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
return string.replace('"', "%22").replace("'", "%27").replace("#", "%23")
|
|
59
|
+
|
|
60
|
+
|
|
56
61
|
def left_pad_zeroes(elapsed_time):
|
|
62
|
+
"""left pad duration with '0' for better alphabetical sorting in html reports"""
|
|
57
63
|
int_decimal = str(round(elapsed_time, 3)).split(".")
|
|
58
64
|
int_decimal[0] = int_decimal[0].zfill(3)
|
|
59
65
|
padded_duration = ".".join(int_decimal)
|
|
60
66
|
return padded_duration
|
|
61
67
|
|
|
62
68
|
|
|
63
|
-
def
|
|
64
|
-
"""
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
def browser_timestamp_to_datetime(value):
|
|
70
|
+
"""Convert a browser timestamp (in milliseconds since epoch) to a datetime object"""
|
|
71
|
+
try:
|
|
72
|
+
timestamp_sec = int(value) / 1000.0
|
|
73
|
+
return datetime.fromtimestamp(timestamp_sec).strftime(
|
|
74
|
+
"%Y-%m-%d %H:%M:%S,%f"
|
|
75
|
+
)[:-3]
|
|
76
|
+
except (ValueError, TypeError):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def step_text_list_to_html(text):
|
|
81
|
+
"""Convert a list of step text lines to an indented HTML heredoc format"""
|
|
82
|
+
text_indent = " " * 8
|
|
83
|
+
heredoc_quote = '"""'
|
|
84
|
+
return "\n".join(
|
|
85
|
+
[text_indent + heredoc_quote]
|
|
86
|
+
+ [f"{text_indent}{x}" for x in text]
|
|
87
|
+
+ [text_indent + heredoc_quote]
|
|
88
|
+
)
|
|
67
89
|
|
|
68
|
-
features = []
|
|
69
90
|
|
|
70
|
-
|
|
91
|
+
def step_table_to_html(table_data):
|
|
92
|
+
"""Convert a step table data structure to an indented HTML table format"""
|
|
93
|
+
text_indent = " " * 8
|
|
94
|
+
return format_gherkin_table(
|
|
95
|
+
table_data["rows"],
|
|
96
|
+
table_data["headings"],
|
|
97
|
+
text_indent,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def generate(results: Path, basepath: Path):
|
|
102
|
+
## Jinja2 templates setup
|
|
103
|
+
package_loader = jinja2.PackageLoader("cucu.reporter", "templates")
|
|
104
|
+
templates = jinja2.Environment(loader=package_loader) # nosec
|
|
105
|
+
templates.globals.update(
|
|
106
|
+
escape=escape,
|
|
107
|
+
urlencode=urlencode,
|
|
108
|
+
browser_timestamp_to_datetime=browser_timestamp_to_datetime,
|
|
109
|
+
step_text_list_to_html=step_text_list_to_html,
|
|
110
|
+
step_table_to_html=step_table_to_html,
|
|
111
|
+
)
|
|
112
|
+
feature_template = templates.get_template("feature.html")
|
|
113
|
+
scenario_template = templates.get_template("scenario.html")
|
|
114
|
+
|
|
115
|
+
## prepare report directory
|
|
116
|
+
cucu_dir = Path(sys.modules["cucu"].__file__).parent
|
|
117
|
+
external_dir = cucu_dir / "reporter/external"
|
|
118
|
+
shutil.copytree(external_dir, basepath / "external")
|
|
119
|
+
shutil.copyfile(
|
|
120
|
+
cucu_dir / "reporter/favicon.png",
|
|
121
|
+
basepath / "favicon.png",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
CONFIG.snapshot()
|
|
125
|
+
|
|
126
|
+
db_path = results / "run.db"
|
|
71
127
|
try:
|
|
72
128
|
db.init_html_report_db(db_path)
|
|
73
|
-
features = []
|
|
74
129
|
|
|
75
|
-
|
|
130
|
+
feature_count = db.feature.select().count()
|
|
131
|
+
scenario_count = db.scenario.select().count()
|
|
132
|
+
step_count = db.step.select().count()
|
|
76
133
|
logger.info(
|
|
77
|
-
f"Starting to process {
|
|
134
|
+
f"Starting to process {feature_count} features, {scenario_count} scenarios, and {step_count} steps for report"
|
|
78
135
|
)
|
|
79
136
|
|
|
137
|
+
db_features = db.feature.select().order_by(db.feature.start_at)
|
|
138
|
+
|
|
139
|
+
features = []
|
|
80
140
|
for db_feature in db_features:
|
|
141
|
+
if db_feature.status == "untested":
|
|
142
|
+
logger.debug(f"Skipping untested feature: {db_feature.name}")
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
feature_dict = shortcuts.model_to_dict(db_feature, backrefs=True)
|
|
146
|
+
features.append(feature_dict)
|
|
147
|
+
|
|
81
148
|
feature_results_dir = results
|
|
82
149
|
if db_path := db_feature.worker.cucu_run.db_path:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"filename": db_feature.filename,
|
|
88
|
-
"description": db_feature.description,
|
|
89
|
-
"tags": db_feature.tags if db_feature.tags else [],
|
|
90
|
-
"status": db_feature.status,
|
|
91
|
-
"elements": [],
|
|
92
|
-
"results_dir": feature_results_dir,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
db_scenarios = (
|
|
96
|
-
db.scenario.select()
|
|
97
|
-
.where(db.scenario.feature_run_id == db_feature.feature_run_id)
|
|
98
|
-
.order_by(db.scenario.seq)
|
|
99
|
-
)
|
|
150
|
+
logger.debug(
|
|
151
|
+
f"Combining cucu_runs, using db_path from worker: {db_path}"
|
|
152
|
+
)
|
|
153
|
+
feature_results_dir = Path(db_path).parent
|
|
100
154
|
|
|
101
|
-
|
|
155
|
+
feature_dict["results_dir"] = feature_results_dir
|
|
156
|
+
feature_dict["folder_name"] = ellipsize_filename(db_feature.name)
|
|
157
|
+
feature_dict["duration"] = (
|
|
158
|
+
feature_dict["start_at"] - feature_dict["start_at"]
|
|
159
|
+
).total_seconds()
|
|
102
160
|
|
|
103
|
-
|
|
104
|
-
logger.debug(f"Feature {db_feature.name} has no scenarios")
|
|
105
|
-
continue
|
|
161
|
+
process_tags(feature_dict)
|
|
106
162
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if db_scenario.status == "failed":
|
|
117
|
-
feature_has_failures = True
|
|
118
|
-
|
|
119
|
-
db_steps = (
|
|
120
|
-
db.step.select()
|
|
121
|
-
.where(
|
|
122
|
-
db.step.scenario_run_id == db_scenario.scenario_run_id
|
|
123
|
-
)
|
|
124
|
-
.order_by(db.step.seq)
|
|
163
|
+
feature_path = basepath / feature_dict["folder_name"]
|
|
164
|
+
|
|
165
|
+
if feature_dict["status"] not in ["skipped", "untested"]:
|
|
166
|
+
# copy each feature directories contents over to the report directory
|
|
167
|
+
src_feature_filepath = (
|
|
168
|
+
Path(feature_dict["results_dir"])
|
|
169
|
+
/ feature_dict["folder_name"]
|
|
125
170
|
)
|
|
126
171
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"status": db_step.status or "passed",
|
|
133
|
-
"duration": db_step.duration or 0,
|
|
134
|
-
"timestamp": db_step.end_at or "",
|
|
135
|
-
},
|
|
136
|
-
"substep": db_step.is_substep,
|
|
137
|
-
"screenshots": db_step.screenshots,
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if db_step.text:
|
|
141
|
-
step_dict["text"] = db_step.text
|
|
142
|
-
|
|
143
|
-
if db_step.table_data:
|
|
144
|
-
step_dict["table"] = db_step.table_data
|
|
145
|
-
|
|
146
|
-
step_dict["result"]["error_message"] = (
|
|
147
|
-
db_step.error_message.splitlines()
|
|
148
|
-
if db_step.error_message
|
|
149
|
-
else []
|
|
172
|
+
if src_feature_filepath.exists():
|
|
173
|
+
shutil.copytree(
|
|
174
|
+
src_feature_filepath,
|
|
175
|
+
feature_path,
|
|
176
|
+
dirs_exist_ok=True,
|
|
150
177
|
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
step_dict["result"]["browser_logs"] = (
|
|
155
|
-
db_step.browser_logs.splitlines()
|
|
156
|
-
)
|
|
157
|
-
step_dict["result"]["debug_output"] = (
|
|
158
|
-
db_step.debug_output.splitlines()
|
|
178
|
+
else:
|
|
179
|
+
logger.warning(
|
|
180
|
+
f"Feature directory not found, skipping copy: {src_feature_filepath}"
|
|
159
181
|
)
|
|
160
182
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
183
|
+
db_scenarios = db_feature.scenarios.select().order_by(
|
|
184
|
+
db.scenario.seq
|
|
185
|
+
)
|
|
164
186
|
|
|
165
|
-
if
|
|
166
|
-
|
|
167
|
-
elif only_failures and not feature_has_failures:
|
|
187
|
+
if len(db_scenarios) == 0:
|
|
188
|
+
logger.debug(f"Feature {db_feature.name} has no scenarios")
|
|
168
189
|
continue
|
|
169
190
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
cucu_dir = os.path.dirname(sys.modules["cucu"].__file__)
|
|
176
|
-
external_dir = os.path.join(cucu_dir, "reporter", "external")
|
|
177
|
-
shutil.copytree(external_dir, os.path.join(basepath, "external"))
|
|
178
|
-
shutil.copyfile(
|
|
179
|
-
os.path.join(cucu_dir, "reporter", "favicon.png"),
|
|
180
|
-
os.path.join(basepath, "favicon.png"),
|
|
181
|
-
)
|
|
191
|
+
for scenario_dict in sorted(
|
|
192
|
+
feature_dict["scenarios"], key=lambda x: x["seq"]
|
|
193
|
+
):
|
|
194
|
+
CONFIG.restore()
|
|
182
195
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
# * features & scenarios with `duration` attribute computed by adding all
|
|
186
|
-
# step durations.
|
|
187
|
-
# * add `image` attribute to a step if it has an underlying .png image.
|
|
188
|
-
#
|
|
189
|
-
CONFIG.snapshot()
|
|
190
|
-
reported_features = []
|
|
191
|
-
for feature in features:
|
|
192
|
-
feature["folder_name"] = ellipsize_filename(feature["name"])
|
|
193
|
-
scenarios = []
|
|
194
|
-
|
|
195
|
-
if feature["status"] != "untested" and "elements" in feature:
|
|
196
|
-
scenarios = feature["elements"]
|
|
197
|
-
|
|
198
|
-
if only_failures and feature["status"] != "failed":
|
|
199
|
-
continue
|
|
200
|
-
|
|
201
|
-
feature_duration = 0
|
|
202
|
-
total_scenarios = 0
|
|
203
|
-
total_scenarios_passed = 0
|
|
204
|
-
total_scenarios_failed = 0
|
|
205
|
-
total_scenarios_skipped = 0
|
|
206
|
-
total_scenarios_errored = 0
|
|
207
|
-
feature_started_at = None
|
|
208
|
-
|
|
209
|
-
reported_features.append(feature)
|
|
210
|
-
process_tags(feature)
|
|
211
|
-
|
|
212
|
-
if feature["status"] not in ["skipped", "untested"]:
|
|
213
|
-
# copy each feature directories contents over to the report directory
|
|
214
|
-
src_feature_filepath = os.path.join(
|
|
215
|
-
feature["results_dir"], feature["folder_name"]
|
|
216
|
-
)
|
|
217
|
-
dst_feature_filepath = os.path.join(
|
|
218
|
-
basepath, feature["folder_name"]
|
|
219
|
-
)
|
|
220
|
-
if os.path.exists(src_feature_filepath):
|
|
221
|
-
shutil.copytree(
|
|
222
|
-
src_feature_filepath,
|
|
223
|
-
dst_feature_filepath,
|
|
224
|
-
dirs_exist_ok=True,
|
|
196
|
+
scenario_dict["folder_name"] = ellipsize_filename(
|
|
197
|
+
scenario_dict["name"]
|
|
225
198
|
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
199
|
+
scenario_filepath = feature_path / scenario_dict["folder_name"]
|
|
200
|
+
scenario_configpath = (
|
|
201
|
+
scenario_filepath / "logs/cucu.config.yaml.txt"
|
|
229
202
|
)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
feature["folder_name"],
|
|
238
|
-
scenario["folder_name"],
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
scenario_configpath = os.path.join(
|
|
242
|
-
scenario_filepath, "logs", "cucu.config.yaml.txt"
|
|
243
|
-
)
|
|
244
|
-
if os.path.exists(scenario_configpath):
|
|
245
|
-
try:
|
|
246
|
-
CONFIG.load(scenario_configpath)
|
|
247
|
-
except Exception as e:
|
|
248
|
-
logger.warning(
|
|
249
|
-
f"Could not reload config: {scenario_configpath}: {e}"
|
|
250
|
-
)
|
|
251
|
-
else:
|
|
252
|
-
logger.info(f"No config to reload: {scenario_configpath}")
|
|
253
|
-
|
|
254
|
-
process_tags(scenario)
|
|
255
|
-
|
|
256
|
-
sub_headers = []
|
|
257
|
-
for handler in CONFIG[
|
|
258
|
-
"__CUCU_HTML_REPORT_SCENARIO_SUBHEADER_HANDLER"
|
|
259
|
-
]:
|
|
260
|
-
try:
|
|
261
|
-
sub_header = handler(scenario, feature)
|
|
262
|
-
if sub_header:
|
|
263
|
-
sub_headers.append(sub_header)
|
|
264
|
-
except Exception:
|
|
265
|
-
logger.warning(
|
|
266
|
-
f'Exception while trying to run sub_headers hook for scenario: "{scenario["name"]}"\n{traceback.format_exc()}'
|
|
267
|
-
)
|
|
268
|
-
scenario["sub_headers"] = "<br/>".join(sub_headers)
|
|
269
|
-
|
|
270
|
-
scenario_duration = 0
|
|
271
|
-
total_scenarios += 1
|
|
272
|
-
total_steps = 0
|
|
273
|
-
|
|
274
|
-
if "status" not in scenario:
|
|
275
|
-
total_scenarios_skipped += 1
|
|
276
|
-
elif scenario["status"] == "passed":
|
|
277
|
-
total_scenarios_passed += 1
|
|
278
|
-
elif scenario["status"] == "failed":
|
|
279
|
-
total_scenarios_failed += 1
|
|
280
|
-
elif scenario["status"] == "skipped":
|
|
281
|
-
total_scenarios_skipped += 1
|
|
282
|
-
elif scenario["status"] == "errored":
|
|
283
|
-
total_scenarios_errored += 1
|
|
284
|
-
|
|
285
|
-
step_index = 0
|
|
286
|
-
scenario_started_at = None
|
|
287
|
-
for step in scenario["steps"]:
|
|
288
|
-
total_steps += 1
|
|
289
|
-
|
|
290
|
-
# Handle section headings with different levels (# to ####)
|
|
291
|
-
if step["name"].startswith("#"):
|
|
292
|
-
# Map the count to the appropriate HTML heading (h2-h5)
|
|
293
|
-
# We use h2-h5 instead of h1-h4 so h1 can be reserved for scenario/feature titles
|
|
294
|
-
step["heading_level"] = (
|
|
295
|
-
f"h{step['name'][:4].count('#') + 1}"
|
|
203
|
+
scenario_dict["total_steps"] = len(scenario_dict["steps"])
|
|
204
|
+
if scenario_dict["start_at"]:
|
|
205
|
+
offset_seconds = (
|
|
206
|
+
scenario_dict["start_at"] - feature_dict["start_at"]
|
|
207
|
+
).total_seconds()
|
|
208
|
+
scenario_dict["time_offset"] = datetime.fromtimestamp(
|
|
209
|
+
offset_seconds, timezone.utc
|
|
296
210
|
)
|
|
297
211
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
212
|
+
if not scenario_configpath.exists():
|
|
213
|
+
logger.info(f"No config to reload: {scenario_configpath}")
|
|
214
|
+
else:
|
|
215
|
+
try:
|
|
216
|
+
CONFIG.load(scenario_configpath)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.warning(
|
|
219
|
+
f"Could not reload config: {scenario_configpath}: {e}"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
process_tags(scenario_dict)
|
|
223
|
+
|
|
224
|
+
sub_headers = []
|
|
225
|
+
for handler in CONFIG[
|
|
226
|
+
"__CUCU_HTML_REPORT_SCENARIO_SUBHEADER_HANDLER"
|
|
227
|
+
]:
|
|
228
|
+
try:
|
|
229
|
+
sub_header = handler(scenario_dict, feature_dict)
|
|
230
|
+
if sub_header:
|
|
231
|
+
sub_headers.append(sub_header)
|
|
232
|
+
except Exception:
|
|
233
|
+
logger.warning(
|
|
234
|
+
f'Exception while trying to run sub_headers hook for scenario: "{scenario_dict["name"]}"\n{traceback.format_exc()}'
|
|
235
|
+
)
|
|
236
|
+
scenario_dict["sub_headers"] = "<br/>".join(sub_headers)
|
|
237
|
+
|
|
238
|
+
for step_dict in scenario_dict["steps"]:
|
|
239
|
+
# Handle section headings with different levels (# to ####)
|
|
240
|
+
if step_dict["name"].startswith("#"):
|
|
241
|
+
# Map the count to the appropriate HTML heading (h2-h5)
|
|
242
|
+
# We use h2-h5 instead of h1-h4 so h1 can be reserved for scenario/feature titles
|
|
243
|
+
step_dict["heading_level"] = (
|
|
244
|
+
f"h{step_dict['name'][:4].count('#') + 1}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# process timestamps and time offsets
|
|
248
|
+
if not step_dict["end_at"]:
|
|
305
249
|
continue
|
|
306
|
-
label = screenshot.get("label", step["name"])
|
|
307
|
-
highlight = None
|
|
308
|
-
if (
|
|
309
|
-
screenshot["location"]
|
|
310
|
-
and not CONFIG["CUCU_SKIP_HIGHLIGHT_BORDER"]
|
|
311
|
-
):
|
|
312
|
-
window_height = screenshot["size"]["height"]
|
|
313
|
-
window_width = screenshot["size"]["width"]
|
|
314
|
-
try:
|
|
315
|
-
highlight = {
|
|
316
|
-
"height_ratio": screenshot["location"][
|
|
317
|
-
"height"
|
|
318
|
-
]
|
|
319
|
-
/ window_height,
|
|
320
|
-
"width_ratio": screenshot["location"]["width"]
|
|
321
|
-
/ window_width,
|
|
322
|
-
"top_ratio": screenshot["location"]["y"]
|
|
323
|
-
/ window_height,
|
|
324
|
-
"left_ratio": screenshot["location"]["x"]
|
|
325
|
-
/ window_width,
|
|
326
|
-
}
|
|
327
|
-
except TypeError:
|
|
328
|
-
# If any of the necessary properties is absent,
|
|
329
|
-
# then oh well, no highlight this time.
|
|
330
|
-
pass
|
|
331
|
-
screenshot_id = f"step-img-{screenshot.get("step_run_id", generate_short_id())}-{screenshot_index:0>4}"
|
|
332
|
-
images.append(
|
|
333
|
-
{
|
|
334
|
-
"src": urllib.parse.quote(
|
|
335
|
-
os.path.join(image_dir, filename)
|
|
336
|
-
),
|
|
337
|
-
"index": screenshot_index,
|
|
338
|
-
"label": label,
|
|
339
|
-
"id": screenshot_id,
|
|
340
|
-
"highlight": highlight,
|
|
341
|
-
}
|
|
342
|
-
)
|
|
343
|
-
step["images"] = sorted(images, key=lambda x: x["index"])
|
|
344
|
-
|
|
345
|
-
if "result" in step:
|
|
346
|
-
if step["result"]["status"] in ["failed", "passed"]:
|
|
347
|
-
if step["result"]["timestamp"]:
|
|
348
|
-
timestamp = datetime.fromisoformat(
|
|
349
|
-
step["result"]["timestamp"]
|
|
350
|
-
)
|
|
351
|
-
step["result"]["timestamp"] = timestamp
|
|
352
|
-
|
|
353
|
-
if scenario_started_at is None:
|
|
354
|
-
scenario_started_at = timestamp
|
|
355
|
-
scenario["started_at"] = timestamp
|
|
356
|
-
time_offset = datetime.utcfromtimestamp(
|
|
357
|
-
(
|
|
358
|
-
timestamp - scenario_started_at
|
|
359
|
-
).total_seconds()
|
|
360
|
-
)
|
|
361
|
-
step["result"]["time_offset"] = time_offset
|
|
362
|
-
else:
|
|
363
|
-
step["result"]["timestamp"] = ""
|
|
364
|
-
step["result"]["time_offset"] = ""
|
|
365
|
-
|
|
366
|
-
scenario_duration += step["result"]["duration"]
|
|
367
|
-
|
|
368
|
-
if "error_message" in step["result"] and step["result"][
|
|
369
|
-
"error_message"
|
|
370
|
-
] == [None]:
|
|
371
|
-
step["result"]["error_message"] = [""]
|
|
372
|
-
|
|
373
|
-
if "text" in step and not isinstance(step["text"], list):
|
|
374
|
-
step["text"] = [step["text"]]
|
|
375
|
-
|
|
376
|
-
# prepare by joining into one big chunk here since we can't do it in the Jinja template
|
|
377
|
-
if "text" in step:
|
|
378
|
-
text_indent = " "
|
|
379
|
-
step["text"] = "\n".join(
|
|
380
|
-
[text_indent + '"""']
|
|
381
|
-
+ [f"{text_indent}{x}" for x in step["text"]]
|
|
382
|
-
+ [text_indent + '"""']
|
|
383
|
-
)
|
|
384
250
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
step["table"]["headings"],
|
|
390
|
-
" ",
|
|
391
|
-
)
|
|
251
|
+
if not step_dict["start_at"]:
|
|
252
|
+
step_dict["timestamp"] = ""
|
|
253
|
+
step_dict["time_offset"] = ""
|
|
254
|
+
continue
|
|
392
255
|
|
|
393
|
-
|
|
394
|
-
|
|
256
|
+
timestamp = step_dict["start_at"]
|
|
257
|
+
step_dict["timestamp"] = timestamp
|
|
395
258
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
259
|
+
time_offset = datetime.fromtimestamp(
|
|
260
|
+
(
|
|
261
|
+
timestamp - scenario_dict["start_at"]
|
|
262
|
+
).total_seconds(),
|
|
263
|
+
timezone.utc,
|
|
401
264
|
)
|
|
265
|
+
step_dict["time_offset"] = time_offset
|
|
266
|
+
|
|
267
|
+
logs_path = scenario_filepath / "logs"
|
|
402
268
|
|
|
403
|
-
|
|
404
|
-
|
|
269
|
+
log_files = []
|
|
270
|
+
for log_file in logs_path.glob("*.*"):
|
|
271
|
+
log_filepath = log_file.relative_to(scenario_filepath)
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
scenario_dict["start_at"]
|
|
275
|
+
and ".console." in log_filepath.name
|
|
276
|
+
):
|
|
277
|
+
log_filepath = Path(f"logs/{log_filepath.name}.html")
|
|
405
278
|
|
|
406
279
|
log_files.append(
|
|
407
280
|
{
|
|
408
281
|
"filepath": log_filepath,
|
|
409
|
-
"name":
|
|
282
|
+
"name": log_file.name,
|
|
410
283
|
}
|
|
411
284
|
)
|
|
412
285
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
scenario["total_steps"] = total_steps
|
|
416
|
-
if scenario_started_at is None:
|
|
417
|
-
scenario["started_at"] = ""
|
|
418
|
-
else:
|
|
419
|
-
if feature_started_at is None:
|
|
420
|
-
feature_started_at = scenario_started_at
|
|
421
|
-
feature["started_at"] = feature_started_at
|
|
422
|
-
|
|
423
|
-
scenario["time_offset"] = datetime.utcfromtimestamp(
|
|
424
|
-
(scenario_started_at - feature_started_at).total_seconds()
|
|
425
|
-
)
|
|
426
|
-
|
|
286
|
+
# generate html version of console log
|
|
427
287
|
for log_file in [
|
|
428
288
|
x for x in log_files if ".console." in x["name"]
|
|
429
289
|
]:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
input_file = Path(log_file_filepath)
|
|
435
|
-
output_file = Path(log_file_filepath + ".html")
|
|
290
|
+
input_file = scenario_filepath / "logs" / log_file["name"]
|
|
291
|
+
output_file = scenario_filepath / log_file["filepath"]
|
|
436
292
|
output_file.write_text(
|
|
437
293
|
parse_log_to_html(
|
|
438
294
|
input_file.read_text(encoding="utf-8")
|
|
@@ -440,126 +296,82 @@ def generate(results, basepath, only_failures=False):
|
|
|
440
296
|
encoding="utf-8",
|
|
441
297
|
)
|
|
442
298
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
grand_totals = {"total_features": len(reported_features)}
|
|
470
|
-
for k in keys:
|
|
471
|
-
grand_totals[k] = sum([float(x[k]) for x in reported_features])
|
|
472
|
-
|
|
473
|
-
package_loader = jinja2.PackageLoader("cucu.reporter", "templates")
|
|
474
|
-
templates = jinja2.Environment(loader=package_loader) # nosec
|
|
475
|
-
|
|
476
|
-
def urlencode(string):
|
|
477
|
-
"""
|
|
478
|
-
handles encoding specific characters in the names of features/scenarios
|
|
479
|
-
so they can be used in a URL. NOTICE: we're not handling spaces since
|
|
480
|
-
the browser handles those already.
|
|
481
|
-
|
|
482
|
-
"""
|
|
483
|
-
return (
|
|
484
|
-
string.replace('"', "%22").replace("'", "%27").replace("#", "%23")
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
templates.globals.update(escape=escape, urlencode=urlencode)
|
|
488
|
-
|
|
489
|
-
index_template = templates.get_template("index.html")
|
|
490
|
-
rendered_index_html = index_template.render(
|
|
491
|
-
features=reported_features,
|
|
492
|
-
grand_totals=grand_totals,
|
|
493
|
-
title="Cucu HTML Test Report",
|
|
494
|
-
basepath=basepath,
|
|
495
|
-
dir_depth="",
|
|
496
|
-
)
|
|
497
|
-
|
|
498
|
-
index_output_filepath = os.path.join(basepath, "index.html")
|
|
499
|
-
with open(index_output_filepath, "wb") as output:
|
|
500
|
-
output.write(rendered_index_html.encode("utf8"))
|
|
501
|
-
|
|
502
|
-
flat_template = templates.get_template("flat.html")
|
|
503
|
-
rendered_flat_html = flat_template.render(
|
|
504
|
-
features=reported_features,
|
|
505
|
-
grand_totals=grand_totals,
|
|
506
|
-
title="Flat HTML Test Report",
|
|
507
|
-
basepath=basepath,
|
|
508
|
-
dir_depth="",
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
flat_output_filepath = os.path.join(basepath, "flat.html")
|
|
512
|
-
with open(flat_output_filepath, "wb") as output:
|
|
513
|
-
output.write(rendered_flat_html.encode("utf8"))
|
|
514
|
-
|
|
515
|
-
feature_template = templates.get_template("feature.html")
|
|
516
|
-
|
|
517
|
-
for feature in reported_features:
|
|
518
|
-
feature_basepath = os.path.join(basepath, feature["folder_name"])
|
|
519
|
-
os.makedirs(feature_basepath, exist_ok=True)
|
|
299
|
+
scenario_dict["logs"] = log_files
|
|
300
|
+
|
|
301
|
+
# render scenario html
|
|
302
|
+
scenario_basepath = feature_path / scenario_dict["folder_name"]
|
|
303
|
+
scenario_basepath.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
rendered_scenario_html = scenario_template.render(
|
|
305
|
+
basepath=results,
|
|
306
|
+
feature=feature_dict,
|
|
307
|
+
path_exists=lambda path: Path(path).exists(),
|
|
308
|
+
scenario=scenario_dict,
|
|
309
|
+
steps=scenario_dict["steps"],
|
|
310
|
+
title=scenario_dict["name"],
|
|
311
|
+
dir_depth="../../",
|
|
312
|
+
)
|
|
313
|
+
scenario_output_filepath = scenario_basepath / "index.html"
|
|
314
|
+
scenario_output_filepath.write_text(rendered_scenario_html)
|
|
315
|
+
|
|
316
|
+
# render feature html
|
|
317
|
+
rendered_feature_html = feature_template.render(
|
|
318
|
+
feature=feature_dict,
|
|
319
|
+
scenarios=feature_dict["scenarios"],
|
|
320
|
+
dir_depth="",
|
|
321
|
+
title=feature_dict["name"],
|
|
322
|
+
)
|
|
323
|
+
feature_output_filepath = basepath / f"{feature_dict['name']}.html"
|
|
324
|
+
feature_output_filepath.write_text(rendered_feature_html)
|
|
520
325
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
326
|
+
feature_dict["total_steps"] = sum(
|
|
327
|
+
[x["total_steps"] for x in feature_dict["scenarios"]]
|
|
328
|
+
)
|
|
329
|
+
feature_dict["duration"] = left_pad_zeroes(
|
|
330
|
+
sum(
|
|
331
|
+
[
|
|
332
|
+
float(x["duration"])
|
|
333
|
+
for x in feature_dict["scenarios"]
|
|
334
|
+
if x["duration"]
|
|
335
|
+
]
|
|
336
|
+
)
|
|
337
|
+
)
|
|
524
338
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
339
|
+
# query the database for stats
|
|
340
|
+
feature_stats_db = db.db.execute_sql("SELECT * FROM flat_feature")
|
|
341
|
+
keys = tuple([x[0] for x in feature_stats_db.description])
|
|
342
|
+
feature_stats = [
|
|
343
|
+
dict(zip(keys, x)) for x in feature_stats_db.fetchall()
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
grand_totals_db = db.db.execute_sql("SELECT * FROM flat_all")
|
|
347
|
+
keys = tuple([x[0] for x in grand_totals_db.description])
|
|
348
|
+
grand_totals = dict(zip(keys, grand_totals_db.fetchone()))
|
|
349
|
+
|
|
350
|
+
## Generate index.html and flat.html
|
|
351
|
+
|
|
352
|
+
index_template = templates.get_template("index.html")
|
|
353
|
+
rendered_index_html = index_template.render(
|
|
354
|
+
feature_stats=feature_stats,
|
|
355
|
+
grand_totals=grand_totals,
|
|
356
|
+
title="Cucu HTML Test Report",
|
|
357
|
+
basepath=basepath,
|
|
528
358
|
dir_depth="",
|
|
529
|
-
title=feature.get("name", "Cucu results"),
|
|
530
359
|
)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
360
|
+
html_index_path = basepath / "index.html"
|
|
361
|
+
html_index_path.write_text(rendered_index_html)
|
|
362
|
+
|
|
363
|
+
flat_template = templates.get_template("flat.html")
|
|
364
|
+
rendered_flat_html = flat_template.render(
|
|
365
|
+
features=features,
|
|
366
|
+
grand_totals=grand_totals,
|
|
367
|
+
title="Flat HTML Test Report",
|
|
368
|
+
basepath=basepath,
|
|
369
|
+
dir_depth="",
|
|
534
370
|
)
|
|
371
|
+
html_flat_path = basepath / "flat.html"
|
|
372
|
+
html_flat_path.write_text(rendered_flat_html)
|
|
535
373
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
scenario_template = templates.get_template("scenario.html")
|
|
540
|
-
|
|
541
|
-
for scenario in scenarios:
|
|
542
|
-
steps = scenario["steps"]
|
|
543
|
-
scenario_basepath = os.path.join(
|
|
544
|
-
feature_basepath, scenario["folder_name"]
|
|
545
|
-
)
|
|
546
|
-
os.makedirs(scenario_basepath, exist_ok=True)
|
|
547
|
-
|
|
548
|
-
scenario_output_filepath = os.path.join(
|
|
549
|
-
scenario_basepath, "index.html"
|
|
550
|
-
)
|
|
551
|
-
|
|
552
|
-
rendered_scenario_html = scenario_template.render(
|
|
553
|
-
basepath=results,
|
|
554
|
-
feature=feature,
|
|
555
|
-
path_exists=os.path.exists,
|
|
556
|
-
scenario=scenario,
|
|
557
|
-
steps=steps,
|
|
558
|
-
title=scenario.get("name", "Cucu results"),
|
|
559
|
-
dir_depth="../../",
|
|
560
|
-
)
|
|
561
|
-
|
|
562
|
-
with open(scenario_output_filepath, "wb") as output:
|
|
563
|
-
output.write(rendered_scenario_html.encode("utf8"))
|
|
374
|
+
finally:
|
|
375
|
+
db.close_html_report_db()
|
|
564
376
|
|
|
565
|
-
return
|
|
377
|
+
return html_flat_path
|