diff --git a/cmd/package b/cmd/package index d134896d3..5f24273ac 100755 --- a/cmd/package +++ b/cmd/package @@ -32,6 +32,9 @@ cp cmd/install $MAKE_SRC_DIR/DEBIAN/postinst mkdir -p $MAKE_SRC_DIR/usr/local/testrun/cmd cp cmd/{prepare,build} $MAKE_SRC_DIR/usr/local/testrun/cmd +# Copy resources +cp -r resources $MAKE_SRC_DIR/usr/local/testrun/ + # Create local folder mkdir -p $MAKE_SRC_DIR/usr/local/testrun/local cp local/system.json.example $MAKE_SRC_DIR/usr/local/testrun/local/system.json.example diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index da26e0700..02c9d65a9 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -1,185 +1,559 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Store previous test run information.""" - -import os -from datetime import datetime -from weasyprint import HTML -from io import BytesIO - -DATE_TIME_FORMAT = '%Y-%m-%d %H:%M:%S' -DEVICES_DIR = '/usr/local/testrun/local/devices' - -class TestReport(): - """Represents a previous Testrun report.""" - - def __init__(self, - status='Non-Compliant', - started=None, - finished=None, - total_tests=0 - ): - self._device = {} - self._status: str = status - self._started = started - self._finished = finished - self._total_tests = total_tests - self._results = [] - self._report = '' - - def get_status(self): - return self._status - - def get_started(self): - return self._started - - def get_finished(self): - return self._finished - - def get_duration_seconds(self): - diff = self._finished - self._started - return diff.total_seconds() - - def get_duration(self): - return str(datetime.timedelta(seconds=self.get_duration_seconds())) - - def add_test(self, test): - self._results.append(test) - - def get_report_url(self): - return self._report - - def to_json(self): - report_json = {} - report_json['device'] = self._device - report_json['status'] = self._status - report_json['started'] = self._started.strftime(DATE_TIME_FORMAT) - report_json['finished'] = self._finished.strftime(DATE_TIME_FORMAT) - report_json['tests'] = {'total': self._total_tests, - 'results': self._results} - report_json['report'] = self._report - return report_json - - def from_json(self, json_file): - - self._device['mac_addr'] = json_file['device']['mac_addr'] - self._device['manufacturer'] = json_file['device']['manufacturer'] - self._device['model'] = json_file['device']['model'] - - if 'firmware' in json_file['device']: - self._device['firmware'] = json_file['device']['firmware'] - - self._status = json_file['status'] - self._started = datetime.strptime(json_file['started'], DATE_TIME_FORMAT) - self._finished = datetime.strptime(json_file['finished'], DATE_TIME_FORMAT) - self._total_tests = json_file['tests']['total'] - - if 'report' in json_file: - self._report = json_file['report'] - - # Loop through test results - for test_result in json_file['tests']['results']: - self.add_test(test_result) - - return self - - # Create a pdf file in memory and return the bytes - def to_pdf(self): - # Resolve the data as html first - report_html = self.to_html() - - # Convert HTML to PDF in memory using weasyprint - pdf_bytes = BytesIO() - HTML(string=report_html).write_pdf(pdf_bytes) - return pdf_bytes - - def to_html(self): - json_data = self.to_json() - return f''' - - - {self.generate_header()} - -

Test Results Summary

- -
-

Device Information

-

MAC Address: {json_data["device"]["mac_addr"]}

-

Manufacturer: {json_data["device"]["manufacturer"] or "Unknown"}

-

Model: {json_data["device"]["model"]}

-
- -

Test Results

