cucu 1.3.15__py3-none-any.whl → 1.3.20__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.
- cucu/browser/core.py +3 -0
- cucu/browser/selenium.py +7 -0
- cucu/cli/core.py +15 -36
- cucu/db.py +96 -42
- cucu/environment.py +14 -16
- cucu/formatter/rundb.py +4 -6
- cucu/reporter/html.py +267 -452
- 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/steps/browser_steps.py +19 -1
- cucu/steps/filesystem_steps.py +16 -0
- cucu/utils.py +55 -13
- {cucu-1.3.15.dist-info → cucu-1.3.20.dist-info}/METADATA +1 -1
- {cucu-1.3.15.dist-info → cucu-1.3.20.dist-info}/RECORD +18 -18
- {cucu-1.3.15.dist-info → cucu-1.3.20.dist-info}/WHEEL +0 -0
- {cucu-1.3.15.dist-info → cucu-1.3.20.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,250 @@ 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
|
)
|
|
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
|
|
210
|
+
)
|
|
230
211
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
scenario_dict["steps"] = sorted(
|
|
238
|
+
scenario_dict["steps"], key=lambda x: x["seq"]
|
|
239
|
+
)
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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}"
|
|
296
|
-
)
|
|
241
|
+
for step_dict in scenario_dict["steps"]:
|
|
242
|
+
# Handle section headings with different levels (# to ####)
|
|
243
|
+
if step_dict["name"].startswith("#"):
|
|
244
|
+
# Map the count to the appropriate HTML heading (h2-h5)
|
|
245
|
+
# We use h2-h5 instead of h1-h4 so h1 can be reserved for scenario/feature titles
|
|
246
|
+
step_dict["heading_level"] = (
|
|
247
|
+
f"h{step_dict['name'][:4].count('#') + 1}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# process timestamps and time offsets
|
|
251
|
+
if not step_dict["end_at"]:
|
|
252
|
+
continue
|
|
297
253
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
for screenshot_index, screenshot in enumerate(step["screenshots"]):
|
|
302
|
-
filename = os.path.split(screenshot["filepath"])[-1]
|
|
303
|
-
filepath = os.path.join(image_dirpath, filename)
|
|
304
|
-
if not os.path.exists(filepath):
|
|
254
|
+
if not step_dict["start_at"]:
|
|
255
|
+
step_dict["timestamp"] = ""
|
|
256
|
+
step_dict["time_offset"] = ""
|
|
305
257
|
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
258
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
259
|
+
timestamp = step_dict["start_at"]
|
|
260
|
+
step_dict["timestamp"] = timestamp
|
|
261
|
+
|
|
262
|
+
time_offset = datetime.fromtimestamp(
|
|
263
|
+
(
|
|
264
|
+
timestamp - scenario_dict["start_at"]
|
|
265
|
+
).total_seconds(),
|
|
266
|
+
timezone.utc,
|
|
391
267
|
)
|
|
268
|
+
step_dict["time_offset"] = time_offset
|
|
392
269
|
|
|
393
|
-
|
|
394
|
-
logs_dir = os.path.join(scenario_filepath, "logs")
|
|
270
|
+
logs_path = scenario_filepath / "logs"
|
|
395
271
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
log_filepath = log_file.removeprefix(
|
|
400
|
-
f"{scenario_filepath}/"
|
|
401
|
-
)
|
|
272
|
+
log_files = []
|
|
273
|
+
for log_file in logs_path.glob("*.*"):
|
|
274
|
+
log_filepath = log_file.relative_to(scenario_filepath)
|
|
402
275
|
|
|
403
|
-
if
|
|
404
|
-
|
|
276
|
+
if (
|
|
277
|
+
scenario_dict["start_at"]
|
|
278
|
+
and ".console." in log_filepath.name
|
|
279
|
+
):
|
|
280
|
+
log_filepath = Path(f"logs/{log_filepath.name}.html")
|
|
405
281
|
|
|
406
282
|
log_files.append(
|
|
407
283
|
{
|
|
408
284
|
"filepath": log_filepath,
|
|
409
|
-
"name":
|
|
285
|
+
"name": log_file.name,
|
|
410
286
|
}
|
|
411
287
|
)
|
|
412
288
|
|
|
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
|
-
|
|
289
|
+
# generate html version of console log
|
|
427
290
|
for log_file in [
|
|
428
291
|
x for x in log_files if ".console." in x["name"]
|
|
429
292
|
]:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
input_file = Path(log_file_filepath)
|
|
435
|
-
output_file = Path(log_file_filepath + ".html")
|
|
293
|
+
input_file = scenario_filepath / "logs" / log_file["name"]
|
|
294
|
+
output_file = scenario_filepath / log_file["filepath"]
|
|
436
295
|
output_file.write_text(
|
|
437
296
|
parse_log_to_html(
|
|
438
297
|
input_file.read_text(encoding="utf-8")
|
|
@@ -440,126 +299,82 @@ def generate(results, basepath, only_failures=False):
|
|
|
440
299
|
encoding="utf-8",
|
|
441
300
|
)
|
|
442
301
|
|
|
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)
|
|
302
|
+
scenario_dict["logs"] = log_files
|
|
303
|
+
|
|
304
|
+
# render scenario html
|
|
305
|
+
scenario_basepath = feature_path / scenario_dict["folder_name"]
|
|
306
|
+
scenario_basepath.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
rendered_scenario_html = scenario_template.render(
|
|
308
|
+
basepath=results,
|
|
309
|
+
feature=feature_dict,
|
|
310
|
+
path_exists=lambda path: Path(path).exists(),
|
|
311
|
+
scenario=scenario_dict,
|
|
312
|
+
steps=scenario_dict["steps"],
|
|
313
|
+
title=scenario_dict["name"],
|
|
314
|
+
dir_depth="../../",
|
|
315
|
+
)
|
|
316
|
+
scenario_output_filepath = scenario_basepath / "index.html"
|
|
317
|
+
scenario_output_filepath.write_text(rendered_scenario_html)
|
|
318
|
+
|
|
319
|
+
# render feature html
|
|
320
|
+
rendered_feature_html = feature_template.render(
|
|
321
|
+
feature=feature_dict,
|
|
322
|
+
scenarios=feature_dict["scenarios"],
|
|
323
|
+
dir_depth="",
|
|
324
|
+
title=feature_dict["name"],
|
|
325
|
+
)
|
|
326
|
+
feature_output_filepath = basepath / f"{feature_dict['name']}.html"
|
|
327
|
+
feature_output_filepath.write_text(rendered_feature_html)
|
|
520
328
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
329
|
+
feature_dict["total_steps"] = sum(
|
|
330
|
+
[x["total_steps"] for x in feature_dict["scenarios"]]
|
|
331
|
+
)
|
|
332
|
+
feature_dict["duration"] = left_pad_zeroes(
|
|
333
|
+
sum(
|
|
334
|
+
[
|
|
335
|
+
float(x["duration"])
|
|
336
|
+
for x in feature_dict["scenarios"]
|
|
337
|
+
if x["duration"]
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
)
|
|
524
341
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
342
|
+
# query the database for stats
|
|
343
|
+
feature_stats_db = db.db.execute_sql("SELECT * FROM flat_feature")
|
|
344
|
+
keys = tuple([x[0] for x in feature_stats_db.description])
|
|
345
|
+
feature_stats = [
|
|
346
|
+
dict(zip(keys, x)) for x in feature_stats_db.fetchall()
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
grand_totals_db = db.db.execute_sql("SELECT * FROM flat_all")
|
|
350
|
+
keys = tuple([x[0] for x in grand_totals_db.description])
|
|
351
|
+
grand_totals = dict(zip(keys, grand_totals_db.fetchone()))
|
|
352
|
+
|
|
353
|
+
## Generate index.html and flat.html
|
|
354
|
+
|
|
355
|
+
index_template = templates.get_template("index.html")
|
|
356
|
+
rendered_index_html = index_template.render(
|
|
357
|
+
feature_stats=feature_stats,
|
|
358
|
+
grand_totals=grand_totals,
|
|
359
|
+
title="Cucu HTML Test Report",
|
|
360
|
+
basepath=basepath,
|
|
528
361
|
dir_depth="",
|
|
529
|
-
title=feature.get("name", "Cucu results"),
|
|
530
362
|
)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
363
|
+
html_index_path = basepath / "index.html"
|
|
364
|
+
html_index_path.write_text(rendered_index_html)
|
|
365
|
+
|
|
366
|
+
flat_template = templates.get_template("flat.html")
|
|
367
|
+
rendered_flat_html = flat_template.render(
|
|
368
|
+
features=features,
|
|
369
|
+
grand_totals=grand_totals,
|
|
370
|
+
title="Flat HTML Test Report",
|
|
371
|
+
basepath=basepath,
|
|
372
|
+
dir_depth="",
|
|
534
373
|
)
|
|
374
|
+
html_flat_path = basepath / "flat.html"
|
|
375
|
+
html_flat_path.write_text(rendered_flat_html)
|
|
535
376
|
|
|
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"))
|
|
377
|
+
finally:
|
|
378
|
+
db.close_html_report_db()
|
|
564
379
|
|
|
565
|
-
return
|
|
380
|
+
return html_flat_path
|