- {self.generate_test_sections(json_data)} - - - ''' - - def generate_test_sections(self,json_data): - results = json_data['tests']['results'] - sections = '' - for result in results: - sections += self.generate_test_section(result) - return sections - - def generate_test_section(self, result): - section_content = '
\n' - for key, value in result.items(): - if value is not None: # Check if the value is not None - # Replace underscores and capitalize - formatted_key = key.replace('_', ' ').title() - section_content += f'

{formatted_key}: {value}

\n' - section_content += '
\n
\n' - return section_content - - def generate_header(self): - return f''' - - - - Test Results Summary - - - ''' - - def generate_css(self): - return ''' - body { - font-family: Arial, sans-serif; - margin: 20px; - } - h1 { - margin-bottom: 10px; - } - .summary { - border: 1px solid #ccc; - padding: 10px; - margin-bottom: 20px; - background-color: #f5f5f5; - } - .test-list { - list-style: none; - padding: 0; - } - .test-item { - margin-bottom: 10px; - } - .test-link { - text-decoration: none; - color: #007bff; - } - ''' +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Store previous test run information.""" + +from datetime import datetime +from weasyprint import HTML +from io import BytesIO +import base64 +import os + +DATE_TIME_FORMAT = '%Y-%m-%d %H:%M:%S' +RESOURCES_DIR = 'resources/report' + +# Locate parent directory +current_dir = os.path.dirname(os.path.realpath(__file__)) + +# Locate the test-run root directory, 4 levels, src->python->framework->test-run +root_dir = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(current_dir)))) + +# Obtain the report resources directory +report_resource_dir = os.path.join(root_dir, + RESOURCES_DIR) + +font_file = os.path.join(report_resource_dir,'GoogleSans-Regular.ttf') +test_run_img_file = os.path.join(report_resource_dir,'testrun.png') + +class TestReport(): + """Represents a previous Test Run report.""" + + def __init__(self, + status='Non-Compliant', + started=None, + finished=None, + total_tests=0 + ): + self._device = {} + self._status: str = status + self._started = started + self._finished = finished + self._total_tests = total_tests + self._results = [] + + def get_status(self): + return self._status + + def get_started(self): + return self._started + + def get_finished(self): + return self._finished + + def get_duration_seconds(self): + diff = self._finished - self._started + return diff.total_seconds() + + def get_duration(self): + return str(datetime.timedelta(seconds=self.get_duration_seconds())) + + def add_test(self, test): + self._results.append(test) + + def to_json(self): + report_json = {} + report_json['device'] = self._device + report_json['status'] = self._status + report_json['started'] = self._started.strftime(DATE_TIME_FORMAT) + report_json['finished'] = self._finished.strftime(DATE_TIME_FORMAT) + report_json['tests'] = {'total': self._total_tests, + 'results': self._results} + return report_json + + def from_json(self, json_file): + + self._device['mac_addr'] = json_file['device']['mac_addr'] + self._device['manufacturer'] = json_file['device']['manufacturer'] + self._device['model'] = json_file['device']['model'] + + if 'firmware' in self._device: + self._device['firmware'] = json_file['device']['firmware'] + + self._status = json_file['status'] + self._started = datetime.strptime(json_file['started'], DATE_TIME_FORMAT) + self._finished = datetime.strptime(json_file['finished'], DATE_TIME_FORMAT) + self._total_tests = json_file['tests']['total'] + + # Loop through test results + for test_result in json_file['tests']['results']: + self.add_test(test_result) + + return self + + # Create a pdf file in memory and return the bytes + def to_pdf(self): + # Resolve the data as html first + report_html = self.to_html() + + # Convert HTML to PDF in memory using weasyprint + pdf_bytes = BytesIO() + HTML(string=report_html).write_pdf(pdf_bytes) + return pdf_bytes + + def to_html(self): + json_data = self.to_json() + return f''' + + + {self.generate_head()} + + {self.generate_body(json_data)} + + + ''' + + def generate_test_sections(self,json_data): + results = json_data["tests"]["results"] + sections = "" + for result in results: + sections += self.generate_test_section(result) + return sections + + def generate_test_section(self, result): + section_content = '
\n' + for key, value in result.items(): + if value is not None: # Check if the value is not None + formatted_key = key.replace('_', ' ').title() # Replace underscores and capitalize + section_content += f'

{formatted_key}: {value}

\n' + section_content += '
\n
\n' + return section_content + + def generate_pages(self,json_data): + max_page = 1 + reports_per_page = 25 # figure out how many can fit on other pages + + # Calculate pages + test_count = len(json_data['tests']['results']) + + # 10 tests can fit on the first page + if test_count > 10: + test_count -= 10 + + full_page = (int)(test_count / reports_per_page) + partial_page = 1 if test_count % reports_per_page > 0 else 0 + if partial_page > 0: + max_page += full_page + partial_page + + pages = '' + for i in range(max_page): + pages += self.generate_page(json_data, i+1, max_page) + return pages + + def generate_page(self,json_data, page_num, max_page): + version = 'v1.0 (2023-10-02)' # Place holder until available in json report + page = '
' + page += self.generate_header(json_data) + if page_num == 1: + page += self.generate_summary(json_data) + page += self.generate_results(json_data, page_num) + page += self.generate_footer(page_num,max_page,version) + page += '
' + if page_num < max_page: + page += '
' + #page += f'''

''' + return page + + def generate_body(self,json_data, page_num=1, max_page=1): + return f''' + + {self.generate_pages(json_data)} + + ''' + + def generate_footer(self,page_num, max_page, version): + footer = f''' + + ''' + return footer + + def generate_results(self,json_data, page_num): + + result_list = ''' + +
+ Results List +
+
Name
+
Description
+
Result
+
''' + if page_num == 1: + start = 0 + else: + start = 10 * (page_num - 1) + (page_num-2) * 25 + results_on_page = 10 if page_num == 1 else 25 + result_end = min(results_on_page,len(json_data['tests']['results'])) + for ix in range(result_end-start): + result = json_data['tests']['results'][ix+start] + result_list += self.generate_result(result) + result_list += '
' + return result_list + + def generate_result(self,result): + if result['result'] == 'Non-Compliant': + result_class = 'result-test-result-non-compliant' + elif result['result'] == 'Compliant': + result_class = 'result-test-result-compliant' + else: + result_class = 'result-test-result-skipped' + + result_html = f''' +
+
{result['name']}
+
{result['test_description']}
+
{result['result']}
+
+ ''' + return result_html + + def generate_header(self, json_data): + with open(test_run_img_file, 'rb') as f: + tr_img_b64 = base64.b64encode(f.read()).decode('utf-8') + return f''' +
+

Testrun report

+

{json_data["device"]["manufacturer"]} {json_data["device"]["model"]}

+ Test Run +
+ ''' + + def generate_summary(self, json_data): + # Generate the basic content section layout + summary = ''' +
+ +
+ ''' + # Add the device information + manufacturer = json_data['device']['manufacturer'] if 'manufacturer' in json_data['device'] else 'Undefined' + model = json_data['device']['model'] if 'model' in json_data['device'] else 'Undefined' + fw = json_data['device']['firmware'] if 'firmware' in json_data['device'] else 'Undefined' + mac = json_data['device']['mac_addr'] if 'mac_addr' in json_data['device'] else 'Undefined' + + summary += self.generate_device_summary_label('Manufacturer',manufacturer) + summary += self.generate_device_summary_label('Model',model) + summary += self.generate_device_summary_label('Firmware',fw) + summary += self.generate_device_summary_label('MAC Address',mac,trailing_space=False) + + # Add the result summary + summary += self.generate_result_summary(json_data) + + summary += '\n
' + return summary + + def generate_result_summary(self,json_data): + if json_data['status'] == 'Compliant': + result_summary = '''
''' + else: + result_summary = '''
''' + result_summary += self.generate_result_summary_item('Test status', 'Complete') + result_summary += self.generate_result_summary_item('Test result', json_data['status'], style='color: white; font-size:24px; font-weight: 700;') + result_summary += self.generate_result_summary_item('Started', json_data['started']) + + # Convert the timestamp strings to datetime objects + start_time = datetime.strptime(json_data['started'], "%Y-%m-%d %H:%M:%S") + end_time = datetime.strptime(json_data['finished'], "%Y-%m-%d %H:%M:%S") + # Calculate the duration + duration = end_time - start_time + result_summary += self.generate_result_summary_item('Duration',str(duration)) + + result_summary += '\n
' + return result_summary + + def generate_result_summary_item(self, key, value, style=None): + summary_item = f'''
{key}
''' + if style is not None: + summary_item += f'''
{value}
''' + else: + summary_item += f'''
{value}
''' + return summary_item + + def generate_device_summary_label(self, key, value, trailing_space=True): + label = f''' +
{key}
+
{value}
+ ''' + if trailing_space: + label += '''
''' + return label + + def generate_head(self): + return f''' + + + + Testrun Report + + + ''' + + def generate_css(self): + return ''' + /* Set some global variables */ + :root { + --header-height: .75in; + --header-width: 8.5in; + --header-pos-x: 0in; + --header-pos-y: 0in; + --summary-width: 8.5in; + --summary-height: 2.8in; + --vertical-line-height: calc(var(--summary-height)-.2in); + --vertical-line-pos-x: 25%; + } + + @font-face { + font-family: 'Google Sans'; + font-style: normal; + src: url(https://fonts.gstatic.com/s/googlesans/v58/4Ua_rENHsxJlGDuGo1OIlJfC6l_24rlCK1Yo_Iqcsih3SAyH6cAwhX9RFD48TE63OOYKtrwEIJllpyk.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + /* Define some common body formatting*/ + body { + font-family: 'Google Sans', sans-serif; + margin: 0px; + padding: 0px; + } + + /* Use this for various section breaks*/ + .gradient-line { + position: relative; + background-image: linear-gradient(to right, red, blue, green, yellow, orange); + height: 1px; + /* Adjust the height as needed */ + width: 100%; + /* To span the entire width */ + display: block; + /* Ensures it's a block-level element */ + } + + /* Sets proper page size during print to pdf for weasyprint */ + @page { + size: Letter; + width: 8.5in; + height: 11in; + } + + .page { + position: relative; + margin: 0 20px; + width: 8.5in; + height: 11in; + } + + /* Define the header related css elements*/ + .header { + position: relative; + } + + .header-text { + margin: 0 0 8px 0; + font-size: 20px; + font-weight: 400; + } + + .header-title { + margin: 0px; + font-size: 48px; + font-weight: 700; + } + + /* Define the summary related css elements*/ + .summary-content { + position: relative; + width: var(--summary-width); + height: var(--summary-height); + margin-top: 19px; + margin-bottom: 19px; + } + + .summary-item-label { + position: relative; + font-size: 12px; + font-weight: 500; + color: #5F6368; + } + + .summary-item-value { + position: relative; + font-size: 20px; + font-weight: 400; + color: #202124; + } + + .summary-item-space { + position: relative; + padding-bottom: 15px; + margin: 0; + } + + .summary-vertical-line { + width: 1px; + height: var(--vertical-line-height); + background-color: #80868B; + position: absolute; + top: .3in; + bottom: .1in; + left: 3in; + } + + /* CSS for the color box */ + .summary-color-box { + position: absolute; + right: 0in; + top: .3in; + width: 2.6in; + height: 226px; + } + + .summary-box-compliant { + background-color: rgb(24, 128, 56); + } + + .summary-box-non-compliant { + background-color: #b31412; + } + + .summary-box-label { + font-size: 14px; + margin-top: 5px; + color: #DADCE0; + position: relative; + top: 10px; + left: 10px; + font-weight: 500; + } + + .summary-box-value { + font-size: 18px; + margin: 0 0 10px 0; + color: #ffffff; + position: relative; + top: 10px; + left: 10px; + } + + .result-list-title { + font-size: 24px; + } + + .result-list { + position: relative; + margin-top: .2in; + font-size: 18px; + } + + .result-line { + border: 1px solid #D3D3D3; + /* Light Gray border*/ + height: .4in; + width: 8.5in; + } + + .result-line-result { + border-top: 0px; + } + + .result-list-header-label { + font-weight: 500; + position: absolute; + font-size: 12px; + font-weight: bold; + height: 40px; + display: flex; + align-items: center; + } + + .result-test-label { + position: absolute; + font-size: 12px; + margin-top: 12px; + max-width: 300px; + font-weight: normal; + align-items: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .result-test-description { + max-width: 380px; + } + + .result-test-result-non-compliant { + background-color: #FCE8E6; + color: #C5221F; + left: 7.02in; + } + + .result-test-result { + position: absolute; + font-size: 12px; + width: fit-content; + height: 12px; + margin-top: 8px; + padding: 4px 4px 7px 5px; + border-radius: 2px; + } + + .result-test-result-compliant { + background-color: #E6F4EA; + color: #137333; + left: 7.16in; + } + + .result-test-result-skipped { + background-color: #e3e3e3; + color: #393939; + left: 7.2in; + } + + /* CSS for the footer */ + .footer { + position: absolute; + height: 30px; + width: 8.5in; + bottom: 0in; + } + + .footer-label { + position: absolute; + top: 20px; + font-size: 12px; + } + + @media print { + @page { + size: Letter; + width: 8.5in; + height: 11in; + } + }''' \ No newline at end of file diff --git a/modules/test/base/python/src/test_module.py b/modules/test/base/python/src/test_module.py index c36ba0adf..47ec7b6c1 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -99,7 +99,8 @@ def run_tests(self): LOGGER.info(f'Test {test["name"]} not implemented. Skipping') result = None else: - LOGGER.debug(f'Test {test["name"]} is disabled. Skipping') + LOGGER.debug(f'Test {test["name"]} is disabled') + if result is not None: if isinstance(result, bool): test['result'] = 'Compliant' if result else 'Non-Compliant' @@ -118,7 +119,7 @@ def run_tests(self): duration = datetime.fromisoformat(test['end']) - datetime.fromisoformat( test['start']) test['duration'] = str(duration) - + json_results = json.dumps({'results': tests}, indent=2) self._write_results(json_results) diff --git a/modules/test/baseline/README.md b/modules/test/baseline/README.md index 96a700572..c7c83f72e 100644 --- a/modules/test/baseline/README.md +++ b/modules/test/baseline/README.md @@ -17,5 +17,5 @@ Within the ```python/src``` directory, the below tests are executed. | ID | Description | Expected behavior | Required result |---|---|---|---| | baseline.compliant | Simulate a compliant test | A compliant test result is generated | Required | -| baseline.informational | Simulate an informational test | An informational test result is generated | Informational | +| baseline.skipped | Simulate an skipped test | An skipped test result is generated | Skipped | | baseline.non-compliant | Simulate a non-compliant test | A non-compliant test result is generated | Required | \ No newline at end of file diff --git a/modules/test/baseline/conf/module_config.json b/modules/test/baseline/conf/module_config.json index cc78ce0a0..bdaff49c4 100644 --- a/modules/test/baseline/conf/module_config.json +++ b/modules/test/baseline/conf/module_config.json @@ -25,10 +25,10 @@ "required_result": "Recommended" }, { - "name": "baseline.informational", - "test_description": "Simulate an informational test", - "expected_behavior": "An informational test result is generated", - "required_result": "Informational" + "name": "baseline.skipped", + "test_description": "Simulate a skipped test", + "expected_behavior": "A skipped test result is generated", + "required_result": "Skipped" } ] } diff --git a/modules/test/baseline/python/src/baseline_module.py b/modules/test/baseline/python/src/baseline_module.py index 111db708e..38da718de 100644 --- a/modules/test/baseline/python/src/baseline_module.py +++ b/modules/test/baseline/python/src/baseline_module.py @@ -37,7 +37,7 @@ def _baseline_non_compliant(self): LOGGER.info('Baseline non-compliant test finished') return False, 'Baseline non-compliant test ran successfully' - def _baseline_informational(self): - LOGGER.info('Running baseline informational test') - LOGGER.info('Baseline informational test finished') - return None, 'Baseline informational test ran successfully' + def _baseline_skipped(self): + LOGGER.info('Running baseline skipped test') + LOGGER.info('Baseline skipped test finished') + return None, 'Baseline skipped test ran successfully' diff --git a/resources/report/Google Sans.woff2 b/resources/report/Google Sans.woff2 new file mode 100644 index 000000000..8fdba9873 Binary files /dev/null and b/resources/report/Google Sans.woff2 differ diff --git a/resources/report/testrun.png b/resources/report/testrun.png new file mode 100644 index 000000000..9d0610adf Binary files /dev/null and b/resources/report/testrun.png differ