diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index f45851445..533343870 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -76,7 +76,7 @@ jobs: - name: Install Node uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 with: - node-version: 18.13.0 + node-version: 18.18.0 - name: Install Chromium Browser run: sudo apt install chromium-browser - name: Install dependencies @@ -99,7 +99,7 @@ jobs: - name: Install Node uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 with: - node-version: 18.13.0 + node-version: 18.18.0 - name: Install dependencies run: npm install && npm ci working-directory: ./modules/ui diff --git a/cmd/prepare b/cmd/prepare index 252d505a0..9e68f734d 100755 --- a/cmd/prepare +++ b/cmd/prepare @@ -20,6 +20,6 @@ echo Installing system dependencies # Install system dependencies -sudo apt-get update && sudo apt-get install openvswitch-common openvswitch-switch python3 libpangocairo-1.0-0 +sudo apt-get update && sudo apt-get install openvswitch-common openvswitch-switch python3 libpangocairo-1.0-0 ethtool echo Finished installing system dependencies diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 758da6656..7f72d8810 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -95,6 +95,9 @@ def __init__(self, test_run): self._router.add_api_route("/device/edit", self.edit_device, methods=["POST"]) + + self._router.add_api_route("/system/modules", + self.get_test_modules) # Allow all origins to access the API origins = ["*"] @@ -551,6 +554,20 @@ async def get_results(self, response: Response, response.status_code = 404 return self._generate_msg(False, "Test results could not be found") + async def get_test_modules(self): + + LOGGER.debug("Received request to list test modules") + + test_modules = [] + + for test_module in self._get_test_run().get_test_orc().get_test_modules(): + + # Only add module if it is an actual, enabled test module + if (test_module.enabled and test_module.enable_container): + test_modules.append(test_module.display_name) + + return test_modules + def _validate_device_json(self, json_obj): # Check all required properties are present @@ -575,3 +592,6 @@ def _validate_device_json(self, json_obj): return False return True + + def _get_test_run(self): + return self._test_run diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 45687b866..e2a187cce 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -58,7 +58,10 @@ def __init__(self, self._report_url = '' self._cur_page = 0 # Placeholder until available in json report - self._version = 'v1.2.2' + self._version = 'v1.3-alpha' + + def get_mac_addr(self): + return self._mac_addr def get_mac_addr(self): return self._mac_addr @@ -94,6 +97,12 @@ def get_report_url(self): def set_mac_addr(self, mac_addr): self._mac_addr = mac_addr + def set_mac_addr(self, mac_addr): + self._mac_addr = mac_addr + + def set_mac_addr(self, mac_addr): + self._mac_addr = mac_addr + def to_json(self): report_json = {} diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 71070e2cc..22607a520 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -100,14 +100,18 @@ def __init__(self, if validate: self._session.add_runtime_param('validate') - self.load_all_devices() - self._net_orc = net_orc.NetworkOrchestrator( session=self._session) self._test_orc = test_orc.TestOrchestrator( self._session, self._net_orc) + # Load device repository + self.load_all_devices() + + # Load test modules + self._test_orc.start() + if self._no_ui: # Check Testrun is able to start @@ -324,8 +328,6 @@ def start(self): if self._net_only: LOGGER.info('Network only option configured, no tests will be run') else: - self._test_orc.start() - self.get_net_orc().get_listener().register_callback( self._device_stable, [NetworkEvent.DEVICE_STABLE] @@ -382,6 +384,9 @@ def get_config_file(self): def get_net_orc(self): return self._net_orc + def get_test_orc(self): + return self._test_orc + def _start_network(self): # Start the network orchestrator if not self.get_net_orc().start(): diff --git a/framework/python/src/net_orc/ip_control.py b/framework/python/src/net_orc/ip_control.py index 1eeafc1a9..506b23a95 100644 --- a/framework/python/src/net_orc/ip_control.py +++ b/framework/python/src/net_orc/ip_control.py @@ -42,7 +42,7 @@ def add_namespace(self, namespace): return success def check_interface_status(self, interface_name): - output = util.run_command(cmd=f'ip link show {interface_name}',output=True) + output = util.run_command(cmd=f'ip link show {interface_name}', output=True) if 'state DOWN ' in output[0]: return False else: @@ -81,6 +81,22 @@ def get_links(self): netns_links.append(interface_name.strip()) return netns_links + def get_iface_connection_stats(self, iface): + """Extract information about the physical connection""" + response = util.run_command(f'ethtool {iface}') + if len(response[1]) == 0: + return response[0] + else: + return None + + def get_iface_port_stats(self, iface): + """Extract information about packets connection""" + response = util.run_command(f'ethtool -S {iface}') + if len(response[1]) == 0: + return response[0] + else: + return None + def get_namespaces(self): result = util.run_command('ip netns list') #Strip ID's from the namespace results diff --git a/framework/python/src/net_orc/network_orchestrator.py b/framework/python/src/net_orc/network_orchestrator.py index aa23f1918..11c5918a9 100644 --- a/framework/python/src/net_orc/network_orchestrator.py +++ b/framework/python/src/net_orc/network_orchestrator.py @@ -173,6 +173,7 @@ def _device_discovered(self, mac_addr): # Ignore discovered device return + self._get_port_stats(pre_monitor=True) self._monitor_in_progress = True LOGGER.debug( @@ -203,6 +204,8 @@ def _device_discovered(self, mac_addr): with open(runtime_device_conf, 'w', encoding='utf-8') as f: json.dump(self._session.get_target_device().to_config_json(), f, indent=2) + self._get_conn_stats() + if device.ip_addr is None: LOGGER.info( f'Timed out whilst waiting for {mac_addr} to obtain an IP address') @@ -216,6 +219,31 @@ def _device_discovered(self, mac_addr): self._start_device_monitor(device) + def _get_conn_stats(self): + """ Extract information about the physical connection + and store it to a file for the conn test module to access""" + dev_int = self._session.get_device_interface() + conn_stats = self._ip_ctrl.get_iface_connection_stats(dev_int) + if conn_stats is not None: + eth_out_file = os.path.join(NET_DIR, 'ethtool_conn_stats.txt') + with open(eth_out_file, 'w', encoding='utf-8') as f: + f.write(conn_stats) + else: + LOGGER.error('Failed to generate connection stats') + + def _get_port_stats(self, pre_monitor=True): + """ Extract information about the port statistics + and store it to a file for the conn test module to access""" + dev_int = self._session.get_device_interface() + port_stats = self._ip_ctrl.get_iface_port_stats(dev_int) + if port_stats is not None: + suffix = 'pre_monitor' if pre_monitor else 'post_monitor' + eth_out_file = os.path.join(NET_DIR, f'ethtool_port_stats_{suffix}.txt') + with open(eth_out_file, 'w', encoding='utf-8') as f: + f.write(port_stats) + else: + LOGGER.error('Failed to generate port stats') + def monitor_in_progress(self): return self._monitor_in_progress @@ -261,6 +289,7 @@ def _start_device_monitor(self, device): wrpcap(os.path.join(device_runtime_dir, 'monitor.pcap'), self._monitor_packets) self._monitor_in_progress = False + self._get_port_stats(pre_monitor=False) self.get_listener().call_callback(NetworkEvent.DEVICE_STABLE, device.mac_addr) @@ -498,23 +527,22 @@ def _start_network_service(self, net_module): try: client = docker.from_env() net_module.container = client.containers.run( - net_module.image_name, - auto_remove=True, - cap_add=['NET_ADMIN'], - name=net_module.container_name, - hostname=net_module.container_name, - # Undetermined version of docker seems to have broken - # DNS configuration (/etc/resolv.conf) Re-add when/if - # this network is utilized and DNS issue is resolved - #network=PRIVATE_DOCKER_NET, - privileged=True, - detach=True, - mounts=net_module.mounts, - environment={ - 'TZ': self.get_session().get_timezone(), - 'HOST_USER': util.get_host_user() - } - ) + net_module.image_name, + auto_remove=True, + cap_add=['NET_ADMIN'], + name=net_module.container_name, + hostname=net_module.container_name, + # Undetermined version of docker seems to have broken + # DNS configuration (/etc/resolv.conf) Re-add when/if + # this network is utilized and DNS issue is resolved + #network=PRIVATE_DOCKER_NET, + privileged=True, + detach=True, + mounts=net_module.mounts, + environment={ + 'TZ': self.get_session().get_timezone(), + 'HOST_USER': util.get_host_user() + }) except docker.errors.ContainerError as error: LOGGER.error('Container run error') LOGGER.error(error) diff --git a/modules/test/conn/README.md b/modules/test/conn/README.md index cf79c3efb..c2f6377c6 100644 --- a/modules/test/conn/README.md +++ b/modules/test/conn/README.md @@ -14,6 +14,9 @@ Within the ```python/src``` directory, the below tests are executed. A few dhcp | ID | Description | Expected Behavior | Required Result | |------------------------------|----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------| +| connection.port_link | The network switch port connected to the device has an active link without errors | When the etherent cable is connected to the port, the device triggers the port to its enabled \"Link UP\" (LEDs illuminate on device and switch ports if present) state, and the switch shows no errors with the LEDs and when interrogated with a \"show interface\" command on most network switches. | Required | +| connection.port_speed | The network switch port connected to the device has auto-negotiated a speed that is 10 Mbps or higher | When the ethernet cable is connected to the port, the device autonegotiates a speed that can be checked with the \"show interface\" command on most network switches. The output of this command must also show that the \"configured speed\" is set to \"auto\". | Required | +| connection.port_duplex | The network switch port connected to the device has auto-negotiated full-duplex. | When the ethernet cable is connected to the port, the device autonegotiates a full-duplex connection. | Required | | connection.dhcp_address | The device under test has received an IP address from the DHCP server and responds to an ICMP echo (ping) request | The device is not set up with a static IP address. The device accepts an IP address from a DHCP server (RFC 2131) and responds successfully to an ICMP echo (ping) request. | Required | | connection.mac_address | Check and note device physical address. | N/A | Required | | connection.mac_oui | The device under test has a MAC address prefix that is registered against a known manufacturer. | The MAC address prefix is registered in the IEEE Organizationally Unique Identifier database. | Required | diff --git a/modules/test/conn/conf/module_config.json b/modules/test/conn/conf/module_config.json index 888a06c48..5289e7eb0 100644 --- a/modules/test/conn/conf/module_config.json +++ b/modules/test/conn/conf/module_config.json @@ -13,6 +13,24 @@ "timeout": 1800 }, "tests": [ + { + "name": "connection.port_link", + "test_description": "The network switch port connected to the device has an active link without errors", + "expected_behavior": "When the etherent cable is connected to the port, the device triggers the port to its enabled \"Link UP\" (LEDs illuminate on device and switch ports if present) state, and the switch shows no errors with the LEDs and when interrogated with a \"show interface\" command on most network switches.", + "required_result": "Required" + }, + { + "name": "connection.port_speed", + "test_description": "The network switch port connected to the device has auto-negotiated a speed that is 10 Mbps or higher", + "expected_behavior": "When the ethernet cable is connected to the port, the device autonegotiates a speed that can be checked with the \"show interface\" command on most network switches. The output of this command must also show that the \"configured speed\" is set to \"auto\".", + "required_result": "Required" + }, + { + "name": "connection.port_duplex", + "test_description": "The network switch port connected to the device has auto-negotiated full-duplex", + "expected_behavior": "When the ethernet cable is connected to the port, the device autonegotiates a full-duplex connection.", + "required_result": "Required" + }, { "name": "connection.switch.arp_inspection", "test_description": "The device implements ARP correctly as per RFC826", diff --git a/modules/test/conn/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index 34e129103..7df9aa3d9 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -20,15 +20,16 @@ from dhcp1.client import Client as DHCPClient1 from dhcp2.client import Client as DHCPClient2 from dhcp_util import DHCPUtil +from port_stats_util import PortStatsUtil LOG_NAME = 'test_connection' -LOGGER = None OUI_FILE = '/usr/local/etc/oui.txt' STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' DHCP_CAPTURE_FILE = '/runtime/network/dhcp-1.pcap' SLAAC_PREFIX = 'fd10:77be:4186' TR_CONTAINER_MAC_PREFIX = '9a:02:57:1e:8f:' +LOGGER = None # Should be at least twice as much as the max lease time # set in the DHCP server @@ -38,10 +39,15 @@ class ConnectionModule(TestModule): """Connection Test module""" - def __init__(self, module): - super().__init__(module_name=module, log_name=LOG_NAME) + def __init__(self, module, log_dir=None, conf_file=None, results_dir=None): + super().__init__(module_name=module, + log_name=LOG_NAME, + log_dir=log_dir, + conf_file=conf_file, + results_dir=results_dir) global LOGGER LOGGER = self._get_logger() + self._port_stats = PortStatsUtil(logger=LOGGER) self.dhcp1_client = DHCPClient1() self.dhcp2_client = DHCPClient2() self._dhcp_util = DHCPUtil(self.dhcp1_client, self.dhcp2_client, LOGGER) @@ -74,6 +80,18 @@ def __init__(self, module): # response = self.dhcp1_client.set_dhcp_range('10.10.10.20','10.10.10.30') # print("Set Range: " + str(response)) + def _connection_port_link(self): + LOGGER.info('Running connection.port_link') + return self._port_stats.connection_port_link_test() + + def _connection_port_speed(self): + LOGGER.info('Running connection.port_speed') + return self._port_stats.connection_port_speed_test() + + def _connection_port_duplex(self): + LOGGER.info('Running connection.port_duplex') + return self._port_stats.connection_port_duplex_test() + def _connection_switch_arp_inspection(self): LOGGER.info('Running connection.switch.arp_inspection') diff --git a/modules/test/conn/python/src/port_stats_util.py b/modules/test/conn/python/src/port_stats_util.py new file mode 100644 index 000000000..d923501eb --- /dev/null +++ b/modules/test/conn/python/src/port_stats_util.py @@ -0,0 +1,141 @@ +# 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. +"""Module that contains various methods for validating the Port statistics """ + +import os + +ETHTOOL_CONN_STATS_FILE = 'runtime/network/ethtool_conn_stats.txt' +ETHTOOL_PORT_STATS_PRE_FILE = ( + 'runtime/network/ethtool_port_stats_pre_monitor.txt') +ETHTOOL_PORT_STATS_POST_FILE = ( + 'runtime/network/ethtool_port_stats_post_monitor.txt') + +LOG_NAME = 'port_stats_util' +LOGGER = None + + +class PortStatsUtil(): + """Helper class for various tests concerning Port behavior""" + + def __init__(self, + logger, + ethtool_conn_stats_file=ETHTOOL_CONN_STATS_FILE, + ethtool_port_stats_pre_file=ETHTOOL_PORT_STATS_PRE_FILE, + ethtool_port_stats_post_file=ETHTOOL_PORT_STATS_POST_FILE): + self.ethtool_conn_stats_file = ethtool_conn_stats_file + self.ethtool_port_stats_pre_file = ethtool_port_stats_pre_file + self.ethtool_port_stats_post_file = ethtool_port_stats_post_file + global LOGGER + LOGGER = logger + self.conn_stats = self._read_stats_file(self.ethtool_conn_stats_file) + + def is_autonegotiate(self): + auto_negotiation = False + auto_negotiation_status = self._get_stat_option(stats=self.conn_stats, + option='Auto-negotiation:') + if auto_negotiation_status is not None: + auto_negotiation = 'on' in auto_negotiation_status + return auto_negotiation + + def connection_port_link_test(self): + stats_pre = self._read_stats_file(self.ethtool_port_stats_pre_file) + stats_post = self._read_stats_file(self.ethtool_port_stats_post_file) + result = None + description = '' + details = '' + if stats_pre is None or stats_pre is None: + result = 'Error' + description = 'Port stats not available' + else: + tx_errors_pre = self._get_stat_option(stats=stats_pre, + option='tx_errors:') + tx_errors_post = self._get_stat_option(stats=stats_post, + option='tx_errors:') + rx_errors_pre = self._get_stat_option(stats=stats_pre, + option='rx_errors:') + rx_errors_post = self._get_stat_option(stats=stats_post, + option='rx_errors:') + tx_errors = int(tx_errors_post) - int(tx_errors_pre) + rx_errors = int(rx_errors_post) - int(rx_errors_pre) + if tx_errors > 0 or rx_errors > 0: + result = False + description = 'Port errors detected' + details = f'TX errors: {tx_errors}, RX errors: {rx_errors}' + else: + result = True + description = 'No port errors detected' + return result, description, details + + def connection_port_duplex_test(self): + auto_negotiation = self.is_autonegotiate() + # Calculate final results + result = None + description = '' + details = '' + if not auto_negotiation: + result = False + description = 'Interface not configured for auto-negotiation' + else: + duplex = self._get_stat_option(stats=self.conn_stats, option='Duplex:') + if 'Full' in duplex: + result = True + description = 'Succesfully auto-negotiated full duplex' + details = f'Duplex negotiated: {duplex}' + else: + result = False + description = 'Failed to auto-negotate full duplex' + details = f'Duplex negotiated: {duplex}' + return result, description, details + + def connection_port_speed_test(self): + auto_negotiation = self.is_autonegotiate() + # Calculate final results + result = None + description = '' + details = '' + if not auto_negotiation: + result = False + description = 'Interface not configured for auto-negotiation' + else: + speed = self._get_stat_option(stats=self.conn_stats, option='Speed:') + if speed in ('100Mb/s', '1000Mb/s'): + result = True + description = 'Succesfully auto-negotiated speeds above 10 Mbps' + details = f'Speed negotiated: {speed}' + else: + result = False + description = 'Failed to auto-negotate speeds above 10 Mbps' + details = f'Speed negotiated: {speed}' + return result, description, details + + def _get_stat_option(self, stats, option): + """Extract the requested parameter from the ethtool result""" + value = None + for line in stats.split('\n'): + #LOGGER.info(f'Checking option: {line}') + if line.startswith(f'{option}'): + value = line.split(':')[1].strip() + break + return value + + def _read_stats_file(self, file): + if os.path.isfile(file): + with open(file, encoding='utf-8') as f: + content = f.read() + # Cleanup the results for easier processing + lines = content.split('\n') + cleaned_lines = [line.strip() for line in lines if line.strip()] + recombined_text = '\n'.join(cleaned_lines) + return recombined_text + return None diff --git a/modules/ui/src/app/app-routing.module.ts b/modules/ui/src/app/app-routing.module.ts index d503116d6..454782cc9 100644 --- a/modules/ui/src/app/app-routing.module.ts +++ b/modules/ui/src/app/app-routing.module.ts @@ -37,6 +37,14 @@ const routes: Routes = [ import('./pages/reports/history.module').then(m => m.HistoryModule), title: 'Testrun - Reports', }, + { + path: 'risk-assessment', + loadChildren: () => + import('./pages/risk-assessment/risk-assessment.module').then( + m => m.RiskAssessmentModule + ), + title: 'Testrun - Risk Assessment', + }, { path: '', redirectTo: 'devices', diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 8b3a8f7dc..bd93aeb12 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -14,96 +14,134 @@ limitations under the License. --> - - -
- - - - - - - -
-
+ + + +
+ + + + + + + + + +
+
- - - - - -

Testrun

-
- - - - -
-
- - - - No ports are detected. Please define a valid ones using - - - Selected port is missing! Please define a valid one using - + + + + + +

Testrun

+
+ + + + + +
+
+ + + + No ports are detected. Please define a valid ones using + + + Selected port is missing! Please define a valid one using + + System settings + panel. + + + + Step 1: To perform a device test, please, select ports in Testrun > panel. - - - Step 1: To perform a device test, please, select ports in - System settings - panel. - - - Step 2: To perform a device test please - Create a Device - first. - - - Step 3: Once device is created, you are able to - start testing. - - -
-
+ + Step 2: To perform a device test please + Create a Device + first. + + + Step 3: Once device is created, you are able to + start testing. + + +
+
+
+ Testrun class="settings-drawer"> -
+ + + + + { let component: AppComponent; @@ -91,15 +95,15 @@ describe('AppComponent', () => { 'setIsOpenStartTestrun', 'fetchDevices', 'getTestModules', + 'testrunInProgress', + 'fetchCertificates', ]); + mockService.fetchCertificates.and.returnValue(of([])); mockFocusManagerService = jasmine.createSpyObj('mockFocusManagerService', [ 'focusFirstElementInContainer', ]); - (mockService.systemStatus$ as unknown) = of({}); - mockService.isTestrunStarted$ = of(true); - TestBed.configureTestingModule({ imports: [ RouterTestingModule, @@ -113,6 +117,7 @@ describe('AppComponent', () => { BypassComponent, CalloutComponent, MatIconTestingModule, + CertificatesComponent, ], providers: [ { provide: TestRunService, useValue: mockService }, @@ -135,6 +140,9 @@ describe('AppComponent', () => { { selector: selectError, value: null }, { selector: selectMenuOpened, value: false }, { selector: selectHasDevices, value: false }, + { selector: selectIsTestrunStarted, value: false }, + { selector: selectSystemStatus, value: null }, + { selector: selectIsOpenStartTestrun, value: false }, ], }), { provide: FocusManagerService, useValue: mockFocusManagerService }, @@ -324,6 +332,7 @@ describe('AppComponent', () => { describe('menu button', () => { beforeEach(() => { + mockFocusManagerService.focusFirstElementInContainer.calls.reset(); store.overrideSelector(selectHasDevices, false); fixture.detectChanges(); }); @@ -363,7 +372,9 @@ describe('AppComponent', () => { menuBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); - expect(document.activeElement).toBe(document.body); + expect( + mockFocusManagerService.focusFirstElementInContainer + ).not.toHaveBeenCalled(); }); }); @@ -389,8 +400,7 @@ describe('AppComponent', () => { describe('Callout component visibility', () => { describe('with no connection settings', () => { beforeEach(() => { - component.hasConnectionSetting$ = of(false); - component.ngOnInit(); + store.overrideSelector(selectHasConnectionSettings, false); fixture.detectChanges(); }); @@ -429,11 +439,12 @@ describe('AppComponent', () => { describe('with system status as "Idle"', () => { beforeEach(() => { - component.hasConnectionSetting$ = of(true); + component.appStore.updateIsStatusLoaded(true); + store.overrideSelector(selectHasConnectionSettings, true); store.overrideSelector(selectHasDevices, true); - mockService.systemStatus$ = of(MOCK_PROGRESS_DATA_IDLE); - mockService.isTestrunStarted$ = of(false); - component.ngOnInit(); + store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_IDLE); + store.overrideSelector(selectIsTestrunStarted, false); + fixture.detectChanges(); }); @@ -507,7 +518,11 @@ describe('AppComponent', () => { describe('with devices setted but without systemStatus data', () => { beforeEach(() => { store.overrideSelector(selectHasDevices, true); - mockService.isTestrunStarted$ = of(false); + store.overrideSelector(selectIsTestrunStarted, false); + component.appStore.updateIsStatusLoaded(true); + store.overrideSelector(selectHasConnectionSettings, true); + store.overrideSelector(selectSystemStatus, null); + fixture.detectChanges(); }); @@ -547,7 +562,7 @@ describe('AppComponent', () => { describe('with devices setted, without systemStatus data, but run the tests ', () => { beforeEach(() => { store.overrideSelector(selectHasDevices, true); - mockService.isTestrunStarted$ = of(true); + store.overrideSelector(selectIsTestrunStarted, true); fixture.detectChanges(); }); @@ -561,7 +576,10 @@ describe('AppComponent', () => { describe('with devices setted and systemStatus data', () => { beforeEach(() => { store.overrideSelector(selectHasDevices, true); - mockService.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); fixture.detectChanges(); }); @@ -575,12 +593,11 @@ describe('AppComponent', () => { describe('error', () => { describe('with settingMissedError with one port is missed', () => { beforeEach(() => { - component.settingMissedError$ = of({ + store.overrideSelector(selectError, { isSettingMissed: true, devicePortMissed: true, internetPortMissed: false, }); - component.ngOnInit(); fixture.detectChanges(); }); @@ -595,12 +612,11 @@ describe('AppComponent', () => { describe('with settingMissedError with two ports are missed', () => { beforeEach(() => { - component.settingMissedError$ = of({ + store.overrideSelector(selectError, { isSettingMissed: true, devicePortMissed: true, internetPortMissed: true, }); - component.ngOnInit(); fixture.detectChanges(); }); @@ -615,7 +631,7 @@ describe('AppComponent', () => { describe('with no settingMissedError', () => { beforeEach(() => { - component.settingMissedError$ = of(null); + store.overrideSelector(selectError, null); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -640,6 +656,27 @@ describe('AppComponent', () => { expect(spyToggle).toHaveBeenCalledTimes(0); }); + + it('should render certificates button', () => { + const generalSettingsButton = compiled.querySelector( + '.app-toolbar-button-certificates' + ); + + expect(generalSettingsButton).toBeDefined(); + }); + + it('should call settingsDrawer open on click settings button', () => { + fixture.detectChanges(); + + const settingsBtn = compiled.querySelector( + '.app-toolbar-button-certificates' + ) as HTMLButtonElement; + spyOn(component.certDrawer, 'open'); + + settingsBtn.click(); + + expect(component.certDrawer.open).toHaveBeenCalledTimes(1); + }); }); @Component({ @@ -647,10 +684,8 @@ describe('AppComponent', () => { template: '
', }) class FakeGeneralSettingsComponent { - @Input() interfaces = []; - @Input() hasConnectionSettings = false; + @Input() settingsDisable = false; @Output() closeSettingEvent = new EventEmitter(); - @Output() reloadInterfacesEvent = new EventEmitter(); getSystemInterfaces = () => {}; getSystemConfig = () => {}; } diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index 307638e75..2cf2869e1 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -13,39 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, ViewChild } from '@angular/core'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import { MatDrawer } from '@angular/material/sidenav'; -import { TestRunService } from './services/test-run.service'; -import { Observable } from 'rxjs'; -import { TestrunStatus, StatusOfTestrun } from './model/testrun-status'; +import { StatusOfTestrun } from './model/testrun-status'; import { Router } from '@angular/router'; import { CalloutType } from './model/callout-type'; -import { tap, shareReplay } from 'rxjs/operators'; import { Routes } from './model/routes'; import { FocusManagerService } from './services/focus-manager.service'; import { State, Store } from '@ngrx/store'; import { AppState } from './store/state'; -import { - selectError, - selectHasConnectionSettings, - selectInterfaces, - selectMenuOpened, -} from './store/selectors'; import { setIsOpenAddDevice, toggleMenu, updateFocusNavigation, } from './store/actions'; import { appFeatureKey } from './store/reducers'; -import { SettingMissedError, SystemInterfaces } from './model/setting'; import { GeneralSettingsComponent } from './pages/settings/general-settings.component'; import { AppStore } from './app.store'; +import { TestRunService } from './services/test-run.service'; const DEVICES_LOGO_URL = '/assets/icons/devices.svg'; const DEVICES_RUN_URL = '/assets/icons/device_run.svg'; const REPORTS_LOGO_URL = '/assets/icons/reports.svg'; +const RISK_ASSESSMENT_LOGO_URL = '/assets/icons/risk-assessment.svg'; const TESTRUN_LOGO_URL = '/assets/icons/testrun_logo_small.svg'; const TESTRUN_LOGO_COLOR_URL = '/assets/icons/testrun_logo_color.svg'; const CLOSE_URL = '/assets/icons/close.svg'; @@ -56,25 +48,17 @@ const CLOSE_URL = '/assets/icons/close.svg'; styleUrls: ['./app.component.scss'], providers: [AppStore], }) -export class AppComponent implements OnInit { +export class AppComponent { public readonly CalloutType = CalloutType; public readonly StatusOfTestrun = StatusOfTestrun; public readonly Routes = Routes; - systemStatus$!: Observable; - isTestrunStarted$!: Observable; - hasConnectionSetting$: Observable = this.store.select( - selectHasConnectionSettings - ); - isStatusLoaded = false; private openedSettingFromToggleBtn = true; - isMenuOpen: Observable = this.store.select(selectMenuOpened); - interfaces: Observable = - this.store.select(selectInterfaces); - settingMissedError$: Observable = - this.store.select(selectError); @ViewChild('settingsDrawer') public settingsDrawer!: MatDrawer; + @ViewChild('certDrawer') public certDrawer!: MatDrawer; @ViewChild('toggleSettingsBtn') public toggleSettingsBtn!: HTMLButtonElement; + @ViewChild('toggleCertificatesBtn') + public toggleCertificatesBtn!: HTMLButtonElement; @ViewChild('navigation') public navigation!: ElementRef; @ViewChild('settings') public settings!: GeneralSettingsComponent; viewModel$ = this.appStore.viewModel$; @@ -82,15 +66,15 @@ export class AppComponent implements OnInit { constructor( private matIconRegistry: MatIconRegistry, private domSanitizer: DomSanitizer, - private testRunService: TestRunService, private route: Router, private store: Store, private state: State, private readonly focusManagerService: FocusManagerService, - private appStore: AppStore + private testRunService: TestRunService, + public appStore: AppStore ) { this.appStore.getDevices(); - this.testRunService.getSystemStatus(); + this.appStore.getSystemStatus(); this.matIconRegistry.addSvgIcon( 'devices', this.domSanitizer.bypassSecurityTrustResourceUrl(DEVICES_LOGO_URL) @@ -103,6 +87,10 @@ export class AppComponent implements OnInit { 'reports', this.domSanitizer.bypassSecurityTrustResourceUrl(REPORTS_LOGO_URL) ); + this.matIconRegistry.addSvgIcon( + 'risk_assessment', + this.domSanitizer.bypassSecurityTrustResourceUrl(RISK_ASSESSMENT_LOGO_URL) + ); this.matIconRegistry.addSvgIcon( 'testrun_logo_small', this.domSanitizer.bypassSecurityTrustResourceUrl(TESTRUN_LOGO_URL) @@ -117,15 +105,6 @@ export class AppComponent implements OnInit { ); } - ngOnInit(): void { - this.systemStatus$ = this.testRunService.systemStatus$.pipe( - tap(() => (this.isStatusLoaded = true)), - shareReplay({ refCount: true, bufferSize: 1 }) - ); - - this.isTestrunStarted$ = this.testRunService.isTestrunStarted$; - } - navigateToDeviceRepository(): void { this.route.navigate([Routes.Devices]); this.store.dispatch(setIsOpenAddDevice({ isOpenAddDevice: true })); @@ -133,7 +112,11 @@ export class AppComponent implements OnInit { navigateToRuntime(): void { this.route.navigate([Routes.Testing]); - this.testRunService.setIsOpenStartTestrun(true); + this.appStore.setIsOpenStartTestrun(); + } + + async closeCertificates(): Promise { + await this.certDrawer.close(); } async closeSetting(hasDevices: boolean): Promise { @@ -176,15 +159,15 @@ export class AppComponent implements OnInit { await this.settingsDrawer.open(); } + async openCert() { + await this.certDrawer.open(); + } + consentShown() { this.appStore.setContent(); } - testrunInProgress(status?: string): boolean { - return ( - status === StatusOfTestrun.InProgress || - status === StatusOfTestrun.WaitingForDevice || - status === StatusOfTestrun.Monitoring - ); + isTestrunInProgress(status?: string) { + return this.testRunService.testrunInProgress(status); } } diff --git a/modules/ui/src/app/app.module.ts b/modules/ui/src/app/app.module.ts index 753aaadc4..c182fa3ea 100644 --- a/modules/ui/src/app/app.module.ts +++ b/modules/ui/src/app/app.module.ts @@ -46,6 +46,7 @@ import { CdkTrapFocus } from '@angular/cdk/a11y'; import { SettingsDropdownComponent } from './pages/settings/components/settings-dropdown/settings-dropdown.component'; import { ShutdownAppComponent } from './components/shutdown-app/shutdown-app.component'; import { WindowProvider } from './providers/window.provider'; +import { CertificatesComponent } from './pages/certificates/certificates.component'; @NgModule({ declarations: [AppComponent, GeneralSettingsComponent], @@ -74,6 +75,7 @@ import { WindowProvider } from './providers/window.provider'; CdkTrapFocus, SettingsDropdownComponent, ShutdownAppComponent, + CertificatesComponent, ], providers: [ WindowProvider, diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 93e7c8113..d53840850 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -18,11 +18,20 @@ import { of, skip, take } from 'rxjs'; import { AppStore, CONSENT_SHOWN_KEY } from './app.store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from './store/state'; -import { selectHasDevices } from './store/selectors'; +import { + selectError, + selectHasConnectionSettings, + selectHasDevices, + selectInterfaces, + selectIsTestrunStarted, + selectMenuOpened, + selectSystemStatus, +} from './store/selectors'; import { TestRunService } from './services/test-run.service'; import SpyObj = jasmine.SpyObj; import { device } from './mocks/device.mock'; -import { setDevices } from './store/actions'; +import { setDevices, setTestrunStatus } from './store/actions'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from './mocks/progress.mock'; const mock = (() => { let store: { [key: string]: string } = {}; @@ -48,7 +57,7 @@ describe('AppStore', () => { let mockService: SpyObj; beforeEach(() => { - mockService = jasmine.createSpyObj(['fetchDevices']); + mockService = jasmine.createSpyObj(['fetchDevices', 'fetchSystemStatus']); TestBed.configureTestingModule({ providers: [ @@ -62,6 +71,13 @@ describe('AppStore', () => { appStore = TestBed.inject(AppStore); store.overrideSelector(selectHasDevices, true); + store.overrideSelector(selectHasConnectionSettings, true); + store.overrideSelector(selectMenuOpened, true); + store.overrideSelector(selectInterfaces, {}); + store.overrideSelector(selectError, null); + store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_IN_PROGRESS); + store.overrideSelector(selectIsTestrunStarted, false); + spyOn(store, 'dispatch').and.callFake(() => {}); }); @@ -82,6 +98,15 @@ describe('AppStore', () => { appStore.updateConsent(true); }); + + it('should update isStatusLoaded', (done: DoneFn) => { + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.isStatusLoaded).toEqual(true); + done(); + }); + + appStore.updateIsStatusLoaded(true); + }); }); describe('selectors', () => { @@ -90,6 +115,13 @@ describe('AppStore', () => { expect(store).toEqual({ consentShown: false, hasDevices: true, + isTestrunStarted: false, + isStatusLoaded: false, + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + hasConnectionSettings: true, + isMenuOpen: true, + interfaces: {}, + settingMissedError: null, }); done(); }); @@ -127,5 +159,30 @@ describe('AppStore', () => { expect(store.dispatch).toHaveBeenCalledWith(setDevices({ devices })); }); }); + + describe('getSystemStatus', () => { + const status = MOCK_PROGRESS_DATA_IN_PROGRESS; + + beforeEach(() => { + mockService.fetchSystemStatus.and.returnValue(of(status)); + }); + + it('should dispatch action setTestrunStatus', () => { + appStore.getSystemStatus(); + + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ systemStatus: status }) + ); + }); + + it('should update store', done => { + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.systemStatus).toEqual(status); + done(); + }); + + appStore.getSystemStatus(); + }); + }); }); }); diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index 86d3560c6..d214d4848 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -17,26 +17,61 @@ import { Injectable } from '@angular/core'; import { ComponentStore } from '@ngrx/component-store'; import { tap } from 'rxjs/operators'; -import { selectHasDevices } from './store/selectors'; +import { + selectError, + selectHasConnectionSettings, + selectHasDevices, + selectInterfaces, + selectIsTestrunStarted, + selectMenuOpened, + selectSystemStatus, +} from './store/selectors'; import { Store } from '@ngrx/store'; import { AppState } from './store/state'; import { TestRunService } from './services/test-run.service'; -import { exhaustMap } from 'rxjs'; +import { exhaustMap, Observable } from 'rxjs'; import { Device } from './model/device'; -import { setDevices } from './store/actions'; +import { + setDevices, + setTestrunStatus, + setIsOpenStartTestrun, +} from './store/actions'; +import { TestrunStatus } from './model/testrun-status'; +import { SettingMissedError, SystemInterfaces } from './model/setting'; export const CONSENT_SHOWN_KEY = 'CONSENT_SHOWN'; export interface AppComponentState { consentShown: boolean; + isStatusLoaded: boolean; + isTestrunStarted: boolean; + systemStatus: TestrunStatus | null; } @Injectable() export class AppStore extends ComponentStore { private consentShown$ = this.select(state => state.consentShown); + private isStatusLoaded$ = this.select(state => state.isStatusLoaded); private hasDevices$ = this.store.select(selectHasDevices); + private hasConnectionSetting$ = this.store.select( + selectHasConnectionSettings + ); + private isMenuOpen$ = this.store.select(selectMenuOpened); + private interfaces$: Observable = + this.store.select(selectInterfaces); + private settingMissedError$: Observable = + this.store.select(selectError); + private systemStatus$ = this.store.select(selectSystemStatus); + private isTestrunStarted$ = this.store.select(selectIsTestrunStarted); viewModel$ = this.select({ consentShown: this.consentShown$, hasDevices: this.hasDevices$, + isTestrunStarted: this.isTestrunStarted$, + isStatusLoaded: this.isStatusLoaded$, + systemStatus: this.systemStatus$, + hasConnectionSettings: this.hasConnectionSetting$, + isMenuOpen: this.isMenuOpen$, + interfaces: this.interfaces$, + settingMissedError: this.settingMissedError$, }); updateConsent = this.updater((state, consentShown: boolean) => ({ @@ -44,6 +79,11 @@ export class AppStore extends ComponentStore { consentShown, })); + updateIsStatusLoaded = this.updater((state, isStatusLoaded: boolean) => ({ + ...state, + isStatusLoaded, + })); + setContent = this.effect(trigger$ => { return trigger$.pipe( tap(() => { @@ -65,12 +105,38 @@ export class AppStore extends ComponentStore { ); }); + getSystemStatus = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.fetchSystemStatus().pipe( + tap((res: TestrunStatus) => { + this.updateIsStatusLoaded(true); + this.store.dispatch(setTestrunStatus({ systemStatus: res })); + }) + ); + }) + ); + }); + + setIsOpenStartTestrun = this.effect(trigger$ => { + return trigger$.pipe( + tap(() => { + this.store.dispatch( + setIsOpenStartTestrun({ isOpenStartTestrun: true }) + ); + }) + ); + }); + constructor( private store: Store, private testRunService: TestRunService ) { super({ consentShown: sessionStorage.getItem(CONSENT_SHOWN_KEY) !== null, + isStatusLoaded: false, + isTestrunStarted: false, + systemStatus: null, }); } } diff --git a/modules/ui/src/app/components/device-item/device-item.component.html b/modules/ui/src/app/components/device-item/device-item.component.html index 84117dd34..c2d69f7cc 100644 --- a/modules/ui/src/app/components/device-item/device-item.component.html +++ b/modules/ui/src/app/components/device-item/device-item.component.html @@ -36,6 +36,7 @@ *ngIf="deviceView === DeviceView.WithActions" class="device-item-with-actions"> + + + diff --git a/modules/ui/src/app/components/snack-bar/snack-bar.component.scss b/modules/ui/src/app/components/snack-bar/snack-bar.component.scss new file mode 100644 index 000000000..c3772e863 --- /dev/null +++ b/modules/ui/src/app/components/snack-bar/snack-bar.component.scss @@ -0,0 +1,31 @@ +/** + * 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. + */ +@import '../../../theming/colors'; + +.snack-bar-container { + display: flex; + + .snack-bar-label p { + margin: 0; + } + + .snack-bar-actions button.action-btn { + color: $blue-300; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + } +} diff --git a/modules/ui/src/app/components/snack-bar/snack-bar.component.spec.ts b/modules/ui/src/app/components/snack-bar/snack-bar.component.spec.ts new file mode 100644 index 000000000..76013d1f8 --- /dev/null +++ b/modules/ui/src/app/components/snack-bar/snack-bar.component.spec.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SnackBarComponent } from './snack-bar.component'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { AppState } from '../../store/state'; +import { MatSnackBarRef } from '@angular/material/snack-bar'; +import { setIsOpenWaitSnackBar, setIsStopTestrun } from '../../store/actions'; + +describe('SnackBarComponent', () => { + let component: SnackBarComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + let store: MockStore; + + const MatSnackBarRefMock = { + open: () => ({}), + dismiss: () => ({}), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SnackBarComponent], + providers: [ + { provide: MatSnackBarRef, useValue: MatSnackBarRefMock }, + provideMockStore({}), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SnackBarComponent); + component = fixture.componentInstance; + store = TestBed.inject(MockStore); + compiled = fixture.nativeElement as HTMLElement; + spyOn(store, 'dispatch').and.callFake(() => {}); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch setIsStopTestrun action', () => { + const actionBtnStop = compiled.querySelector( + '.action-btn.stop' + ) as HTMLButtonElement; + + actionBtnStop.click(); + + expect(store.dispatch).toHaveBeenCalledWith( + setIsStopTestrun({ isStopTestrun: true }) + ); + }); + + it('should dispatch setIsOpenWaitSnackBar action', () => { + const actionBtnWait = compiled.querySelector( + '.action-btn.wait' + ) as HTMLButtonElement; + + actionBtnWait.click(); + + expect(store.dispatch).toHaveBeenCalledWith( + setIsOpenWaitSnackBar({ isOpenWaitSnackBar: false }) + ); + }); +}); diff --git a/modules/ui/src/app/components/snack-bar/snack-bar.component.ts b/modules/ui/src/app/components/snack-bar/snack-bar.component.ts new file mode 100644 index 000000000..aa19f971b --- /dev/null +++ b/modules/ui/src/app/components/snack-bar/snack-bar.component.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatSnackBarAction, + MatSnackBarActions, + MatSnackBarLabel, + MatSnackBarRef, +} from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../store/state'; +import { setIsOpenWaitSnackBar, setIsStopTestrun } from '../../store/actions'; + +@Component({ + selector: 'app-snack-bar', + standalone: true, + imports: [ + MatButtonModule, + MatSnackBarLabel, + MatSnackBarActions, + MatSnackBarAction, + ], + templateUrl: './snack-bar.component.html', + styleUrl: './snack-bar.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SnackBarComponent { + snackBarRef = inject(MatSnackBarRef); + constructor(private store: Store) {} + + wait(): void { + this.snackBarRef.dismiss(); + this.store.dispatch(setIsOpenWaitSnackBar({ isOpenWaitSnackBar: false })); + } + + stop(): void { + this.store.dispatch(setIsStopTestrun({ isStopTestrun: true })); + } +} diff --git a/modules/ui/src/app/components/version/version.component.spec.ts b/modules/ui/src/app/components/version/version.component.spec.ts index a821c8328..f7fe3136e 100644 --- a/modules/ui/src/app/components/version/version.component.spec.ts +++ b/modules/ui/src/app/components/version/version.component.spec.ts @@ -38,6 +38,8 @@ describe('VersionComponent', () => { const versionBehaviorSubject$ = new BehaviorSubject(null); beforeEach(() => { + // @ts-expect-error data layer should be defined + window.dataLayer = window.dataLayer || []; mockService = jasmine.createSpyObj(['getVersion', 'fetchVersion']); mockService.getVersion.and.returnValue(versionBehaviorSubject$); TestBed.configureTestingModule({ diff --git a/modules/ui/src/app/components/version/version.component.ts b/modules/ui/src/app/components/version/version.component.ts index a85a3ce40..7693ecfcf 100644 --- a/modules/ui/src/app/components/version/version.component.ts +++ b/modules/ui/src/app/components/version/version.component.ts @@ -68,6 +68,11 @@ export class VersionComponent implements OnInit, OnDestroy { this.openConsentDialog(version); this.consentShownEvent.emit(); } + // @ts-expect-error data layer is not null + window.dataLayer.push({ + event: 'testrun_version', + testrunVersion: version?.installed_version, + }); }) ); } diff --git a/modules/ui/src/app/mocks/certificate.mock.ts b/modules/ui/src/app/mocks/certificate.mock.ts new file mode 100644 index 000000000..65d0be109 --- /dev/null +++ b/modules/ui/src/app/mocks/certificate.mock.ts @@ -0,0 +1,33 @@ +/** + * 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. + */ +import { Certificate } from '../model/certificate'; + +export const certificate = { + name: 'iot.bms.google.com', + organisation: 'Google, Inc.', + expires: '2024-09-01T09:00:12Z', +} as Certificate; + +export const certificate_uploading = { + name: 'test', + uploading: true, +} as Certificate; + +export const certificate2 = { + name: 'sensor.bms.google.com', + organisation: 'Google, Inc.', + expires: '2024-09-01T09:00:12Z', +} as Certificate; diff --git a/modules/ui/src/app/mocks/progress.mock.ts b/modules/ui/src/app/mocks/progress.mock.ts index 258e73c10..0572e79c0 100644 --- a/modules/ui/src/app/mocks/progress.mock.ts +++ b/modules/ui/src/app/mocks/progress.mock.ts @@ -97,6 +97,14 @@ export const MOCK_PROGRESS_DATA_COMPLIANT: TestrunStatus = 'https://api.testrun.io/report.pdf' ); +export const MOCK_PROGRESS_DATA_NON_COMPLIANT: TestrunStatus = + PROGRESS_DATA_RESPONSE( + StatusOfTestrun.NonCompliant, + '2023-06-22T09:20:00.123Z', + TEST_DATA_RESULT, + 'https://api.testrun.io/report.pdf' + ); + export const MOCK_PROGRESS_DATA_CANCELLED: TestrunStatus = PROGRESS_DATA_RESPONSE(StatusOfTestrun.Cancelled, null, TEST_DATA); diff --git a/modules/ui/src/app/model/certificate.ts b/modules/ui/src/app/model/certificate.ts new file mode 100644 index 000000000..384120cf7 --- /dev/null +++ b/modules/ui/src/app/model/certificate.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ +export interface Certificate { + name: string; + organisation?: string; + expires?: string; + uploading?: boolean; +} diff --git a/modules/ui/src/app/model/routes.ts b/modules/ui/src/app/model/routes.ts index f523f1b15..05ff91ed9 100644 --- a/modules/ui/src/app/model/routes.ts +++ b/modules/ui/src/app/model/routes.ts @@ -18,4 +18,5 @@ export enum Routes { Devices = '/devices', Testing = '/testing', Reports = '/reports', + RiskAssessment = '/risk-assessment', } diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html new file mode 100644 index 000000000..ee641becf --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.html @@ -0,0 +1,26 @@ +
+ workspace_premium +
+

{{ certificate.name }}

+

+ {{ certificate.organisation }} +

+

+ {{ certificate.expires | date: 'dd MMM yyyy' }} +

+ +
+ + +
diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.scss b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.scss new file mode 100644 index 000000000..3f09e90e2 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.scss @@ -0,0 +1,79 @@ +/** + * 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. + */ +@import 'src/theming/colors'; +@import 'src/theming/variables'; + +:host { + ::ng-deep .mat-mdc-progress-bar { + --mdc-linear-progress-active-indicator-color: #1967d2; + } +} +:host:first-child .certificate-item-container { + border-top: 1px solid $lighter-grey; +} +.certificate-item-container { + display: grid; + grid-template-columns: 24px minmax(200px, 1fr) 24px; + gap: 16px; + box-sizing: border-box; + height: 88px; + padding: 12px 0; + border-bottom: 1px solid $lighter-grey; +} + +.certificate-item-icon { + color: $grey-700; +} + +.certificate-item-delete { + padding: 0; + height: 24px; + width: 24px; + border-radius: 4px; + color: $grey-700; + display: flex; + align-items: start; + justify-content: center; + & ::ng-deep .mat-mdc-button-persistent-ripple { + border-radius: 4px; + } + &:disabled { + pointer-events: none; + opacity: 0.6; + } +} + +.certificate-item-information { + overflow: hidden; + p { + font-family: $font-secondary, sans-serif; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .certificate-item-name { + font-size: 16px; + color: $grey-800; + height: 24px; + } + .certificate-item-organisation, + .certificate-item-expires { + font-size: 14px; + color: $grey-700; + height: 20px; + } +} diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts new file mode 100644 index 000000000..94a0a7a19 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CertificateItemComponent } from './certificate-item.component'; +import { + certificate, + certificate_uploading, +} from '../../../mocks/certificate.mock'; + +describe('CertificateItemComponent', () => { + let component: CertificateItemComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CertificateItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CertificateItemComponent); + compiled = fixture.nativeElement as HTMLElement; + component = fixture.componentInstance; + component.certificate = certificate; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('DOM tests', () => { + it('should have certificate name', () => { + const name = compiled.querySelector('.certificate-item-name'); + + expect(name?.textContent?.trim()).toEqual('iot.bms.google.com'); + }); + + describe('uploaded certificate', () => { + it('should have certificate organization', () => { + const organization = compiled.querySelector( + '.certificate-item-organisation' + ); + + expect(organization?.textContent?.trim()).toEqual('Google, Inc.'); + }); + + it('should have certificate expire date', () => { + const date = compiled.querySelector('.certificate-item-expires'); + + expect(date?.textContent?.trim()).toEqual('01 Sep 2024'); + }); + + describe('delete button', () => { + let deleteButton: HTMLButtonElement; + beforeEach(() => { + deleteButton = fixture.nativeElement.querySelector( + '.certificate-item-delete' + ) as HTMLButtonElement; + }); + + it('should be present', () => { + expect(deleteButton).toBeDefined(); + }); + + it('should emit delete event on delete button clicked', () => { + const deleteSpy = spyOn(component.deleteButtonClicked, 'emit'); + deleteButton.click(); + + expect(deleteSpy).toHaveBeenCalledWith('iot.bms.google.com'); + }); + }); + }); + + describe('uploading certificate', () => { + beforeEach(() => { + component.certificate = certificate_uploading; + fixture.detectChanges(); + }); + + it('should have loader', () => { + const loader = compiled.querySelector('mat-progress-bar'); + + expect(loader).toBeDefined(); + }); + + it('should have disabled delete button', () => { + const deleteButton = fixture.nativeElement.querySelector( + '.certificate-item-delete' + ) as HTMLButtonElement; + + expect(deleteButton.getAttribute('disabled')).toBeTruthy(); + }); + }); + }); +}); diff --git a/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts new file mode 100644 index 000000000..87e3a8160 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-item/certificate-item.component.ts @@ -0,0 +1,20 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Certificate } from '../../../model/certificate'; +import { MatIcon } from '@angular/material/icon'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { provideAnimations } from '@angular/platform-browser/animations'; + +@Component({ + selector: 'app-certificate-item', + standalone: true, + imports: [MatIcon, MatButtonModule, MatProgressBarModule, CommonModule], + providers: [provideAnimations()], + templateUrl: './certificate-item.component.html', + styleUrl: './certificate-item.component.scss', +}) +export class CertificateItemComponent { + @Input() certificate!: Certificate; + @Output() deleteButtonClicked = new EventEmitter(); +} diff --git a/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.html b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.html new file mode 100644 index 000000000..cea3fb1b4 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.html @@ -0,0 +1,13 @@ + + + diff --git a/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.scss b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.scss new file mode 100644 index 000000000..3f9e70f6e --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.scss @@ -0,0 +1,12 @@ +.browse-files-button { + margin: 18px 16px; + padding: 8px 24px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; +} + +#default-file-input { + display: none; +} diff --git a/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.spec.ts b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.spec.ts new file mode 100644 index 000000000..2dcbd9e05 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CertificateUploadButtonComponent } from './certificate-upload-button.component'; + +describe('CertificateUploadButtonComponent', () => { + let component: CertificateUploadButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CertificateUploadButtonComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CertificateUploadButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('DOM tests', () => { + it('should have upload file button', () => { + const uploadCertificatesButton = fixture.nativeElement.querySelector( + '.browse-files-button' + ) as HTMLButtonElement; + + expect(uploadCertificatesButton).toBeDefined(); + }); + + it('should have hidden file input', () => { + const input = fixture.nativeElement.querySelector( + '#default-file-input' + ) as HTMLInputElement; + + expect(input).toBeDefined(); + }); + + it('should emit input click on button click', () => { + const input = fixture.nativeElement.querySelector( + '#default-file-input' + ) as HTMLInputElement; + const inputSpy = spyOn(input, 'click'); + + const uploadCertificatesButton = fixture.nativeElement.querySelector( + '.browse-files-button' + ) as HTMLButtonElement; + + uploadCertificatesButton.click(); + + expect(inputSpy).toHaveBeenCalled(); + }); + + it('should detect file input change and emit event', () => { + const emitSpy = spyOn(component.fileChanged, 'emit'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File([''], 'test-file.pdf')); + + const inputDebugEl = fixture.nativeElement.querySelector( + '#default-file-input' + ) as HTMLInputElement; + + inputDebugEl.files = dataTransfer.files; + + inputDebugEl.dispatchEvent(new InputEvent('change')); + + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.ts b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.ts new file mode 100644 index 000000000..5b06163b4 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificate-upload-button/certificate-upload-button.component.ts @@ -0,0 +1,25 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'app-certificate-upload-button', + standalone: true, + imports: [MatButtonModule], + templateUrl: './certificate-upload-button.component.html', + styleUrl: './certificate-upload-button.component.scss', +}) +export class CertificateUploadButtonComponent { + @Output() fileChanged = new EventEmitter(); + fileChange(event: Event) { + const fileList = (event.target as HTMLInputElement).files; + + if (fileList && fileList.length < 1) { + return; + } + + // @ts-expect-error fileList is not null at this point + const file: File = fileList[0]; + + this.fileChanged.emit(file); + } +} diff --git a/modules/ui/src/app/pages/certificates/certificates.component.html b/modules/ui/src/app/pages/certificates/certificates.component.html new file mode 100644 index 000000000..d75f115b4 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.html @@ -0,0 +1,52 @@ + + +
+

Certificates

+ +
+
+ +
+ +
+ +
+
diff --git a/modules/ui/src/app/pages/certificates/certificates.component.scss b/modules/ui/src/app/pages/certificates/certificates.component.scss new file mode 100644 index 000000000..eca247502 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.scss @@ -0,0 +1,85 @@ +/** + * 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. + */ +@use '@angular/material' as mat; +@import '../../../theming/colors'; +@import '../../../theming/variables'; + +:host { + display: flex; + flex-direction: column; + height: 100%; + flex: 1 0 auto; +} + +.certificates-drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 12px 16px 24px; + + &-title { + margin: 0; + font-size: 22px; + font-style: normal; + font-weight: 400; + line-height: 28px; + color: $dark-grey; + } + + &-button { + min-width: 24px; + width: 24px; + height: 24px; + margin: 4px; + padding: 8px !important; + box-sizing: content-box; + + .close-button-icon { + width: 24px; + height: 24px; + margin: 0; + } + } +} + +.certificates-drawer-content { + overflow: hidden; + flex: 1; + display: grid; + grid-template-rows: auto 1fr auto; +} + +.content-certificates { + padding: 0 16px; + border-bottom: 1px solid $lighter-grey; + overflow-y: scroll; +} + +.certificates-drawer-footer { + padding: 16px 24px 8px 16px; + margin-top: auto; + display: flex; + flex-shrink: 0; + justify-content: flex-end; + + .close-button { + padding: 0 24px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + } +} diff --git a/modules/ui/src/app/pages/certificates/certificates.component.spec.ts b/modules/ui/src/app/pages/certificates/certificates.component.spec.ts new file mode 100644 index 000000000..0a50f6b9d --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.spec.ts @@ -0,0 +1,179 @@ +/** + * 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. + */ +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, + tick, +} from '@angular/core/testing'; + +import { CertificatesComponent } from './certificates.component'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { MatIcon } from '@angular/material/icon'; +import { certificate } from '../../mocks/certificate.mock'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; +import SpyObj = jasmine.SpyObj; +import { of } from 'rxjs'; +import { MatDialogRef } from '@angular/material/dialog'; +import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; +import { TestRunService } from '../../services/test-run.service'; +import { NotificationService } from '../../services/notification.service'; + +describe('CertificatesComponent', () => { + let component: CertificatesComponent; + let mockLiveAnnouncer: SpyObj; + let mockService: SpyObj; + let fixture: ComponentFixture; + + const notificationServiceMock: jasmine.SpyObj = + jasmine.createSpyObj(['notify']); + + beforeEach(async () => { + mockService = jasmine.createSpyObj([ + 'fetchCertificates', + 'deleteCertificate', + 'uploadCertificate', + ]); + mockService.deleteCertificate.and.returnValue(of(true)); + mockService.fetchCertificates.and.returnValue( + of([certificate, certificate]) + ); + mockLiveAnnouncer = jasmine.createSpyObj(['announce']); + await TestBed.configureTestingModule({ + imports: [CertificatesComponent, MatIconTestingModule, MatIcon], + providers: [ + { provide: LiveAnnouncer, useValue: mockLiveAnnouncer }, + { provide: TestRunService, useValue: mockService }, + { provide: NotificationService, useValue: notificationServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CertificatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('DOM tests', () => { + it('should emit closeSettingEvent when header button clicked', () => { + const headerCloseButton = fixture.nativeElement.querySelector( + '.certificates-drawer-header-button' + ) as HTMLButtonElement; + spyOn(component.closeCertificatedEvent, 'emit'); + + headerCloseButton.click(); + + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + 'The certificates panel is closed.' + ); + expect(component.closeCertificatedEvent.emit).toHaveBeenCalled(); + }); + + it('should emit closeSettingEvent when close button clicked', () => { + const headerCloseButton = fixture.nativeElement.querySelector( + '.close-button' + ) as HTMLButtonElement; + spyOn(component.closeCertificatedEvent, 'emit'); + + headerCloseButton.click(); + + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + 'The certificates panel is closed.' + ); + expect(component.closeCertificatedEvent.emit).toHaveBeenCalled(); + }); + + it('should have upload file button', () => { + const uploadCertificatesButton = fixture.nativeElement.querySelector( + '.browse-files-button' + ) as HTMLButtonElement; + + expect(uploadCertificatesButton).toBeDefined(); + }); + + describe('with certificates', () => { + it('should have certificates list', () => { + const certificateList = fixture.nativeElement.querySelectorAll( + 'app-certificate-item' + ); + + expect(certificateList.length).toEqual(2); + }); + }); + }); + + describe('Class tests', () => { + describe('#deleteCertificate', () => { + it('should open delete certificate modal', fakeAsync(() => { + const openSpy = spyOn(component.dialog, 'open').and.returnValue({ + afterClosed: () => of(true), + } as MatDialogRef); + tick(); + + component.deleteCertificate(certificate.name); + tick(); + + expect(openSpy).toHaveBeenCalledWith(DeleteFormComponent, { + ariaLabel: 'Delete certificate', + data: { + title: 'Delete certificate', + content: `You are about to delete a certificate iot.bms.google.com. Are you sure?`, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'delete-form-dialog', + }); + + openSpy.calls.reset(); + })); + }); + + describe('#focusNextButton', () => { + it('should focus next active element if exist', fakeAsync(() => { + const row = window.document.querySelector( + 'app-certificate-item' + ) as HTMLElement; + row.classList.add('certificate-selected'); + const nextButton = window.document.querySelector( + '.certificate-selected + app-certificate-item .certificate-item-delete' + ) as HTMLButtonElement; + const buttonFocusSpy = spyOn(nextButton, 'focus'); + + component.focusNextButton(); + + expect(buttonFocusSpy).toHaveBeenCalled(); + flush(); + })); + + it('should focus navigation button if next active element does not exist', fakeAsync(() => { + const nextButton = window.document.querySelector( + '.certificates-drawer-content .close-button' + ) as HTMLButtonElement; + const buttonFocusSpy = spyOn(nextButton, 'focus'); + + component.focusNextButton(); + + expect(buttonFocusSpy).toHaveBeenCalled(); + flush(); + })); + }); + }); +}); diff --git a/modules/ui/src/app/pages/certificates/certificates.component.ts b/modules/ui/src/app/pages/certificates/certificates.component.ts new file mode 100644 index 000000000..0e0b805b1 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.component.ts @@ -0,0 +1,111 @@ +/** + * 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. + */ +import { Component, EventEmitter, OnDestroy, Output } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { CertificateItemComponent } from './certificate-item/certificate-item.component'; +import { CommonModule, DatePipe } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; +import { CertificateUploadButtonComponent } from './certificate-upload-button/certificate-upload-button.component'; +import { CertificatesStore } from './certificates.store'; +import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; +import { Subject, takeUntil } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; + +@Component({ + selector: 'app-certificates', + standalone: true, + imports: [ + MatIcon, + CertificateItemComponent, + MatButtonModule, + CertificateUploadButtonComponent, + CommonModule, + ], + providers: [CertificatesStore, DatePipe], + templateUrl: './certificates.component.html', + styleUrl: './certificates.component.scss', +}) +export class CertificatesComponent implements OnDestroy { + viewModel$ = this.store.viewModel$; + @Output() closeCertificatedEvent = new EventEmitter(); + + private destroy$: Subject = new Subject(); + + constructor( + private liveAnnouncer: LiveAnnouncer, + private store: CertificatesStore, + public dialog: MatDialog + ) { + this.store.getCertificates(); + } + + ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.unsubscribe(); + } + + closeCertificates() { + this.liveAnnouncer.announce('The certificates panel is closed.'); + this.closeCertificatedEvent.emit(); + } + + uploadFile(file: File) { + this.store.uploadCertificate(file); + } + + deleteCertificate(name: string) { + this.store.selectCertificate(name); + + const dialogRef = this.dialog.open(DeleteFormComponent, { + ariaLabel: 'Delete certificate', + data: { + title: 'Delete certificate', + content: `You are about to delete a certificate ${name}. Are you sure?`, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'delete-form-dialog', + }); + + dialogRef + ?.afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe(deleteCertificate => { + if (deleteCertificate) { + this.store.deleteCertificate(name); + this.focusNextButton(); + } + }); + } + + focusNextButton() { + // Try to focus next interactive element, if exists + const next = window.document.querySelector( + '.certificate-selected + app-certificate-item .certificate-item-delete' + ) as HTMLButtonElement; + if (next) { + next.focus(); + } else { + // If next interactive element doest not exist, close button will be focused + const menuButton = window.document.querySelector( + '.certificates-drawer-content .close-button' + ) as HTMLButtonElement; + menuButton?.focus(); + } + } +} diff --git a/modules/ui/src/app/pages/certificates/certificates.store.spec.ts b/modules/ui/src/app/pages/certificates/certificates.store.spec.ts new file mode 100644 index 000000000..f42b9e928 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.store.spec.ts @@ -0,0 +1,159 @@ +/* + * 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. + */ +import { TestBed } from '@angular/core/testing'; +import { of, skip, take } from 'rxjs'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TestRunService } from '../../services/test-run.service'; +import SpyObj = jasmine.SpyObj; +import { + certificate, + certificate2, + certificate_uploading, +} from '../../mocks/certificate.mock'; +import { CertificatesStore } from './certificates.store'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { DatePipe } from '@angular/common'; +import { NotificationService } from '../../services/notification.service'; + +describe('CertificatesStore', () => { + let certificateStore: CertificatesStore; + let mockService: SpyObj; + const notificationServiceMock: jasmine.SpyObj = + jasmine.createSpyObj(['notify']); + + beforeEach(() => { + mockService = jasmine.createSpyObj([ + 'fetchCertificates', + 'uploadCertificate', + 'deleteCertificate', + ]); + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [ + CertificatesStore, + provideMockStore({}), + { provide: TestRunService, useValue: mockService }, + { provide: NotificationService, useValue: notificationServiceMock }, + DatePipe, + ], + }); + + certificateStore = TestBed.inject(CertificatesStore); + }); + + it('should be created', () => { + expect(certificateStore).toBeTruthy(); + }); + + describe('updaters', () => { + it('should update certificates', (done: DoneFn) => { + certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.certificates).toEqual([certificate]); + done(); + }); + + certificateStore.updateCertificates([certificate]); + }); + + it('should update selectedCertificate', (done: DoneFn) => { + const certificate = 'test'; + certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.selectedCertificate).toEqual(certificate); + done(); + }); + + certificateStore.selectCertificate(certificate); + }); + }); + + describe('selectors', () => { + it('should select state', done => { + certificateStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store).toEqual({ + certificates: [], + selectedCertificate: '', + }); + done(); + }); + }); + }); + + describe('effects', () => { + describe('fetchCertificates', () => { + const certificates = [certificate]; + + beforeEach(() => { + mockService.fetchCertificates.and.returnValue(of(certificates)); + }); + + it('should update certificates', done => { + certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.certificates).toEqual(certificates); + done(); + }); + + certificateStore.getCertificates(); + }); + }); + + describe('uploadCertificate', () => { + const uploadingCertificate = certificate_uploading; + + beforeEach(() => { + mockService.uploadCertificate.and.returnValue(of(true)); + mockService.fetchCertificates.and.returnValue(of([certificate])); + }); + + it('should update certificates', done => { + certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.certificates).toContain(uploadingCertificate); + }); + + certificateStore.viewModel$.pipe(skip(2), take(1)).subscribe(store => { + expect(store.certificates).toEqual([certificate]); + done(); + }); + + certificateStore.uploadCertificate(new File([], 'test')); + }); + + it('should notify', () => { + certificateStore.uploadCertificate(new File([], 'test')); + expect(notificationServiceMock.notify).toHaveBeenCalledWith( + 'Certificate successfully added.\niot.bms.google.com by Google, Inc. valid until 01 Sep 2024', + 0, + 'certificate-notification' + ); + }); + }); + + describe('deleteCertificate', () => { + it('should update store', done => { + mockService.deleteCertificate.and.returnValue(of(true)); + + certificateStore.updateCertificates([certificate, certificate2]); + + certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.certificates).toEqual([certificate2]); + done(); + }); + + certificateStore.deleteCertificate('iot.bms.google.com'); + }); + }); + }); +}); diff --git a/modules/ui/src/app/pages/certificates/certificates.store.ts b/modules/ui/src/app/pages/certificates/certificates.store.ts new file mode 100644 index 000000000..1def48617 --- /dev/null +++ b/modules/ui/src/app/pages/certificates/certificates.store.ts @@ -0,0 +1,136 @@ +/* + * 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. + */ + +import { Injectable } from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { tap, withLatestFrom } from 'rxjs/operators'; +import { catchError, EMPTY, exhaustMap, throwError } from 'rxjs'; +import { Certificate } from '../../model/certificate'; +import { TestRunService } from '../../services/test-run.service'; +import { NotificationService } from '../../services/notification.service'; +import { DatePipe } from '@angular/common'; + +export interface AppComponentState { + certificates: Certificate[]; + selectedCertificate: string; +} +@Injectable() +export class CertificatesStore extends ComponentStore { + private certificates$ = this.select(state => state.certificates); + private selectedCertificate$ = this.select( + state => state.selectedCertificate + ); + + viewModel$ = this.select({ + certificates: this.certificates$, + selectedCertificate: this.selectedCertificate$, + }); + + updateCertificates = this.updater((state, certificates: Certificate[]) => ({ + ...state, + certificates, + })); + + selectCertificate = this.updater((state, selectedCertificate: string) => ({ + ...state, + selectedCertificate, + })); + + getCertificates = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.fetchCertificates().pipe( + tap((certificates: Certificate[]) => { + this.updateCertificates(certificates); + }) + ); + }) + ); + }); + + uploadCertificate = this.effect(trigger$ => { + return trigger$.pipe( + withLatestFrom(this.certificates$), + tap(([file, certificates]) => { + this.addCertificate(file.name, certificates); + }), + exhaustMap(([file, certificates]) => { + return this.testRunService.uploadCertificate(file).pipe( + exhaustMap(uploaded => { + if (uploaded) { + return this.testRunService.fetchCertificates(); + } + return throwError('Failed to upload certificate'); + }), + tap(newCertificates => { + const uploadedCertificate = newCertificates.filter( + certificate => + !certificates.some(cert => cert.name === certificate.name) + )[0]; + this.updateCertificates(newCertificates); + this.notificationService.notify( + `Certificate successfully added.\n${uploadedCertificate.name} by ${uploadedCertificate.organisation} valid until ${this.datePipe.transform(uploadedCertificate.expires, 'dd MMM yyyy')}`, + 0, + 'certificate-notification' + ); + }), + catchError(() => { + this.removeCertificate(file.name, certificates); + return EMPTY; + }) + ); + }) + ); + }); + + addCertificate(name: string, certificates: Certificate[]) { + const certificate = { name, uploading: true } as Certificate; + this.updateCertificates([certificate, ...certificates]); + } + + deleteCertificate = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap((name: string) => { + return this.testRunService.deleteCertificate(name).pipe( + withLatestFrom(this.certificates$), + tap(([remove, current]) => { + if (remove) { + this.removeCertificate(name, current); + } + }) + ); + }) + ); + }); + + private removeCertificate(name: string, current: Certificate[]) { + const certificates = current.filter( + certificate => certificate.name !== name + ); + this.updateCertificates(certificates); + } + + constructor( + private testRunService: TestRunService, + private notificationService: NotificationService, + private datePipe: DatePipe + ) { + super({ + certificates: [], + selectedCertificate: '', + }); + } +} diff --git a/modules/ui/src/app/pages/devices/device-repository.component.html b/modules/ui/src/app/pages/devices/device-repository.component.html index 6dc585f5e..3750e8db6 100644 --- a/modules/ui/src/app/pages/devices/device-repository.component.html +++ b/modules/ui/src/app/pages/devices/device-repository.component.html @@ -26,7 +26,10 @@

Devices

(startTestrunClicked)="openStartTestrun($event, vm.devices)" [deviceView]="DeviceView.WithActions" [class.device-item-selected]="device === vm.selectedDevice" - [device]="device"> + [device]="device" + [disabled]=" + device?.mac_addr === vm.deviceInProgress?.mac_addr + "> diff --git a/modules/ui/src/app/pages/devices/device-repository.component.scss b/modules/ui/src/app/pages/devices/device-repository.component.scss index d15f6bad5..a089fb978 100644 --- a/modules/ui/src/app/pages/devices/device-repository.component.scss +++ b/modules/ui/src/app/pages/devices/device-repository.component.scss @@ -30,10 +30,10 @@ } .device-repository-toolbar { - padding-left: 32px; gap: 16px; background: $white; - height: 72px; + height: 74px; + padding: 24px 0 8px 32px; } .device-repository-content { diff --git a/modules/ui/src/app/pages/devices/device-repository.component.spec.ts b/modules/ui/src/app/pages/devices/device-repository.component.spec.ts index 98c923d91..f01edabee 100644 --- a/modules/ui/src/app/pages/devices/device-repository.component.spec.ts +++ b/modules/ui/src/app/pages/devices/device-repository.component.spec.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; import { of } from 'rxjs'; import { Device } from '../../model/device'; @@ -30,17 +35,26 @@ import { DeleteFormComponent } from '../../components/delete-form/delete-form.co import SpyObj = jasmine.SpyObj; import { FocusManagerService } from '../../services/focus-manager.service'; import { DevicesStore } from './devices.store'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { ProgressInitiateFormComponent } from '../testrun/components/progress-initiate-form/progress-initiate-form.component'; +import { Routes } from '../../model/routes'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Component } from '@angular/core'; describe('DeviceRepositoryComponent', () => { let component: DeviceRepositoryComponent; let fixture: ComponentFixture; let compiled: HTMLElement; let mockDevicesStore: SpyObj; + let router: Router; const stateServiceMock: jasmine.SpyObj = jasmine.createSpyObj('stateServiceMock', ['focusFirstElementInContainer']); beforeEach(async () => { + // @ts-expect-error data layer should be defined + window.dataLayer = window.dataLayer || []; mockDevicesStore = jasmine.createSpyObj('DevicesStore', [ 'setIsOpenAddDevice', 'selectDevice', @@ -50,18 +64,26 @@ describe('DeviceRepositoryComponent', () => { mockDevicesStore.testModules = MOCK_TEST_MODULES; await TestBed.configureTestingModule({ - imports: [DeviceRepositoryModule, BrowserAnimationsModule], + imports: [ + RouterTestingModule.withRoutes([ + { path: 'testing', component: FakeProgressComponent }, + ]), + DeviceRepositoryModule, + BrowserAnimationsModule, + MatIconTestingModule, + ], providers: [ { provide: DevicesStore, useValue: mockDevicesStore }, { provide: FocusManagerService, useValue: stateServiceMock }, ], - declarations: [DeviceRepositoryComponent], + declarations: [DeviceRepositoryComponent, FakeProgressComponent], }).compileComponents(); TestBed.overrideProvider(DevicesStore, { useValue: mockDevicesStore }); fixture = TestBed.createComponent(DeviceRepositoryComponent); component = fixture.componentInstance; + router = TestBed.get(Router); compiled = fixture.nativeElement as HTMLElement; }); @@ -74,6 +96,7 @@ describe('DeviceRepositoryComponent', () => { component.viewModel$ = of({ devices: [] as Device[], selectedDevice: null, + deviceInProgress: null, }); mockDevicesStore.devices$ = of([]); mockDevicesStore.isOpenAddDevice$ = of(true); @@ -102,6 +125,7 @@ describe('DeviceRepositoryComponent', () => { component.viewModel$ = of({ devices: [device, device, device], selectedDevice: device, + deviceInProgress: device, }); fixture.detectChanges(); }); @@ -199,6 +223,12 @@ describe('DeviceRepositoryComponent', () => { openSpy.calls.reset(); }); }); + + it('should disable device if deviceInProgress is exist', () => { + const item = compiled.querySelector('app-device-item'); + + expect(item?.getAttribute('ng-reflect-disabled')).toBeTruthy(); + }); }); it('should call setIsOpenAddDevice if dialog closes with null', () => { @@ -230,6 +260,7 @@ describe('DeviceRepositoryComponent', () => { component.viewModel$ = of({ devices: [device, device, device], selectedDevice: device, + deviceInProgress: null, }); fixture.detectChanges(); }); @@ -264,4 +295,39 @@ describe('DeviceRepositoryComponent', () => { expect(openDeviceDialogSpy).toHaveBeenCalledWith([device], device, true); }); }); + + describe('#openStartTestrun', () => { + it('should open initiate test run modal', fakeAsync(() => { + const openSpy = spyOn(component.dialog, 'open').and.returnValue({ + afterClosed: () => of(true), + } as MatDialogRef); + + fixture.ngZone?.run(() => { + component.openStartTestrun(device, [device]); + + expect(openSpy).toHaveBeenCalledWith(ProgressInitiateFormComponent, { + ariaLabel: 'Initiate testrun', + data: { + devices: [device], + device: device, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'initiate-test-run-dialog', + }); + + tick(); + expect(router.url).toBe(Routes.Testing); + + openSpy.calls.reset(); + }); + })); + }); }); + +@Component({ + selector: 'app-fake-progress-component', + template: '', +}) +class FakeProgressComponent {} diff --git a/modules/ui/src/app/pages/devices/device-repository.component.ts b/modules/ui/src/app/pages/devices/device-repository.component.ts index 8edb18f34..441a11f17 100644 --- a/modules/ui/src/app/pages/devices/device-repository.component.ts +++ b/modules/ui/src/app/pages/devices/device-repository.component.ts @@ -91,8 +91,12 @@ export class DeviceRepositoryComponent implements OnInit, OnDestroy { dialogRef ?.afterClosed() .pipe(takeUntil(this.destroy$)) - .subscribe(data => { - if (data === 'testrunStarted') { + .subscribe(testrunStarted => { + if (testrunStarted) { + // @ts-expect-error data layer is not null + window.dataLayer.push({ + event: 'successful_testrun_initiation', + }); this.route.navigate([Routes.Testing]); } }); diff --git a/modules/ui/src/app/pages/devices/devices.store.spec.ts b/modules/ui/src/app/pages/devices/devices.store.spec.ts index e60f612d6..0cf340b32 100644 --- a/modules/ui/src/app/pages/devices/devices.store.spec.ts +++ b/modules/ui/src/app/pages/devices/devices.store.spec.ts @@ -17,7 +17,10 @@ import { TestBed } from '@angular/core/testing'; import { of, skip, take } from 'rxjs'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../../store/state'; -import { selectHasDevices } from '../../store/selectors'; +import { + selectDeviceInProgress, + selectHasDevices, +} from '../../store/selectors'; import { TestRunService } from '../../services/test-run.service'; import SpyObj = jasmine.SpyObj; import { device, updated_device } from '../../mocks/device.mock'; @@ -45,6 +48,7 @@ describe('DevicesStore', () => { selectors: [ { selector: selectDevices, value: [device] }, { selector: selectIsOpenAddDevice, value: true }, + { selector: selectDeviceInProgress, value: device }, ], }), { provide: TestRunService, useValue: mockService }, @@ -79,6 +83,7 @@ describe('DevicesStore', () => { expect(store).toEqual({ devices: [device], selectedDevice: null, + deviceInProgress: device, }); done(); }); diff --git a/modules/ui/src/app/pages/devices/devices.store.ts b/modules/ui/src/app/pages/devices/devices.store.ts index 5417701c8..58cc131fd 100644 --- a/modules/ui/src/app/pages/devices/devices.store.ts +++ b/modules/ui/src/app/pages/devices/devices.store.ts @@ -22,7 +22,11 @@ import { tap, withLatestFrom } from 'rxjs/operators'; import { Device } from '../../model/device'; import { AppState } from '../../store/state'; import { Store } from '@ngrx/store'; -import { selectDevices, selectIsOpenAddDevice } from '../../store/selectors'; +import { + selectDeviceInProgress, + selectDevices, + selectIsOpenAddDevice, +} from '../../store/selectors'; import { setDevices, setIsOpenAddDevice } from '../../store/actions'; export interface DevicesComponentState { @@ -34,12 +38,14 @@ export interface DevicesComponentState { export class DevicesStore extends ComponentStore { devices$ = this.store.select(selectDevices); isOpenAddDevice$ = this.store.select(selectIsOpenAddDevice); + private deviceInProgress$ = this.store.select(selectDeviceInProgress); private selectedDevice$ = this.select(state => state.selectedDevice); testModules = this.testRunService.getTestModules(); viewModel$ = this.select({ devices: this.devices$, selectedDevice: this.selectedDevice$, + deviceInProgress: this.deviceInProgress$, }); selectDevice = this.updater((state, device: Device | null) => ({ diff --git a/modules/ui/src/app/pages/reports/history.component.scss b/modules/ui/src/app/pages/reports/history.component.scss index e8c8850d4..bd6098940 100644 --- a/modules/ui/src/app/pages/reports/history.component.scss +++ b/modules/ui/src/app/pages/reports/history.component.scss @@ -24,14 +24,14 @@ } .history-toolbar { - padding-left: 32px; gap: 10px; background: $white; - height: 72px; + height: 74px; + padding: 24px 0 8px 32px; } .history-content { - margin: 0 32px 39px 32px; + margin: 10px 32px 39px 32px; overflow-y: auto; border-radius: 4px; border: 1px solid $lighter-grey; diff --git a/modules/ui/src/app/pages/reports/reports.store.ts b/modules/ui/src/app/pages/reports/reports.store.ts index a457b358b..c9c1448cc 100644 --- a/modules/ui/src/app/pages/reports/reports.store.ts +++ b/modules/ui/src/app/pages/reports/reports.store.ts @@ -145,8 +145,10 @@ export class ReportsStore extends ComponentStore { exhaustMap(({ mac_addr, started }) => { return this.testRunService.deleteReport(mac_addr, started || '').pipe( withLatestFrom(this.history$), - tap(([, current]) => { - this.removeReport(mac_addr, started, current); + tap(([remove, current]) => { + if (remove) { + this.removeReport(mac_addr, started, current); + } }) ); }) diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment-routing.module.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment-routing.module.ts new file mode 100644 index 000000000..927da40bc --- /dev/null +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment-routing.module.ts @@ -0,0 +1,26 @@ +/** + * 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. + */ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { RiskAssessmentComponent } from './risk-assessment.component'; + +const routes: Routes = [{ path: '', component: RiskAssessmentComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class RiskAssessmentRoutingModule {} diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html new file mode 100644 index 000000000..e19456aa5 --- /dev/null +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html @@ -0,0 +1,18 @@ + + +

Risk assessment

+
diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.scss b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.scss new file mode 100644 index 000000000..171771e81 --- /dev/null +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.scss @@ -0,0 +1,22 @@ +/** + * 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. + */ +@import 'src/theming/colors'; + +.risk-assessment-toolbar { + height: 74px; + padding: 24px 0 8px 32px; + background: $white; +} diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts new file mode 100644 index 000000000..dc1f931e1 --- /dev/null +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts @@ -0,0 +1,52 @@ +/** + * 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. + */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RiskAssessmentComponent } from './risk-assessment.component'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('RiskAssessmentComponent', () => { + let component: RiskAssessmentComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RiskAssessmentComponent], + imports: [MatToolbarModule, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(RiskAssessmentComponent); + component = fixture.componentInstance; + compiled = fixture.nativeElement as HTMLElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have toolbar with title', () => { + const toolbarEl = compiled.querySelector('.risk-assessment-toolbar'); + const title = compiled.querySelector('h2.title'); + const titleContent = title?.innerHTML.trim(); + + expect(toolbarEl).not.toBeNull(); + expect(title).toBeTruthy(); + expect(titleContent).toContain('Risk assessment'); + }); +}); diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts new file mode 100644 index 000000000..c788ae2c0 --- /dev/null +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts @@ -0,0 +1,24 @@ +/** + * 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. + */ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'app-risk-assessment', + templateUrl: './risk-assessment.component.html', + styleUrl: './risk-assessment.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RiskAssessmentComponent {} diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.module.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.module.ts new file mode 100644 index 000000000..6d3838a92 --- /dev/null +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.module.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { RiskAssessmentRoutingModule } from './risk-assessment-routing.module'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { RiskAssessmentComponent } from './risk-assessment.component'; + +@NgModule({ + declarations: [RiskAssessmentComponent], + imports: [CommonModule, RiskAssessmentRoutingModule, MatToolbarModule], +}) +export class RiskAssessmentModule {} diff --git a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss b/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss index 7cd228c75..16093e336 100644 --- a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss +++ b/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss @@ -38,11 +38,11 @@ letter-spacing: 0.2px; &.top { - color: #3c4043; + color: $grey-800; } &.bottom { - color: #5f6368; + color: $grey-700; } } diff --git a/modules/ui/src/app/pages/settings/general-settings.component.html b/modules/ui/src/app/pages/settings/general-settings.component.html index 2e77950a6..36849b42e 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.html +++ b/modules/ui/src/app/pages/settings/general-settings.component.html @@ -25,6 +25,7 @@

System settings

+
System settings [options]="vm.internetOptions"> -

+

If a port is missing from this list, you can Refresh @@ -105,7 +107,9 @@

System settings

class="save-button" color="primary" (click)="saveSetting()" - [disabled]="!isFormValues || vm.isLessThanOneInterface"> + [disabled]=" + !isFormValues || vm.isLessThanOneInterface || settingsDisable + "> Save
diff --git a/modules/ui/src/app/pages/settings/general-settings.component.scss b/modules/ui/src/app/pages/settings/general-settings.component.scss index c1f2e259c..6bec40683 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.scss +++ b/modules/ui/src/app/pages/settings/general-settings.component.scss @@ -119,3 +119,25 @@ } } } + +.settings-disabled-overlay { + position: absolute; + width: 100%; + left: 0; + right: 0; + top: 75px; + bottom: 45px; + background-color: rgba(255, 255, 255, 0.7); + z-index: 2; +} + +.disabled { + .message-link { + cursor: default; + pointer-events: none; + + &:focus-visible { + outline: none; + } + } +} diff --git a/modules/ui/src/app/pages/settings/general-settings.component.spec.ts b/modules/ui/src/app/pages/settings/general-settings.component.spec.ts index 097cc2f0e..8095468fd 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.spec.ts +++ b/modules/ui/src/app/pages/settings/general-settings.component.spec.ts @@ -116,6 +116,47 @@ describe('GeneralSettingsComponent', () => { expect(mockLoaderService.setLoading).toHaveBeenCalledWith(true); }); + describe('#settingsDisable', () => { + it('should disable setting form when get settingDisable as true ', () => { + spyOn(component.settingForm, 'disable'); + + component.settingsDisable = true; + + expect(component.settingForm.disable).toHaveBeenCalled(); + }); + + it('should enable setting form when get settingDisable as false ', () => { + spyOn(component.settingForm, 'enable'); + + component.settingsDisable = false; + + expect(component.settingForm.enable).toHaveBeenCalled(); + }); + + it('should disable "Save" button when get settingDisable as true', () => { + component.settingsDisable = true; + + const saveBtn = compiled.querySelector( + '.save-button' + ) as HTMLButtonElement; + + expect(saveBtn.disabled).toBeTrue(); + }); + + it('should disable "Refresh" link when settingDisable', () => { + component.settingsDisable = true; + + const refreshLink = compiled.querySelector( + '.message-link' + ) as HTMLAnchorElement; + + refreshLink.click(); + + expect(refreshLink.hasAttribute('aria-disabled')).toBeTrue(); + expect(mockLoaderService.setLoading).not.toHaveBeenCalled(); + }); + }); + describe('#closeSetting', () => { beforeEach(() => { component.ngOnInit(); diff --git a/modules/ui/src/app/pages/settings/general-settings.component.ts b/modules/ui/src/app/pages/settings/general-settings.component.ts index c4244e4c4..7c903207c 100644 --- a/modules/ui/src/app/pages/settings/general-settings.component.ts +++ b/modules/ui/src/app/pages/settings/general-settings.component.ts @@ -15,10 +15,13 @@ */ import { Component, + ElementRef, EventEmitter, + Input, OnDestroy, OnInit, Output, + ViewChild, } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { Subject, takeUntil, tap } from 'rxjs'; @@ -38,7 +41,17 @@ import { LoaderService } from '../../services/loader.service'; providers: [SettingsStore], }) export class GeneralSettingsComponent implements OnInit, OnDestroy { + @ViewChild('reloadSettingLink') public reloadSettingLink!: ElementRef; @Output() closeSettingEvent = new EventEmitter(); + + private isSettingsDisable = false; + get settingsDisable(): boolean { + return this.isSettingsDisable; + } + @Input() set settingsDisable(value: boolean) { + this.isSettingsDisable = value; + value ? this.disableSettings() : this.enableSettings(); + } public readonly CalloutType = CalloutType; public readonly EventType = EventType; public readonly FormKey = FormKey; @@ -88,6 +101,9 @@ export class GeneralSettingsComponent implements OnInit, OnDestroy { } reloadSetting(): void { + if (this.settingsDisable) { + return; + } this.showLoading(); this.getSystemInterfaces(); this.settingsStore.getSystemConfig(); @@ -111,6 +127,16 @@ export class GeneralSettingsComponent implements OnInit, OnDestroy { } } + private disableSettings(): void { + this.settingForm?.disable(); + this.reloadSettingLink?.nativeElement.setAttribute('aria-disabled', 'true'); + } + + private enableSettings(): void { + this.settingForm?.enable(); + this.reloadSettingLink?.nativeElement.removeAttribute('aria-disabled'); + } + private createSettingForm() { this.settingForm = this.fb.group( { diff --git a/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.spec.ts b/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.spec.ts index c28c453c1..42d084a5c 100644 --- a/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.spec.ts @@ -19,15 +19,24 @@ import { DownloadOption, DownloadOptionsComponent, } from './download-options.component'; -import { MOCK_PROGRESS_DATA_COMPLIANT } from '../../../../mocks/progress.mock'; +import { + MOCK_PROGRESS_DATA_CANCELLED, + MOCK_PROGRESS_DATA_COMPLIANT, + MOCK_PROGRESS_DATA_NON_COMPLIANT, +} from '../../../../mocks/progress.mock'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatOptionSelectionChange } from '@angular/material/core'; +interface GAEvent { + event: string; +} describe('DownloadOptionsComponent', () => { let component: DownloadOptionsComponent; let fixture: ComponentFixture; beforeEach(async () => { + // @ts-expect-error data layer should be defined + window.dataLayer = window.dataLayer || []; await TestBed.configureTestingModule({ imports: [DownloadOptionsComponent, NoopAnimationsModule], }).compileComponents(); @@ -75,4 +84,78 @@ describe('DownloadOptionsComponent', () => { expect(spyGetZipLink).toHaveBeenCalled(); }); + + describe('#sendGAEvent', () => { + it('should send download_report_pdf when type is pdf', () => { + component.sendGAEvent(MOCK_PROGRESS_DATA_CANCELLED, DownloadOption.PDF); + + expect( + // @ts-expect-error data layer should be defined + window.dataLayer.some( + (item: GAEvent) => item.event === 'download_report_pdf' + ) + ).toBeTruthy(); + }); + + it('should send download_report_pdf_compliant when type is pdf and status is compliant', () => { + component.sendGAEvent(MOCK_PROGRESS_DATA_COMPLIANT, DownloadOption.PDF); + + expect( + // @ts-expect-error data layer should be defined + window.dataLayer.some( + (item: GAEvent) => item.event === 'download_report_pdf_compliant' + ) + ).toBeTruthy(); + }); + + it('should send download_report_pdf_non_compliant when type is pdf and status is not compliant', () => { + component.sendGAEvent( + MOCK_PROGRESS_DATA_NON_COMPLIANT, + DownloadOption.PDF + ); + + expect( + // @ts-expect-error data layer should be defined + window.dataLayer.some( + (item: GAEvent) => item.event === 'download_report_pdf_non_compliant' + ) + ).toBeTruthy(); + }); + + it('should send download_report_zip when type is zip', () => { + component.sendGAEvent(MOCK_PROGRESS_DATA_CANCELLED, DownloadOption.ZIP); + + expect( + // @ts-expect-error data layer should be defined + window.dataLayer.some( + (item: GAEvent) => item.event === 'download_report_zip' + ) + ).toBeTruthy(); + }); + + it('should send download_report_zip_compliant when type is pdf and status is compliant', () => { + component.sendGAEvent(MOCK_PROGRESS_DATA_COMPLIANT, DownloadOption.ZIP); + + expect( + // @ts-expect-error data layer should be defined + window.dataLayer.some( + (item: GAEvent) => item.event === 'download_report_zip_compliant' + ) + ).toBeTruthy(); + }); + + it('should send download_report_zip_non_compliant when type is zip and status is not compliant', () => { + component.sendGAEvent( + MOCK_PROGRESS_DATA_NON_COMPLIANT, + DownloadOption.ZIP + ); + + expect( + // @ts-expect-error data layer should be defined + window.dataLayer.some( + (item: GAEvent) => item.event === 'download_report_zip_non_compliant' + ) + ).toBeTruthy(); + }); + }); }); diff --git a/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts b/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts index 847feb30e..58d61947f 100644 --- a/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts +++ b/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts @@ -19,7 +19,10 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { CommonModule, DatePipe } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; -import { TestrunStatus } from '../../../../model/testrun-status'; +import { + StatusOfTestrun, + TestrunStatus, +} from '../../../../model/testrun-status'; import { MatOptionSelectionChange } from '@angular/material/core'; export enum DownloadOption { @@ -53,6 +56,7 @@ export class DownloadOptionsComponent { ) { if (event.isUserInput) { this.createLink(data, type); + this.sendGAEvent(data, type); } } @@ -83,4 +87,17 @@ export class DownloadOptionsComponent { getFormattedDateString(date: string | null) { return date ? this.datePipe.transform(date, 'd MMM y H:mm') : ''; } + + sendGAEvent(data: TestrunStatus, type: string) { + let event = `download_report_${type === DownloadOption.PDF ? 'pdf' : 'zip'}`; + if (data.status === StatusOfTestrun.Compliant) { + event += '_compliant'; + } else if (data.status === StatusOfTestrun.NonCompliant) { + event += '_non_compliant'; + } + // @ts-expect-error data layer is not null + window.dataLayer.push({ + event: event, + }); + } } diff --git a/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html b/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html index 0cbb2d3d3..02fa4f2f8 100644 --- a/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html +++ b/modules/ui/src/app/pages/testrun/components/progress-initiate-form/progress-initiate-form.component.html @@ -60,10 +60,12 @@ + + {{ error$ | async }} + @@ -76,7 +78,7 @@ type="button"> Change Device - - - - - - - -
+ + - - -
- - - + +
+
+ +
+
+

+ {{ data.device.manufacturer }} {{ data.device.model }} v{{ + data.device.firmware + }} +

+ + +
+
+ + +
+
+ + +
+
+ + + - - -
- -
-
+ +
+ +
+
- - - + + + + diff --git a/modules/ui/src/app/pages/testrun/progress.component.spec.ts b/modules/ui/src/app/pages/testrun/progress.component.spec.ts index 22c4fcc52..4c22e3cfd 100644 --- a/modules/ui/src/app/pages/testrun/progress.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/progress.component.spec.ts @@ -15,7 +15,6 @@ */ import { ComponentFixture, - discardPeriodicTasks, fakeAsync, TestBed, tick, @@ -25,26 +24,20 @@ import { ProgressComponent } from './progress.component'; import { TestRunService } from '../../services/test-run.service'; import { of } from 'rxjs'; import { - EMPTY_RESULT, MOCK_PROGRESS_DATA_CANCELLED, - MOCK_PROGRESS_DATA_CANCELLED_EMPTY, MOCK_PROGRESS_DATA_CANCELLING, MOCK_PROGRESS_DATA_COMPLIANT, MOCK_PROGRESS_DATA_IN_PROGRESS, - MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY, MOCK_PROGRESS_DATA_MONITORING, MOCK_PROGRESS_DATA_NOT_STARTED, MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE, - TEST_DATA_TABLE_RESULT, } from '../../mocks/progress.mock'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { Component, Input } from '@angular/core'; -import { Observable } from 'rxjs/internal/Observable'; import { IResult, TestrunStatus } from '../../model/testrun-status'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { ProgressInitiateFormComponent } from './components/progress-initiate-form/progress-initiate-form.component'; import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; import { SpinnerComponent } from '../../components/spinner/spinner.component'; @@ -52,7 +45,19 @@ import { LoaderService } from '../../services/loader.service'; import { FocusManagerService } from '../../services/focus-manager.service'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../../store/state'; -import { selectDevices, selectHasDevices } from '../../store/selectors'; +import { + selectDevices, + selectHasDevices, + selectIsOpenStartTestrun, + selectIsOpenWaitSnackBar, + selectIsStopTestrun, + selectIsTestrunStarted, + selectSystemStatus, +} from '../../store/selectors'; +import { TestrunStore } from './testrun.store'; +import { setTestrunStatus } from '../../store/actions'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NotificationService } from '../../services/notification.service'; describe('ProgressComponent', () => { let component: ProgressComponent; @@ -62,30 +67,34 @@ describe('ProgressComponent', () => { const testRunServiceMock: jasmine.SpyObj = jasmine.createSpyObj([ - 'getSystemStatus', - 'setSystemStatus', - 'systemStatus$', 'stopTestrun', 'getDevices', 'isOpenStartTestrun$', 'isTestrunStarted$', + 'fetchSystemStatus', + 'getTestModules', + 'testrunInProgress', ]); const loaderServiceMock: jasmine.SpyObj = jasmine.createSpyObj( ['setLoading', 'getLoading'] ); + const notificationServiceMock: jasmine.SpyObj = + jasmine.createSpyObj('NotificationService', [ + 'dismissWithTimout', + 'dismissSnackBar', + 'openSnackBar', + ]); + const stateServiceMock: jasmine.SpyObj = jasmine.createSpyObj('stateServiceMock', ['focusFirstElementInContainer']); - testRunServiceMock.isOpenStartTestrun$ = new BehaviorSubject(false); - testRunServiceMock.isTestrunStarted$ = new BehaviorSubject(false); - describe('Class tests', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); + // @ts-expect-error data layer should be defined + window.dataLayer = window.dataLayer || []; testRunServiceMock.stopTestrun.and.returnValue(of(true)); - TestBed.configureTestingModule({ declarations: [ ProgressComponent, @@ -94,16 +103,26 @@ describe('ProgressComponent', () => { FakeDownloadOptionsComponent, ], providers: [ + TestrunStore, { provide: TestRunService, useValue: testRunServiceMock }, { provide: FocusManagerService, useValue: stateServiceMock }, + { provide: LoaderService, useValue: loaderServiceMock }, + { provide: NotificationService, useValue: notificationServiceMock }, { provide: MatDialogRef, useValue: {}, }, provideMockStore({ selectors: [ - { selector: selectDevices, value: [] }, { selector: selectHasDevices, value: false }, + { selector: selectIsOpenStartTestrun, value: false }, + { selector: selectIsTestrunStarted, value: false }, + { selector: selectIsOpenWaitSnackBar, value: false }, + { selector: selectIsStopTestrun, value: false }, + { + selector: selectSystemStatus, + value: MOCK_PROGRESS_DATA_IN_PROGRESS, + }, ], }), ], @@ -113,6 +132,7 @@ describe('ProgressComponent', () => { MatToolbarModule, MatDialogModule, SpinnerComponent, + BrowserAnimationsModule, ], }) .overrideComponent(ProgressComponent, { @@ -124,22 +144,22 @@ describe('ProgressComponent', () => { }) .compileComponents(); + testRunServiceMock.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); store = TestBed.inject(MockStore); fixture = TestBed.createComponent(ProgressComponent); + spyOn(store, 'dispatch').and.callFake(() => {}); component = fixture.componentInstance; }); - afterEach(() => { - testRunServiceMock.getSystemStatus.calls.reset(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); describe('openTestRunModal on first flow', () => { beforeEach(() => { - testRunServiceMock.isOpenStartTestrun$ = new BehaviorSubject(true); + store.overrideSelector(selectIsOpenStartTestrun, true); component.ngOnInit(); }); @@ -166,12 +186,14 @@ describe('ProgressComponent', () => { }); it('should update system status to Cancelling', () => { - component.currentStatus = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; - + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); component.stopTestrun(); - expect(testRunServiceMock.setSystemStatus).toHaveBeenCalledWith( - MOCK_PROGRESS_DATA_CANCELLING + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ systemStatus: MOCK_PROGRESS_DATA_CANCELLING }) ); }); }); @@ -182,134 +204,17 @@ describe('ProgressComponent', () => { afterClosed: () => of(true), } as MatDialogRef); - component.openStopTestrunDialog(); + component.openStopTestrunDialog(MOCK_PROGRESS_DATA_CANCELLING); expect(stopTestrunSpy).toHaveBeenCalled(); }); describe('#ngOnInit', () => { - it('should set systemStatus$ value', () => { + it('should get systemStatus value', () => { + const spyOpenSetting = spyOn(component.testrunStore, 'getStatus'); component.ngOnInit(); - component.systemStatus$.subscribe(res => { - expect(res).toEqual(MOCK_PROGRESS_DATA_IN_PROGRESS); - }); - }); - - it('should set hasDevices$ value', () => { - component.ngOnInit(); - - component.hasDevices$.subscribe(res => { - expect(res).toEqual(false); - }); - }); - - describe('dataSource$', () => { - it('should set value with empty values if result length < total for status "In Progress"', () => { - const expectedResult = TEST_DATA_TABLE_RESULT; - - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - - it('should set value with empty values for status "Monitoring"', () => { - const expectedResult = EMPTY_RESULT; - - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_MONITORING); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - - it('should set value with empty values for status "Waiting for Device"', () => { - const expectedResult = EMPTY_RESULT; - - testRunServiceMock.systemStatus$ = of( - MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE - ); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - - it('should set value with empty values for status "Cancelled" and empty result', () => { - const expectedResult = EMPTY_RESULT; - - testRunServiceMock.systemStatus$ = of( - MOCK_PROGRESS_DATA_CANCELLED_EMPTY - ); - component.ngOnInit(); - - component.dataSource$.subscribe(res => { - expect(res).toEqual(expectedResult); - }); - }); - }); - - it('should call focusFirstElementInContainer when testrun stops after cancelling', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_COMPLIANT); - component.isCancelling = true; - - component.ngOnInit(); - fixture.detectChanges(); - - expect( - stateServiceMock.focusFirstElementInContainer - ).toHaveBeenCalled(); - }); - - describe('hideLoading', () => { - it('should called if testrun is finished', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_COMPLIANT); - - component.ngOnInit(); - - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); - }); - }); - - it('should called if testrun is in progress and have some test finished', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); - - component.ngOnInit(); - - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); - }); - }); - }); - - describe('showLoading', () => { - it('should be called if testrun is monitoring', () => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_MONITORING); - - component.ngOnInit(); - - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); - }); - }); - - it('should be called if testrun is in progress and have some test finished', () => { - testRunServiceMock.systemStatus$ = of( - MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY - ); - - component.ngOnInit(); - - component.systemStatus$.subscribe(() => { - expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); - }); - }); + expect(spyOpenSetting).toHaveBeenCalled(); }); }); }); @@ -317,6 +222,9 @@ describe('ProgressComponent', () => { describe('DOM tests', () => { beforeEach(async () => { testRunServiceMock.stopTestrun.and.returnValue(of(true)); + testRunServiceMock.testrunInProgress.and.returnValue(false); + // @ts-expect-error data layer should be defined + window.dataLayer = window.dataLayer || []; await TestBed.configureTestingModule({ declarations: [ @@ -326,8 +234,11 @@ describe('ProgressComponent', () => { FakeDownloadOptionsComponent, ], providers: [ + TestrunStore, { provide: TestRunService, useValue: testRunServiceMock }, { provide: FocusManagerService, useValue: stateServiceMock }, + { provide: LoaderService, useValue: loaderServiceMock }, + { provide: NotificationService, useValue: notificationServiceMock }, { provide: MatDialogRef, useValue: {}, @@ -336,6 +247,8 @@ describe('ProgressComponent', () => { selectors: [ { selector: selectHasDevices, value: false }, { selector: selectDevices, value: [] }, + { selector: selectIsOpenWaitSnackBar, value: false }, + { selector: selectIsStopTestrun, value: false }, ], }), ], @@ -345,6 +258,7 @@ describe('ProgressComponent', () => { MatToolbarModule, MatDialogModule, SpinnerComponent, + BrowserAnimationsModule, ], }) .overrideComponent(ProgressComponent, { @@ -359,16 +273,15 @@ describe('ProgressComponent', () => { store = TestBed.inject(MockStore); fixture = TestBed.createComponent(ProgressComponent); compiled = fixture.nativeElement as HTMLElement; + testRunServiceMock.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); component = fixture.componentInstance; }); - afterEach(() => { - testRunServiceMock.getSystemStatus.calls.reset(); - }); - describe('with not devices$ data', () => { beforeEach(() => { - (testRunServiceMock.systemStatus$ as unknown) = of(null); + store.overrideSelector(selectSystemStatus, null); store.overrideSelector(selectHasDevices, false); fixture.detectChanges(); }); @@ -384,7 +297,7 @@ describe('ProgressComponent', () => { describe('with not systemStatus$ data', () => { beforeEach(() => { - (testRunServiceMock.systemStatus$ as unknown) = of(null); + store.overrideSelector(selectSystemStatus, null); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -435,7 +348,11 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, status "In Progress"', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); + testRunServiceMock.testrunInProgress.and.returnValue(true); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -488,21 +405,12 @@ describe('ProgressComponent', () => { }); }); - describe('pullingSystemStatusData with available status "In Progress"', () => { - it('should call again getSystemStatus)', fakeAsync(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_IN_PROGRESS); - store.overrideSelector(selectHasDevices, true); - fixture.detectChanges(); - tick(5000); - - expect(testRunServiceMock.getSystemStatus).toHaveBeenCalledTimes(1); - discardPeriodicTasks(); - })); - }); - describe('with available systemStatus$ data, as Completed', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_COMPLIANT); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_COMPLIANT + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -530,7 +438,10 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, as Cancelled', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_CANCELLED); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_CANCELLED + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -552,9 +463,11 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, as Waiting for Device', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of( + store.overrideSelector( + selectSystemStatus, MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE ); + testRunServiceMock.testrunInProgress.and.returnValue(true); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -576,7 +489,11 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, as Monitoring', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_MONITORING); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_MONITORING + ); + testRunServiceMock.testrunInProgress.and.returnValue(true); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -598,7 +515,10 @@ describe('ProgressComponent', () => { describe('with available systemStatus$ data, when Testrun not started on Idle status', () => { beforeEach(() => { - testRunServiceMock.systemStatus$ = of(MOCK_PROGRESS_DATA_NOT_STARTED); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_NOT_STARTED + ); store.overrideSelector(selectHasDevices, true); fixture.detectChanges(); }); @@ -621,7 +541,7 @@ describe('ProgressComponent', () => { template: '
', }) class FakeProgressStatusCardComponent { - @Input() systemStatus$!: Observable; + @Input() systemStatus!: TestrunStatus; } @Component({ @@ -629,7 +549,8 @@ class FakeProgressStatusCardComponent { template: '
', }) class FakeProgressTableComponent { - @Input() dataSource$!: Observable; + @Input() dataSource!: IResult[] | undefined; + @Input() stepsToResolveCount!: number; } @Component({ diff --git a/modules/ui/src/app/pages/testrun/progress.component.ts b/modules/ui/src/app/pages/testrun/progress.component.ts index 7a643de7a..a7a17d425 100644 --- a/modules/ui/src/app/pages/testrun/progress.component.ts +++ b/modules/ui/src/app/pages/testrun/progress.component.ts @@ -19,24 +19,12 @@ import { OnDestroy, OnInit, } from '@angular/core'; -import { Observable } from 'rxjs/internal/Observable'; -import { TestRunService } from '../../services/test-run.service'; import { - IResult, StatusOfTestrun, TestrunStatus, - TestsData, TestsResponse, } from '../../model/testrun-status'; -import { - interval, - map, - shareReplay, - Subject, - takeUntil, - tap, - timer, -} from 'rxjs'; +import { Subject, takeUntil, timer } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { ProgressInitiateFormComponent } from './components/progress-initiate-form/progress-initiate-form.component'; import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; @@ -44,11 +32,9 @@ import { LoaderService } from '../../services/loader.service'; import { LOADER_TIMEOUT_CONFIG_TOKEN } from '../../services/loaderConfig'; import { FocusManagerService } from '../../services/focus-manager.service'; import { combineLatest } from 'rxjs/internal/observable/combineLatest'; -import { Store } from '@ngrx/store'; -import { AppState } from '../../store/state'; -import { selectHasDevices } from '../../store/selectors'; - -const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); +import { TestrunStore } from './testrun.store'; +import { TestRunService } from '../../services/test-run.service'; +import { NotificationService } from '../../services/notification.service'; @Component({ selector: 'app-progress', @@ -58,34 +44,27 @@ const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); providers: [ LoaderService, { provide: LOADER_TIMEOUT_CONFIG_TOKEN, useValue: 0 }, + TestrunStore, ], }) export class ProgressComponent implements OnInit, OnDestroy { - public systemStatus$!: Observable; - public dataSource$!: Observable; - public hasDevices$!: Observable; public readonly StatusOfTestrun = StatusOfTestrun; - private destroy$: Subject = new Subject(); - private destroyInterval$: Subject = new Subject(); - private startInterval = false; - public currentStatus: TestrunStatus | null = null; - isCancelling = false; + viewModel$ = this.testrunStore.viewModel$; constructor( private readonly testRunService: TestRunService, - private readonly loaderService: LoaderService, + private readonly notificationService: NotificationService, public dialog: MatDialog, - private readonly state: FocusManagerService, - private store: Store + private readonly focusManagerService: FocusManagerService, + public testrunStore: TestrunStore ) {} ngOnInit(): void { - this.hasDevices$ = this.store.select(selectHasDevices); - + this.testrunStore.getStatus(); combineLatest([ - this.testRunService.isOpenStartTestrun$, - this.testRunService.isTestrunStarted$, + this.testrunStore.isOpenStartTestrun$, + this.testrunStore.isTestrunStarted$, ]) .pipe(takeUntil(this.destroy$)) .subscribe(([isOpenStartTestrun, isTestrunStarted]) => { @@ -94,94 +73,25 @@ export class ProgressComponent implements OnInit, OnDestroy { } }); - this.systemStatus$ = this.testRunService.systemStatus$.pipe( - tap(res => { - this.currentStatus = res; - if (this.testrunInProgress(res.status) && !this.startInterval) { - this.pullingSystemStatusData(); - } - if ( - res.status === StatusOfTestrun.WaitingForDevice || - res.status === StatusOfTestrun.Monitoring || - (res.status === StatusOfTestrun.InProgress && - this.resultIsEmpty(res.tests)) - ) { - this.showLoading(); - } - if ( - res.status === StatusOfTestrun.InProgress && - !this.resultIsEmpty(res.tests) - ) { - this.hideLoading(); - } - if ( - !this.testrunInProgress(res.status) && - res.status !== StatusOfTestrun.Cancelling - ) { - if (this.isCancelling) { - this.state.focusFirstElementInContainer(); - } - this.isCancelling = false; - this.destroyInterval$.next(true); - this.startInterval = false; - this.hideLoading(); - } - }), - shareReplay({ refCount: true, bufferSize: 1 }) - ); - - this.dataSource$ = this.systemStatus$.pipe( - map((res: TestrunStatus) => { - const results = (res.tests as TestsData)?.results || []; - if ( - res.status === StatusOfTestrun.Monitoring || - res.status === StatusOfTestrun.WaitingForDevice || - (res.status === StatusOfTestrun.Cancelled && !results.length) - ) { - return EMPTY_RESULT; - } - - const total = (res.tests as TestsData)?.total || 100; - if ( - res.status === StatusOfTestrun.InProgress && - results.length < total - ) { - return [ - ...results, - ...new Array(total - results.length) - .fill(null) - .map(() => ({}) as IResult), - ]; + this.testrunStore.isStopTestrun$ + .pipe(takeUntil(this.destroy$)) + .subscribe(isStop => { + if (isStop) { + this.stopTestrun(); + this.notificationService.dismissSnackBar(); } - - return results; - }) - ); - } - - testrunInProgress(status?: string): boolean { - return ( - status === StatusOfTestrun.InProgress || - status === StatusOfTestrun.WaitingForDevice || - status === StatusOfTestrun.Monitoring - ); + }); } - private pullingSystemStatusData(): void { - this.startInterval = true; - interval(5000) - .pipe( - takeUntil(this.destroyInterval$), - tap(() => this.testRunService.getSystemStatus(this.isCancelling)) - ) - .subscribe(); + isTestrunInProgress(status?: string) { + return this.testRunService.testrunInProgress(status); } - public openStopTestrunDialog() { + public openStopTestrunDialog(systemStatus: TestrunStatus) { const dialogRef = this.dialog.open(DeleteFormComponent, { - ariaLabel: `Stop testrun ${this.getTestRunName()}`, + ariaLabel: `Stop testrun ${this.getTestRunName(systemStatus)}`, data: { - title: `Stop testrun ${this.getTestRunName()}`, + title: `Stop testrun ${this.getTestRunName(systemStatus)}`, content: 'Are you sure you would like to stop testrun without a report generation?', }, @@ -207,41 +117,29 @@ export class ProgressComponent implements OnInit, OnDestroy { this.sendCloseRequest(); } - private getTestRunName(): string { - if (this.currentStatus?.device) { - const device = this.currentStatus.device; + private getTestRunName(systemStatus: TestrunStatus): string { + if (systemStatus?.device) { + const device = systemStatus.device; return `${device.manufacturer} ${device.model} v${device.firmware}`; } else { return ''; } } private setCancellingStatus() { - this.isCancelling = true; - if (this.currentStatus) { - this.currentStatus.status = StatusOfTestrun.Cancelling; - this.testRunService.setSystemStatus(this.currentStatus); - } + this.testrunStore.setCancellingStatus(); } private showLoading() { - this.loaderService.setLoading(true); + this.testrunStore.showLoading(); } - - private hideLoading() { - this.loaderService.setLoading(false); - } - private sendCloseRequest() { - this.testRunService - .stopTestrun() - .pipe(takeUntil(this.destroy$)) - .subscribe(); + this.testrunStore.stopTestrun(); } ngOnDestroy() { + this.notificationService.dismissSnackBar(); this.destroy$.next(true); this.destroy$.unsubscribe(); - this.destroyInterval$.next(true); - this.destroyInterval$.unsubscribe(); + this.testrunStore.destroyInterval(); } openTestRunModal(): void { @@ -256,19 +154,25 @@ export class ProgressComponent implements OnInit, OnDestroy { dialogRef ?.afterClosed() .pipe(takeUntil(this.destroy$)) - .subscribe(() => { + .subscribe((startTestrun: boolean) => { + if (startTestrun) { + // @ts-expect-error data layer is not null + window.dataLayer.push({ + event: 'successful_testrun_initiation', + }); + this.testrunStore.setIsTestrunStarted(true); + this.testrunStore.getStatus(); + } + this.testrunStore.setIsOpenStartTestrun(false); timer(10) .pipe(takeUntil(this.destroy$)) .subscribe(() => { - this.state.focusFirstElementInContainer(); + this.focusManagerService.focusFirstElementInContainer(); }); }); } resultIsEmpty(tests: TestsResponse | undefined) { - return ( - (tests as TestsData)?.results?.length === 0 || - (tests as IResult[])?.length === 0 - ); + return this.testrunStore.resultIsEmpty(tests); } } diff --git a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts new file mode 100644 index 000000000..7cd5de718 --- /dev/null +++ b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts @@ -0,0 +1,349 @@ +/* + * 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. + */ +import { TestRunService } from '../../services/test-run.service'; +import SpyObj = jasmine.SpyObj; +import { + discardPeriodicTasks, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { AppState } from '../../store/state'; +import { skip, take, of } from 'rxjs'; +import { + selectHasConnectionSettings, + selectHasDevices, + selectIsOpenStartTestrun, + selectIsOpenWaitSnackBar, + selectIsStopTestrun, + selectIsTestrunStarted, + selectSystemStatus, +} from '../../store/selectors'; +import { + setIsOpenStartTestrun, + setIsTestrunStarted, + setTestrunStatus, +} from '../../store/actions'; +import { TestrunStore } from './testrun.store'; +import { + EMPTY_RESULT, + MOCK_PROGRESS_DATA_CANCELLED_EMPTY, + MOCK_PROGRESS_DATA_CANCELLING, + MOCK_PROGRESS_DATA_COMPLIANT, + MOCK_PROGRESS_DATA_IN_PROGRESS, + MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY, + MOCK_PROGRESS_DATA_MONITORING, + MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE, + TEST_DATA_RESULT_WITH_RECOMMENDATIONS, + TEST_DATA_TABLE_RESULT, +} from '../../mocks/progress.mock'; +import { LoaderService } from '../../services/loader.service'; +import { NotificationService } from '../../services/notification.service'; + +describe('TestrunStore', () => { + let testrunStore: TestrunStore; + let mockService: SpyObj; + let store: MockStore; + const loaderServiceMock: jasmine.SpyObj = jasmine.createSpyObj( + 'loaderServiceMock', + ['setLoading', 'getLoading'] + ); + const notificationServiceMock: jasmine.SpyObj = + jasmine.createSpyObj('NotificationService', [ + 'dismissWithTimout', + 'openSnackBar', + ]); + + beforeEach(() => { + mockService = jasmine.createSpyObj('mockService', [ + 'stopTestrun', + 'fetchSystemStatus', + ]); + + TestBed.configureTestingModule({ + providers: [ + TestrunStore, + { provide: TestRunService, useValue: mockService }, + { provide: LoaderService, useValue: loaderServiceMock }, + { provide: NotificationService, useValue: notificationServiceMock }, + provideMockStore({ + selectors: [ + { selector: selectHasDevices, value: false }, + { selector: selectSystemStatus, value: null }, + { selector: selectIsTestrunStarted, value: true }, + { selector: selectHasConnectionSettings, value: true }, + { selector: selectIsOpenStartTestrun, value: false }, + { selector: selectIsOpenWaitSnackBar, value: false }, + { selector: selectIsStopTestrun, value: false }, + ], + }), + ], + }); + + testrunStore = TestBed.inject(TestrunStore); + store = TestBed.inject(MockStore); + spyOn(store, 'dispatch').and.callFake(() => {}); + }); + + it('should be created', () => { + expect(testrunStore).toBeTruthy(); + }); + + describe('selectors', () => { + it('should select state', done => { + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store).toEqual({ + hasDevices: false, + systemStatus: null, + dataSource: undefined, + stepsToResolveCount: 0, + isCancelling: false, + startInterval: false, + }); + done(); + }); + }); + }); + + describe('updaters', () => { + it('should update dataSource and stepsToResolveCount', (done: DoneFn) => { + const dataSource = [...TEST_DATA_RESULT_WITH_RECOMMENDATIONS]; + + testrunStore.viewModel$.pipe(skip(2), take(1)).subscribe(store => { + expect(store.dataSource).toEqual(dataSource); + expect(store.stepsToResolveCount).toEqual(1); + done(); + }); + + testrunStore.setDataSource(dataSource); + }); + + it('should update isCancelling', (done: DoneFn) => { + testrunStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.isCancelling).toEqual(true); + done(); + }); + + testrunStore.updateCancelling(true); + }); + + it('should update startInterval', (done: DoneFn) => { + testrunStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.startInterval).toEqual(true); + done(); + }); + + testrunStore.updateStartInterval(true); + }); + }); + + describe('effects', () => { + describe('getStatus', () => { + beforeEach(() => { + testrunStore.updateStartInterval(true); + mockService.fetchSystemStatus.and.returnValue( + of({ ...MOCK_PROGRESS_DATA_MONITORING }) + ); + }); + + it('should dispatch action setTestrunStatus', () => { + testrunStore.getStatus(); + + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ + systemStatus: { ...MOCK_PROGRESS_DATA_MONITORING }, + }) + ); + }); + + it('should change status to Cancelling if cancelling', () => { + testrunStore.updateCancelling(true); + testrunStore.getStatus(); + + expect(store.dispatch).toHaveBeenCalledWith( + setTestrunStatus({ systemStatus: MOCK_PROGRESS_DATA_CANCELLING }) + ); + }); + + describe('pullingSystemStatusData with available status "In Progress"', () => { + beforeEach(() => { + mockService.fetchSystemStatus.and.returnValue( + of({ ...MOCK_PROGRESS_DATA_IN_PROGRESS }) + ); + mockService.fetchSystemStatus.calls.reset(); + }); + + it('should call again getSystemStatus', fakeAsync(() => { + testrunStore.updateStartInterval(false); + testrunStore.updateCancelling(false); + store.overrideSelector( + selectSystemStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS + ); + + testrunStore.getStatus(); + expect(mockService.fetchSystemStatus).toHaveBeenCalled(); + + tick(5000); + + expect(mockService.fetchSystemStatus).toHaveBeenCalledTimes(2); + discardPeriodicTasks(); + })); + }); + + describe('dataSource', () => { + it('should set value with empty values if result length < total for status "In Progress"', done => { + const expectedResult = TEST_DATA_TABLE_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + it('should set value with empty values for status "Monitoring"', done => { + const expectedResult = EMPTY_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_MONITORING) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + it('should set value with empty values for status "Waiting for Device"', done => { + const expectedResult = EMPTY_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + it('should set value with empty values for status "Cancelled" and empty result', done => { + const expectedResult = EMPTY_RESULT; + + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_CANCELLED_EMPTY) + ); + testrunStore.getStatus(); + + testrunStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource).toEqual(expectedResult); + done(); + }); + }); + + describe('hideLoading', () => { + it('should called if testrun is finished', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_COMPLIANT) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); + }); + + it('should called if testrun is in progress and have some test finished', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(false); + }); + }); + + describe('showLoading', () => { + it('should be called if testrun is monitoring', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_MONITORING) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); + }); + + it('should be called if testrun is in progress and have some test finished', () => { + mockService.fetchSystemStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY) + ); + testrunStore.getStatus(); + + expect(loaderServiceMock.setLoading).toHaveBeenCalledWith(true); + }); + }); + }); + }); + + describe('stopTestrun', () => { + beforeEach(() => { + mockService.stopTestrun.and.returnValue(of(true)); + }); + + it('should call stopTestrun', () => { + testrunStore.stopTestrun(); + + expect(mockService.stopTestrun).toHaveBeenCalled(); + }); + }); + + describe('setIsOpenStartTestrun', () => { + it('should dispatch action setIsOpenStartTestrun', () => { + testrunStore.setIsOpenStartTestrun(true); + + expect(store.dispatch).toHaveBeenCalledWith( + setIsOpenStartTestrun({ isOpenStartTestrun: true }) + ); + }); + }); + + describe('setIsTestrunStarted', () => { + it('should dispatch action setIsTestrunStarted', () => { + testrunStore.setIsTestrunStarted(true); + + expect(store.dispatch).toHaveBeenCalledWith( + setIsTestrunStarted({ isTestrunStarted: true }) + ); + }); + }); + + describe('setCancellingStatus', () => { + it('should update state', done => { + testrunStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.isCancelling).toEqual(true); + done(); + }); + testrunStore.setCancellingStatus(); + }); + }); + }); +}); diff --git a/modules/ui/src/app/pages/testrun/testrun.store.ts b/modules/ui/src/app/pages/testrun/testrun.store.ts new file mode 100644 index 000000000..6b5392355 --- /dev/null +++ b/modules/ui/src/app/pages/testrun/testrun.store.ts @@ -0,0 +1,316 @@ +/* + * 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. + */ + +import { Injectable } from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { TestRunService } from '../../services/test-run.service'; +import { exhaustMap, interval, Subject, take, timer } from 'rxjs'; +import { tap, withLatestFrom } from 'rxjs/operators'; +import { AppState } from '../../store/state'; +import { Store } from '@ngrx/store'; +import { + selectHasDevices, + selectIsOpenStartTestrun, + selectIsOpenWaitSnackBar, + selectIsStopTestrun, + selectIsTestrunStarted, + selectSystemStatus, +} from '../../store/selectors'; +import { + setIsOpenStartTestrun, + setIsTestrunStarted, + setTestrunStatus, +} from '../../store/actions'; +import { + IResult, + StatusOfTestrun, + TestrunStatus, + TestsData, + TestsResponse, +} from '../../model/testrun-status'; +import { takeUntil } from 'rxjs/internal/operators/takeUntil'; +import { FocusManagerService } from '../../services/focus-manager.service'; +import { LoaderService } from '../../services/loader.service'; +import { NotificationService } from '../../services/notification.service'; + +const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); +const WAIT_TO_OPEN_SNACKBAR_MS = 60 * 1000; + +export interface TestrunComponentState { + dataSource: IResult[] | undefined; + isCancelling: boolean; + startInterval: boolean; + stepsToResolveCount: number; +} + +@Injectable() +export class TestrunStore extends ComponentStore { + private destroyInterval$: Subject = new Subject(); + private destroyWaitDeviceInterval$: Subject = new Subject(); + private dataSource$ = this.select(state => state.dataSource); + private isCancelling$ = this.select(state => state.isCancelling); + private startInterval$ = this.select(state => state.startInterval); + private stepsToResolveCount$ = this.select( + state => state.stepsToResolveCount + ); + private hasDevices$ = this.store.select(selectHasDevices); + private systemStatus$ = this.store.select(selectSystemStatus); + isTestrunStarted$ = this.store.select(selectIsTestrunStarted); + isStopTestrun$ = this.store.select(selectIsStopTestrun); + isOpenWaitSnackBar$ = this.store.select(selectIsOpenWaitSnackBar); + isOpenStartTestrun$ = this.store.select(selectIsOpenStartTestrun); + viewModel$ = this.select({ + hasDevices: this.hasDevices$, + systemStatus: this.systemStatus$, + dataSource: this.dataSource$, + stepsToResolveCount: this.stepsToResolveCount$, + isCancelling: this.isCancelling$, + startInterval: this.startInterval$, + }); + + setDataSource = this.updater((state, dataSource: IResult[] | undefined) => { + const stepsToResolveCount = + dataSource?.filter(result => result.recommendations).length || 0; + return { + ...state, + stepsToResolveCount, + dataSource, + }; + }); + + updateCancelling = this.updater((state, isCancelling: boolean) => { + return { + ...state, + isCancelling, + }; + }); + + updateStartInterval = this.updater((state, startInterval: boolean) => { + return { + ...state, + startInterval, + }; + }); + + getStatus = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.fetchSystemStatus().pipe( + withLatestFrom( + this.isCancelling$, + this.startInterval$, + this.isOpenWaitSnackBar$ + ), + // change status if cancelling in process + tap(([res, isCancelling]) => { + if (isCancelling && res.status !== StatusOfTestrun.Cancelled) { + res.status = StatusOfTestrun.Cancelling; + } + }), + // perform some additional actions + tap(([res, , startInterval, isOpenWaitSnackBar]) => { + this.store.dispatch(setTestrunStatus({ systemStatus: res })); + + if (this.testrunInProgress(res.status) && !startInterval) { + this.pullingSystemStatusData(); + } + if ( + res.status === StatusOfTestrun.WaitingForDevice && + !isOpenWaitSnackBar + ) { + this.showSnackBar(); + } + if ( + res.status !== StatusOfTestrun.WaitingForDevice && + isOpenWaitSnackBar + ) { + this.notificationService.dismissWithTimout(); + } + if ( + res.status === StatusOfTestrun.WaitingForDevice || + res.status === StatusOfTestrun.Monitoring || + (res.status === StatusOfTestrun.InProgress && + this.resultIsEmpty(res.tests)) + ) { + this.showLoading(); + } + if ( + res.status === StatusOfTestrun.InProgress && + !this.resultIsEmpty(res.tests) + ) { + this.hideLoading(); + } + if ( + !this.testrunInProgress(res.status) && + res.status !== StatusOfTestrun.Cancelling + ) { + this.updateCancelling(false); + this.destroyInterval$.next(true); + this.updateStartInterval(false); + this.hideLoading(); + } + }), + // update data source + tap(([res]) => { + const results = (res.tests as TestsData)?.results || []; + if ( + res.status === StatusOfTestrun.Monitoring || + res.status === StatusOfTestrun.WaitingForDevice || + (res.status === StatusOfTestrun.Cancelled && !results.length) + ) { + this.setDataSource(EMPTY_RESULT); + return; + } + + const total = (res.tests as TestsData)?.total || 100; + if ( + res.status === StatusOfTestrun.InProgress && + results.length < total + ) { + this.setDataSource([ + ...results, + ...new Array(total - results.length) + .fill(null) + .map(() => ({}) as IResult), + ]); + return; + } + + this.setDataSource(results); + }) + ); + }) + ); + }); + stopTestrun = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.stopTestrun(); + }) + ); + }); + + setIsOpenStartTestrun = this.effect(trigger$ => { + return trigger$.pipe( + tap(isOpenStartTestrun => { + this.store.dispatch(setIsOpenStartTestrun({ isOpenStartTestrun })); + }) + ); + }); + + setIsTestrunStarted = this.effect(trigger$ => { + return trigger$.pipe( + tap(isTestrunStarted => { + this.store.dispatch(setIsTestrunStarted({ isTestrunStarted })); + }) + ); + }); + + destroyInterval = this.effect(trigger$ => { + return trigger$.pipe( + tap(() => { + this.destroyInterval$.next(true); + this.destroyInterval$.unsubscribe(); + }) + ); + }); + + setCancellingStatus = this.effect(trigger$ => { + return trigger$.pipe( + withLatestFrom(this.systemStatus$), + tap(([, systemStatus]) => { + this.updateCancelling(true); + if (systemStatus) { + this.store.dispatch( + setTestrunStatus({ + systemStatus: this.getCancellingStatus(systemStatus), + }) + ); + } + }) + ); + }); + + resultIsEmpty(tests: TestsResponse | undefined) { + return ( + (tests as TestsData)?.results?.length === 0 || + (tests as IResult[])?.length === 0 + ); + } + + showLoading() { + this.loaderService.setLoading(true); + } + + private showSnackBar() { + timer(WAIT_TO_OPEN_SNACKBAR_MS) + .pipe( + take(1), + takeUntil(this.destroyWaitDeviceInterval$), + withLatestFrom(this.systemStatus$), + tap(([, systemStatus]) => { + if (systemStatus?.status === StatusOfTestrun.WaitingForDevice) { + this.notificationService.openSnackBar(); + this.destroyWaitDeviceInterval$.next(true); + } + }) + ) + .subscribe(); + } + + private pullingSystemStatusData(): void { + this.updateStartInterval(true); + interval(5000) + .pipe( + takeUntil(this.destroyInterval$), + tap(() => this.getStatus()) + ) + .subscribe(); + } + + private getCancellingStatus(systemStatus: TestrunStatus): TestrunStatus { + const status = Object.assign({}, systemStatus); + status.status = StatusOfTestrun.Cancelling; + return status; + } + + private testrunInProgress(status?: string): boolean { + return ( + status === StatusOfTestrun.InProgress || + status === StatusOfTestrun.WaitingForDevice || + status === StatusOfTestrun.Monitoring + ); + } + + private hideLoading() { + this.loaderService.setLoading(false); + } + + constructor( + private testRunService: TestRunService, + private notificationService: NotificationService, + private store: Store, + private readonly focusManagerService: FocusManagerService, + private readonly loaderService: LoaderService + ) { + super({ + isCancelling: false, + startInterval: false, + dataSource: undefined, + stepsToResolveCount: 0, + }); + } +} diff --git a/modules/ui/src/app/services/notification.service.spec.ts b/modules/ui/src/app/services/notification.service.spec.ts index 8ba6df88b..d8e6e0ca3 100644 --- a/modules/ui/src/app/services/notification.service.spec.ts +++ b/modules/ui/src/app/services/notification.service.spec.ts @@ -22,20 +22,30 @@ import { TextOnlySnackBar, } from '@angular/material/snack-bar'; import { of } from 'rxjs/internal/observable/of'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { AppState } from '../store/state'; +import { SnackBarComponent } from '../components/snack-bar/snack-bar.component'; describe('NotificationService', () => { let service: NotificationService; + let store: MockStore; const mockMatSnackBar = { open: () => ({}), dismiss: () => ({}), + openFromComponent: () => ({}), }; beforeEach(() => { TestBed.configureTestingModule({ - providers: [{ provide: MatSnackBar, useValue: mockMatSnackBar }], + providers: [ + { provide: MatSnackBar, useValue: mockMatSnackBar }, + provideMockStore({}), + ], }); service = TestBed.inject(NotificationService); + store = TestBed.inject(MockStore); + spyOn(store, 'dispatch').and.callFake(() => {}); }); it('should be created', () => { @@ -76,6 +86,25 @@ describe('NotificationService', () => { }); }); + describe('openSnackBar', () => { + it('should open snackbar fromComponent', () => { + const openSpy = spyOn( + service.snackBar, + 'openFromComponent' + ).and.returnValues({ + afterOpened: () => of(void 0), + afterDismissed: () => of({ dismissedByAction: true }), + } as MatSnackBarRef); + + service.openSnackBar(); + + expect(openSpy).toHaveBeenCalledWith(SnackBarComponent, { + duration: 0, + panelClass: 'snack-bar-info', + }); + }); + }); + describe('dismiss', () => { it('should close snackbar', () => { const matSnackBarSpy = spyOn(mockMatSnackBar, 'dismiss').and.stub(); diff --git a/modules/ui/src/app/services/notification.service.ts b/modules/ui/src/app/services/notification.service.ts index 3a068b4e7..0cc6d4927 100644 --- a/modules/ui/src/app/services/notification.service.ts +++ b/modules/ui/src/app/services/notification.service.ts @@ -23,23 +23,32 @@ import { import { FocusManagerService } from './focus-manager.service'; import { delay } from 'rxjs/internal/operators/delay'; import { take } from 'rxjs/internal/operators/take'; +import { SnackBarComponent } from '../components/snack-bar/snack-bar.component'; +import { timer } from 'rxjs'; +import { setIsOpenWaitSnackBar } from '../store/actions'; +import { Store } from '@ngrx/store'; +import { AppState } from '../store/state'; const TIMEOUT_MS = 8000; +const WAIT_DISMISS_TIMEOUT_MS = 5000; @Injectable({ providedIn: 'root', }) export class NotificationService { private snackBarRef!: MatSnackBarRef; + public snackBarCompRef!: MatSnackBarRef; + constructor( public snackBar: MatSnackBar, + private store: Store, private focusManagerService: FocusManagerService ) {} - notify(message: string, duration = 0) { + notify(message: string, duration = 0, panelClass = 'test-run-notification') { this.snackBarRef = this.snackBar.open(message, 'OK', { horizontalPosition: 'center', - panelClass: 'test-run-notification', + panelClass: panelClass, duration: duration, politeness: 'assertive', }); @@ -57,10 +66,39 @@ export class NotificationService { dismiss() { this.snackBar.dismiss(); } + openSnackBar() { + this.snackBarCompRef = this.snackBar.openFromComponent(SnackBarComponent, { + duration: 0, + panelClass: 'snack-bar-info', + }); + + this.store.dispatch(setIsOpenWaitSnackBar({ isOpenWaitSnackBar: true })); + + this.snackBarCompRef + .afterOpened() + .pipe(take(1), delay(TIMEOUT_MS)) + .subscribe(() => this.setFocusToActionButton()); + + this.snackBarCompRef + .afterDismissed() + .pipe(take(1)) + .subscribe(() => this.focusManagerService.focusFirstElementInContainer()); + } + + dismissSnackBar() { + this.snackBarCompRef?.dismiss(); + this.store.dispatch(setIsOpenWaitSnackBar({ isOpenWaitSnackBar: false })); + } + + dismissWithTimout() { + timer(WAIT_DISMISS_TIMEOUT_MS) + .pipe(take(1)) + .subscribe(() => this.dismissSnackBar()); + } private setFocusToActionButton(): void { const btn = document.querySelector( - '.test-run-notification button' + '.test-run-notification button, .snack-bar-info button' ) as HTMLButtonElement; btn?.focus(); } diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts index ab9a338ad..32df1b5db 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -22,13 +22,18 @@ import { Device, TestModule } from '../model/device'; import { TestRunService, UNAVAILABLE_VERSION } from './test-run.service'; import { SystemConfig, SystemInterfaces } from '../model/setting'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../mocks/progress.mock'; import { - MOCK_PROGRESS_DATA_CANCELLING, - MOCK_PROGRESS_DATA_IN_PROGRESS, -} from '../mocks/progress.mock'; -import { StatusOfTestResult, TestrunStatus } from '../model/testrun-status'; + StatusOfTestResult, + StatusOfTestrun, + TestrunStatus, +} from '../model/testrun-status'; import { device } from '../mocks/device.mock'; import { NEW_VERSION, VERSION } from '../mocks/version.mock'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { AppState } from '../store/state'; +import { Certificate } from '../model/certificate'; +import { certificate } from '../mocks/certificate.mock'; const MOCK_SYSTEM_CONFIG: SystemConfig = { network: { @@ -41,15 +46,18 @@ describe('TestRunService', () => { let injector: TestBed; let httpTestingController: HttpTestingController; let service: TestRunService; + let store: MockStore; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [TestRunService], + providers: [TestRunService, provideMockStore({})], }); injector = getTestBed(); httpTestingController = injector.get(HttpTestingController); service = injector.get(TestRunService); + store = TestBed.inject(MockStore); + spyOn(store, 'dispatch').and.callFake(() => {}); }); afterEach(() => { @@ -155,30 +163,14 @@ describe('TestRunService', () => { req.flush(mockSystemInterfaces); }); - describe('getSystemStatus', () => { + describe('fetchSystemStatus', () => { it('should get system status data with no changes', () => { const result = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; - service.systemStatus$.subscribe(res => { + service.fetchSystemStatus().subscribe(res => { expect(res).toEqual(result); }); - service.getSystemStatus(); - const req = httpTestingController.expectOne( - 'http://localhost:8000/system/status' - ); - expect(req.request.method).toBe('GET'); - req.flush(result); - }); - - it('should get cancelling data if status is cancelling', () => { - const result = { ...MOCK_PROGRESS_DATA_IN_PROGRESS }; - - service.systemStatus$.subscribe(res => { - expect(res).toEqual(MOCK_PROGRESS_DATA_CANCELLING); - }); - - service.getSystemStatus(true); const req = httpTestingController.expectOne( 'http://localhost:8000/system/status' ); @@ -329,6 +321,37 @@ describe('TestRunService', () => { }); }); + describe('#testrunInProgress', () => { + const resultsInProgress = [ + StatusOfTestrun.InProgress, + StatusOfTestrun.WaitingForDevice, + StatusOfTestrun.Monitoring, + ]; + + const resultsNotInProgress = [ + StatusOfTestrun.Idle, + StatusOfTestrun.Cancelled, + StatusOfTestrun.Compliant, + StatusOfTestrun.NonCompliant, + ]; + + resultsInProgress.forEach(testCase => { + it(`should return true if testrun result is "${testCase}"`, () => { + const result = service.testrunInProgress(testCase); + + expect(result).toBeTrue(); + }); + }); + + resultsNotInProgress.forEach(testCase => { + it(`should return false if testrun result is "${testCase}"`, () => { + const result = service.testrunInProgress(testCase); + + expect(result).toBeFalse(); + }); + }); + }); + it('deleteDevice should have necessary request data', () => { const apiUrl = 'http://localhost:8000/device'; @@ -409,6 +432,24 @@ describe('TestRunService', () => { req.flush({}); }); + it('deleteReport should return false when error happens', () => { + const apiUrl = 'http://localhost:8000/report'; + + service.deleteReport(device.mac_addr, '').subscribe(res => { + expect(res).toEqual(false); + }); + + const req = httpTestingController.expectOne(apiUrl); + expect(req.request.method).toBe('DELETE'); + expect(req.request.body).toEqual( + JSON.stringify({ + mac_addr: device.mac_addr, + timestamp: '', + }) + ); + req.error(new ErrorEvent('')); + }); + it('#saveDevice should have necessary request data', () => { const apiUrl = 'http://localhost:8000/device'; @@ -436,4 +477,62 @@ describe('TestRunService', () => { ); req.flush(true); }); + + it('fetchCertificates should return certificates', () => { + const certificates = [certificate] as Certificate[]; + + service.fetchCertificates().subscribe(res => { + expect(res).toEqual(certificates); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/system/config/certs/list' + ); + + expect(req.request.method).toBe('GET'); + + req.flush(certificates); + }); + + it('uploadCertificates should upload certificate', () => { + service.uploadCertificate(new File([], 'test')).subscribe(res => { + expect(res).toEqual(true); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/system/config/certs/upload' + ); + + expect(req.request.method).toBe('POST'); + + req.flush(true); + }); + + it('deleteCertificate should delete certificate', () => { + service.deleteCertificate('test').subscribe(res => { + expect(res).toEqual(true); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/system/config/certs/delete' + ); + + expect(req.request.method).toBe('DELETE'); + + req.flush(true); + }); + + it('deleteCertificate should return false when error happens', () => { + service.deleteCertificate('test').subscribe(res => { + expect(res).toEqual(false); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/system/config/certs/delete' + ); + + expect(req.request.method).toBe('DELETE'); + + req.error(new ErrorEvent('')); + }); }); diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts index f23ddb5af..ab751123c 100644 --- a/modules/ui/src/app/services/test-run.service.ts +++ b/modules/ui/src/app/services/test-run.service.ts @@ -18,7 +18,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { Observable } from 'rxjs/internal/Observable'; import { Device, TestModule } from '../model/device'; -import { catchError, map, of, ReplaySubject, retry } from 'rxjs'; +import { catchError, map, of, retry } from 'rxjs'; import { SystemConfig, SystemInterfaces } from '../model/setting'; import { StatusOfTestResult, @@ -27,6 +27,7 @@ import { TestrunStatus, } from '../model/testrun-status'; import { Version } from '../model/version'; +import { Certificate } from '../model/certificate'; const API_URL = `http://${window.location.hostname}:8000`; export const SYSTEM_STOP = '/system/stop'; @@ -75,24 +76,10 @@ export class TestRunService { }, ]; - private isOpenStartTestrunSub$ = new BehaviorSubject(false); - public isOpenStartTestrun$ = this.isOpenStartTestrunSub$.asObservable(); - private systemStatusSubject = new ReplaySubject(1); - public systemStatus$ = this.systemStatusSubject.asObservable(); - private isTestrunStartedSub$ = new BehaviorSubject(false); - public isTestrunStarted$ = this.isTestrunStartedSub$.asObservable(); private version = new BehaviorSubject(null); constructor(private http: HttpClient) {} - setIsOpenStartTestrun(isOpen: boolean): void { - this.isOpenStartTestrunSub$.next(isOpen); - } - - setSystemStatus(status: TestrunStatus): void { - this.systemStatusSubject.next(status); - } - fetchDevices(): Observable { return this.http.get(`${API_URL}/devices`); } @@ -111,22 +98,8 @@ export class TestRunService { return this.http.get(`${API_URL}/system/interfaces`); } - /** - * Gets system status. - * Status Cancelling exist only on FE. Every status except Cancelled - * should be overriden with Cancelling value during cancelling process - * @param isCancelling - indicates if status should be overridden with Cancelling value - */ - getSystemStatus(isCancelling?: boolean): void { - this.http.get(`${API_URL}/system/status`).subscribe( - (res: TestrunStatus) => { - if (isCancelling && res.status !== StatusOfTestrun.Cancelled) { - res.status = StatusOfTestrun.Cancelling; - } - this.setSystemStatus(res); - }, - err => console.error('HTTP Error', err) - ); + fetchSystemStatus() { + return this.http.get(`${API_URL}/system/status`); } stopTestrun(): Observable { @@ -196,9 +169,15 @@ export class TestRunService { }; } - startTestrun(device: Device): Observable { - this.isTestrunStartedSub$.next(true); + testrunInProgress(status?: string): boolean { + return ( + status === StatusOfTestrun.InProgress || + status === StatusOfTestrun.WaitingForDevice || + status === StatusOfTestrun.Monitoring + ); + } + startTestrun(device: Device): Observable { return this.http .post( `${API_URL}/system/start`, @@ -232,6 +211,33 @@ export class TestRunService { .delete(`${API_URL}/report`, { body: JSON.stringify({ mac_addr, timestamp: started }), }) + .pipe( + catchError(() => of(false)), + map(res => !!res) + ); + } + + fetchCertificates(): Observable { + return this.http.get(`${API_URL}/system/config/certs/list`); + } + + deleteCertificate(name: string): Observable { + return this.http + .delete(`${API_URL}/system/config/certs/delete`, { + body: JSON.stringify({ name }), + }) + .pipe( + catchError(() => of(false)), + map(res => !!res) + ); + } + + uploadCertificate(file: File): Observable { + const formData: FormData = new FormData(); + formData.append('file', file, file.name); + formData.append('mode', 'file'); + return this.http + .post(`${API_URL}/system/config/certs/upload`, formData) .pipe(map(() => true)); } } diff --git a/modules/ui/src/app/store/actions.ts b/modules/ui/src/app/store/actions.ts index f4582b7d0..4a6081760 100644 --- a/modules/ui/src/app/store/actions.ts +++ b/modules/ui/src/app/store/actions.ts @@ -22,6 +22,7 @@ import { } from '../model/setting'; import { SystemInterfaces } from '../model/setting'; import { Device } from '../model/device'; +import { TestrunStatus } from '../model/testrun-status'; // App component export const toggleMenu = createAction('[App Component] Toggle Menu'); @@ -65,6 +66,16 @@ export const setIsOpenAddDevice = createAction( props<{ isOpenAddDevice: boolean }>() ); +export const setIsStopTestrun = createAction( + '[Shared] Set Is Stop Testrun', + props<{ isStopTestrun: boolean }>() +); + +export const setIsOpenWaitSnackBar = createAction( + '[Shared] Set Is Open WaitSnackBar', + props<{ isOpenWaitSnackBar: boolean }>() +); + export const setHasDevices = createAction( '[Shared] Set Has Devices', props<{ hasDevices: boolean }>() @@ -74,3 +85,23 @@ export const setDevices = createAction( '[Shared] Set Devices', props<{ devices: Device[] }>() ); + +export const setTestrunStatus = createAction( + '[Shared] Set Testrun Status', + props<{ systemStatus: TestrunStatus }>() +); + +export const setIsOpenStartTestrun = createAction( + '[Shared] Set Is Open Start Testrun', + props<{ isOpenStartTestrun: boolean }>() +); + +export const setIsTestrunStarted = createAction( + '[Shared] Set Testrun Started', + props<{ isTestrunStarted: boolean }>() +); + +export const setDeviceInProgress = createAction( + '[Shared] Set Device In Progress', + props<{ device: Device | null }>() +); diff --git a/modules/ui/src/app/store/effects.spec.ts b/modules/ui/src/app/store/effects.spec.ts index 819959c3f..2067cd4e6 100644 --- a/modules/ui/src/app/store/effects.spec.ts +++ b/modules/ui/src/app/store/effects.spec.ts @@ -25,6 +25,7 @@ import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from './state'; import { selectMenuOpened } from './selectors'; import { device } from '../mocks/device.mock'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../mocks/progress.mock'; describe('Effects', () => { let actions$ = new Observable(); let effects: AppEffects; @@ -55,6 +56,18 @@ describe('Effects', () => { store.refreshState(); }); + it('onSetDevices$ should call setDeviceInProgress when testrun in progress', done => { + const status = MOCK_PROGRESS_DATA_IN_PROGRESS; + actions$ = of(actions.setTestrunStatus({ systemStatus: status })); + + effects.onSetTestrunStatus$.subscribe(action => { + expect(action).toEqual( + actions.setDeviceInProgress({ device: status.device }) + ); + done(); + }); + }); + it('onSetDevices$ should call setHasDevices', done => { actions$ = of(actions.setDevices({ devices: [device] })); diff --git a/modules/ui/src/app/store/effects.ts b/modules/ui/src/app/store/effects.ts index f3b2c602a..c6a5ff499 100644 --- a/modules/ui/src/app/store/effects.ts +++ b/modules/ui/src/app/store/effects.ts @@ -24,6 +24,7 @@ import { AppState } from './state'; import { TestRunService } from '../services/test-run.service'; import { filter, combineLatest } from 'rxjs'; import { selectMenuOpened } from './selectors'; +import { StatusOfTestrun } from '../model/testrun-status'; @Injectable() export class AppEffects { @@ -95,6 +96,22 @@ export class AppEffects { ); }); + onSetTestrunStatus$ = createEffect(() => { + return this.actions$.pipe( + ofType(AppActions.setTestrunStatus), + map(({ systemStatus }) => + AppActions.setDeviceInProgress({ + device: + systemStatus.status === StatusOfTestrun.Monitoring || + systemStatus.status === StatusOfTestrun.InProgress || + systemStatus.status === StatusOfTestrun.WaitingForDevice + ? systemStatus.device + : null, + }) + ) + ); + }); + constructor( private actions$: Actions, private testrunService: TestRunService, diff --git a/modules/ui/src/app/store/reducers.spec.ts b/modules/ui/src/app/store/reducers.spec.ts index 3fccf462f..bea3b69f7 100644 --- a/modules/ui/src/app/store/reducers.spec.ts +++ b/modules/ui/src/app/store/reducers.spec.ts @@ -17,15 +17,22 @@ import * as fromReducer from './reducers'; import { initialAppComponentState, initialSharedState } from './state'; import { fetchInterfacesSuccess, + setDeviceInProgress, setDevices, setHasConnectionSettings, setHasDevices, setIsOpenAddDevice, + setIsOpenStartTestrun, + setIsOpenWaitSnackBar, + setIsStopTestrun, + setIsTestrunStarted, + setTestrunStatus, toggleMenu, updateError, updateFocusNavigation, } from './actions'; import { device } from '../mocks/device.mock'; +import { MOCK_PROGRESS_DATA_CANCELLING } from '../mocks/progress.mock'; describe('Reducer', () => { describe('unknown action', () => { @@ -126,6 +133,30 @@ describe('Reducer', () => { }); }); + describe('setIsStopTestrun action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setIsStopTestrun({ isStopTestrun: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ isStopTestrun: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setIsOpenWaitSnackBar action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setIsOpenWaitSnackBar({ isOpenWaitSnackBar: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ isOpenWaitSnackBar: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + describe('setHasDevices action', () => { it('should update state', () => { const initialState = initialSharedState; @@ -150,4 +181,61 @@ describe('Reducer', () => { expect(state).not.toBe(initialState); }); }); + + describe('setDeviceInProgress action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const deviceInProgress = device; + const action = setDeviceInProgress({ device: deviceInProgress }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ deviceInProgress: deviceInProgress }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setTestrunStatus action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setTestrunStatus({ + systemStatus: MOCK_PROGRESS_DATA_CANCELLING, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ systemStatus: MOCK_PROGRESS_DATA_CANCELLING }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setIsOpenStartTestrun action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setIsOpenStartTestrun({ isOpenStartTestrun: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ isOpenStartTestrun: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setIsTestrunStarted action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setIsTestrunStarted({ isTestrunStarted: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ isTestrunStarted: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); }); diff --git a/modules/ui/src/app/store/reducers.ts b/modules/ui/src/app/store/reducers.ts index fff777847..c7966eb85 100644 --- a/modules/ui/src/app/store/reducers.ts +++ b/modules/ui/src/app/store/reducers.ts @@ -53,6 +53,18 @@ export const sharedReducer = createReducer( isOpenAddDevice, }; }), + on(Actions.setIsStopTestrun, (state, { isStopTestrun }) => { + return { + ...state, + isStopTestrun, + }; + }), + on(Actions.setIsOpenWaitSnackBar, (state, { isOpenWaitSnackBar }) => { + return { + ...state, + isOpenWaitSnackBar, + }; + }), on(Actions.setHasDevices, (state, { hasDevices }) => { return { ...state, @@ -64,6 +76,30 @@ export const sharedReducer = createReducer( ...state, devices, }; + }), + on(Actions.setTestrunStatus, (state, { systemStatus }) => { + return { + ...state, + systemStatus, + }; + }), + on(Actions.setIsOpenStartTestrun, (state, { isOpenStartTestrun }) => { + return { + ...state, + isOpenStartTestrun, + }; + }), + on(Actions.setIsTestrunStarted, (state, { isTestrunStarted }) => { + return { + ...state, + isTestrunStarted, + }; + }), + on(Actions.setDeviceInProgress, (state, { device }) => { + return { + ...state, + deviceInProgress: device, + }; }) ); diff --git a/modules/ui/src/app/store/selectors.spec.ts b/modules/ui/src/app/store/selectors.spec.ts index 9443829b8..4b5a0f9eb 100644 --- a/modules/ui/src/app/store/selectors.spec.ts +++ b/modules/ui/src/app/store/selectors.spec.ts @@ -16,13 +16,19 @@ import { AppState } from './state'; import { + selectDeviceInProgress, selectDevices, selectError, selectHasConnectionSettings, selectHasDevices, selectInterfaces, selectIsOpenAddDevice, + selectIsOpenStartTestrun, + selectIsOpenWaitSnackBar, + selectIsStopTestrun, + selectIsTestrunStarted, selectMenuOpened, + selectSystemStatus, } from './selectors'; describe('Selectors', () => { @@ -40,6 +46,12 @@ describe('Selectors', () => { devices: [], hasDevices: false, isOpenAddDevice: false, + isStopTestrun: false, + isOpenWaitSnackBar: false, + isOpenStartTestrun: false, + systemStatus: null, + isTestrunStarted: false, + deviceInProgress: null, }, }; @@ -77,4 +89,34 @@ describe('Selectors', () => { const result = selectIsOpenAddDevice.projector(initialState); expect(result).toEqual(false); }); + + it('should select systemStatus', () => { + const result = selectSystemStatus.projector(initialState); + expect(result).toEqual(null); + }); + + it('should select isTestrunStarted', () => { + const result = selectIsTestrunStarted.projector(initialState); + expect(result).toEqual(false); + }); + + it('should select isOpenStartTestrun', () => { + const result = selectIsOpenStartTestrun.projector(initialState); + expect(result).toEqual(false); + }); + + it('should select isStopTestrun', () => { + const result = selectIsStopTestrun.projector(initialState); + expect(result).toEqual(false); + }); + + it('should select isOpenWaitSnackBar', () => { + const result = selectIsOpenWaitSnackBar.projector(initialState); + expect(result).toEqual(false); + }); + + it('should select deviceInProgress', () => { + const result = selectDeviceInProgress.projector(initialState); + expect(result).toEqual(null); + }); }); diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 29173bcc8..cac0e572e 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -52,7 +52,37 @@ export const selectDevices = createSelector( (state: AppState) => state.shared.devices ); +export const selectDeviceInProgress = createSelector( + selectAppState, + (state: AppState) => state.shared.deviceInProgress +); + export const selectError = createSelector( selectAppState, (state: AppState) => state.appComponent.settingMissedError ); + +export const selectSystemStatus = createSelector( + selectAppState, + (state: AppState) => state.shared.systemStatus +); + +export const selectIsTestrunStarted = createSelector( + selectAppState, + (state: AppState) => state.shared.isTestrunStarted +); + +export const selectIsStopTestrun = createSelector( + selectAppState, + (state: AppState) => state.shared.isStopTestrun +); + +export const selectIsOpenWaitSnackBar = createSelector( + selectAppState, + (state: AppState) => state.shared.isOpenWaitSnackBar +); + +export const selectIsOpenStartTestrun = createSelector( + selectAppState, + (state: AppState) => state.shared.isOpenStartTestrun +); diff --git a/modules/ui/src/app/store/state.ts b/modules/ui/src/app/store/state.ts index 0693cdfd4..341ee3720 100644 --- a/modules/ui/src/app/store/state.ts +++ b/modules/ui/src/app/store/state.ts @@ -39,13 +39,18 @@ export interface SharedState { //used in app, devices, testrun hasDevices: boolean; //app, testrun - systemStatus?: TestrunStatus | null; + systemStatus: TestrunStatus | null; //app, testrun - isTestrunStarted?: boolean; + isTestrunStarted: boolean; //app, settings hasConnectionSettings: boolean | null; // app, devices isOpenAddDevice: boolean; + // app, testrun + isOpenStartTestrun: boolean; + isStopTestrun: boolean; + isOpenWaitSnackBar: boolean; + deviceInProgress: Device | null; } export const initialAppComponentState: AppComponentState = { @@ -60,6 +65,12 @@ export const initialAppComponentState: AppComponentState = { export const initialSharedState: SharedState = { hasConnectionSettings: null, isOpenAddDevice: false, + isStopTestrun: false, + isOpenWaitSnackBar: false, hasDevices: false, devices: [], + deviceInProgress: null, + isOpenStartTestrun: false, + systemStatus: null, + isTestrunStarted: false, }; diff --git a/modules/ui/src/assets/icons/risk-assessment.svg b/modules/ui/src/assets/icons/risk-assessment.svg new file mode 100644 index 000000000..42ff5f942 --- /dev/null +++ b/modules/ui/src/assets/icons/risk-assessment.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/modules/ui/src/styles.scss b/modules/ui/src/styles.scss index 778f7cedf..4b5a7324e 100644 --- a/modules/ui/src/styles.scss +++ b/modules/ui/src/styles.scss @@ -158,7 +158,7 @@ mat-hint { } } -h2.title { +.mat-toolbar h2.title { margin: 0; font-size: 32px; font-style: normal; @@ -193,6 +193,15 @@ h2.title { --mdc-outlined-text-field-focus-label-text-color: #1a73e8; } +.setting-field { + --mdc-outlined-text-field-disabled-outline-color: rgba(0, 0, 0, 0.38); + --mdc-outlined-text-field-disabled-label-text-color: rgba(0, 0, 0, 0.58); +} + +.snack-bar-info.mat-mdc-snack-bar-container .mdc-snackbar__surface { + max-width: 780px; +} + body:has(.initiate-test-run-dialog) app-root app-spinner.connection-settings-spinner, @@ -267,3 +276,7 @@ body opacity: var(--mat-text-button-focus-state-layer-opacity); } } + +.certificate-notification .mat-mdc-snack-bar-label { + white-space: pre-line; +} diff --git a/modules/ui/src/theming/colors.scss b/modules/ui/src/theming/colors.scss index a54b7e9ab..7fd232d02 100644 --- a/modules/ui/src/theming/colors.scss +++ b/modules/ui/src/theming/colors.scss @@ -16,6 +16,7 @@ $black: #000000; $white: #ffffff; $primary: #1967d2; +$blue-300: #8ab4f8; $secondary: #5f6368; $accent: #008b00; $warn: #c5221f; diff --git a/testing/unit/conn/conn_module_test.py b/testing/unit/conn/conn_module_test.py new file mode 100644 index 000000000..d31a8051f --- /dev/null +++ b/testing/unit/conn/conn_module_test.py @@ -0,0 +1,140 @@ +# 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. +"""Module run all the Connection module related unit tests""" +from port_stats_util import PortStatsUtil +import os +import unittest +from common import logger + +MODULE = 'conn' +# Define the file paths +TEST_FILES_DIR = 'testing/unit/' + MODULE +ETHTOOL_RESULTS_COMPLIANT_FILE = os.path.join(TEST_FILES_DIR, 'ethtool', + 'ethtool_results_compliant.txt') +ETHTOOL_RESULTS_NONCOMPLIANT_FILE = os.path.join( + TEST_FILES_DIR, 'ethtool', 'ethtool_results_noncompliant.txt') +ETHTOOL_RESULTS_NO_AUTO_FILE = os.path.join( + TEST_FILES_DIR, 'ethtool', 'ethtool_results_no_autononegotiation.txt') + +ETHTOOL_PORT_STATS_PRE_FILE = os.path.join( + TEST_FILES_DIR, 'ethtool', 'ethtool_port_stats_pre_monitor.txt') +ETHTOOL_PORT_STATS_POST_FILE = os.path.join( + TEST_FILES_DIR, 'ethtool', 'ethtool_port_stats_post_monitor.txt') +ETHTOOL_PORT_STATS_POST_NONCOMPLIANT_FILE = os.path.join( + TEST_FILES_DIR, 'ethtool', + 'ethtool_port_stats_post_monitor_noncompliant.txt') +LOGGER = None + + +class ConnectionModuleTest(unittest.TestCase): + """Contains and runs all the unit tests concerning Connection + module behaviors""" + + @classmethod + def setUpClass(cls): + global LOGGER + LOGGER = logger.get_logger('unit_test_' + MODULE) + + # Test the port link status + def connection_port_link_compliant_test(self): + LOGGER.info('connection_port_link_compliant_test') + p_stats = PortStatsUtil( + logger=LOGGER, + ethtool_conn_stats_file=ETHTOOL_RESULTS_COMPLIANT_FILE, + ethtool_port_stats_pre_file=ETHTOOL_PORT_STATS_PRE_FILE, + ethtool_port_stats_post_file=ETHTOOL_PORT_STATS_POST_FILE) + result = p_stats.connection_port_link_test() + LOGGER.info(result) + self.assertEqual(result[0], True) + + # Test the port duplex setting + def connection_port_duplex_compliant_test(self): + LOGGER.info('connection_port_duplex_compliant_test') + p_stats = PortStatsUtil( + logger=LOGGER, ethtool_conn_stats_file=ETHTOOL_RESULTS_COMPLIANT_FILE) + result = p_stats.connection_port_duplex_test() + LOGGER.info(result) + self.assertEqual(result[0], True) + + # Test the port speed + def connection_port_speed_compliant_test(self): + LOGGER.info('connection_port_speed_compliant_test') + p_stats = PortStatsUtil( + logger=LOGGER, ethtool_conn_stats_file=ETHTOOL_RESULTS_COMPLIANT_FILE) + result = p_stats.connection_port_speed_test() + LOGGER.info(result) + self.assertEqual(result[0], True) + + # Test the port link status non-compliant + def connection_port_link_noncompliant_test(self): + LOGGER.info('connection_port_link_noncompliant_test') + p_stats = PortStatsUtil( + logger=LOGGER, + ethtool_conn_stats_file=ETHTOOL_RESULTS_COMPLIANT_FILE, + ethtool_port_stats_pre_file=ETHTOOL_PORT_STATS_PRE_FILE, + ethtool_port_stats_post_file=ETHTOOL_PORT_STATS_POST_NONCOMPLIANT_FILE) + result = p_stats.connection_port_link_test() + LOGGER.info(result) + self.assertEqual(result[0], False) + + # Test the port duplex setting non-compliant + def connection_port_duplex_noncompliant_test(self): + LOGGER.info('connection_port_duplex_noncompliant_test') + p_stats = PortStatsUtil( + logger=LOGGER, + ethtool_conn_stats_file=ETHTOOL_RESULTS_NONCOMPLIANT_FILE) + result = p_stats.connection_port_duplex_test() + LOGGER.info(result) + self.assertEqual(result[0], False) + + # Test the port speed non-compliant + def connection_port_speed_noncompliant_test(self): + LOGGER.info('connection_port_speed_noncompliant_test') + p_stats = PortStatsUtil( + logger=LOGGER, + ethtool_conn_stats_file=ETHTOOL_RESULTS_NONCOMPLIANT_FILE) + result = p_stats.connection_port_speed_test() + LOGGER.info(result) + self.assertEqual(result[0], False) + + # Test the autonegotiation failure test + def connection_port_speed_autonegotiation_fail_test(self): + LOGGER.info('connection_port_speed_autonegotiation_fail_test') + p_stats = PortStatsUtil( + logger=LOGGER, ethtool_conn_stats_file=ETHTOOL_RESULTS_NO_AUTO_FILE) + result = p_stats.connection_port_speed_test() + LOGGER.info(result) + self.assertEqual(result[0], False) + + +if __name__ == '__main__': + suite = unittest.TestSuite() + + # Compliant port stats tests + suite.addTest(ConnectionModuleTest('connection_port_link_compliant_test')) + suite.addTest(ConnectionModuleTest('connection_port_duplex_compliant_test')) + suite.addTest(ConnectionModuleTest('connection_port_speed_compliant_test')) + + # Non-compliant port stats tests + suite.addTest(ConnectionModuleTest('connection_port_link_noncompliant_test')) + suite.addTest( + ConnectionModuleTest('connection_port_duplex_noncompliant_test')) + suite.addTest(ConnectionModuleTest('connection_port_speed_noncompliant_test')) + + # Autonegotiation off failure test + suite.addTest( + ConnectionModuleTest('connection_port_speed_autonegotiation_fail_test')) + + runner = unittest.TextTestRunner() + runner.run(suite) diff --git a/testing/unit/conn/ethtool/ethtool_port_stats_post_monitor.txt b/testing/unit/conn/ethtool/ethtool_port_stats_post_monitor.txt new file mode 100644 index 000000000..a4e93ed47 --- /dev/null +++ b/testing/unit/conn/ethtool/ethtool_port_stats_post_monitor.txt @@ -0,0 +1,14 @@ +NIC statistics: + tx_packets: 124 + rx_packets: 216 + tx_errors: 0 + rx_errors: 0 + rx_missed: 0 + align_errors: 0 + tx_single_collisions: 0 + tx_multi_collisions: 0 + rx_unicast: 23 + rx_broadcast: 83 + rx_multicast: 110 + tx_aborted: 0 + tx_underrun: 0 \ No newline at end of file diff --git a/testing/unit/conn/ethtool/ethtool_port_stats_post_monitor_noncompliant.txt b/testing/unit/conn/ethtool/ethtool_port_stats_post_monitor_noncompliant.txt new file mode 100644 index 000000000..1d4b19e54 --- /dev/null +++ b/testing/unit/conn/ethtool/ethtool_port_stats_post_monitor_noncompliant.txt @@ -0,0 +1,14 @@ +NIC statistics: + tx_packets: 124 + rx_packets: 216 + tx_errors: 5 + rx_errors: 12 + rx_missed: 0 + align_errors: 0 + tx_single_collisions: 0 + tx_multi_collisions: 0 + rx_unicast: 23 + rx_broadcast: 83 + rx_multicast: 110 + tx_aborted: 0 + tx_underrun: 0 \ No newline at end of file diff --git a/testing/unit/conn/ethtool/ethtool_port_stats_pre_monitor.txt b/testing/unit/conn/ethtool/ethtool_port_stats_pre_monitor.txt new file mode 100644 index 000000000..18e027efb --- /dev/null +++ b/testing/unit/conn/ethtool/ethtool_port_stats_pre_monitor.txt @@ -0,0 +1,14 @@ +NIC statistics: + tx_packets: 90 + rx_packets: 195 + tx_errors: 0 + rx_errors: 0 + rx_missed: 0 + align_errors: 0 + tx_single_collisions: 0 + tx_multi_collisions: 0 + rx_unicast: 12 + rx_broadcast: 78 + rx_multicast: 105 + tx_aborted: 0 + tx_underrun: 0 \ No newline at end of file diff --git a/testing/unit/conn/ethtool/ethtool_results_compliant.txt b/testing/unit/conn/ethtool/ethtool_results_compliant.txt new file mode 100644 index 000000000..911e8912f --- /dev/null +++ b/testing/unit/conn/ethtool/ethtool_results_compliant.txt @@ -0,0 +1,30 @@ +Settings for enx123456789: + Supported ports: [ TP MII ] + Supported link modes: 10baseT/Half 10baseT/Full + 100baseT/Half 100baseT/Full + 1000baseT/Half 1000baseT/Full + Supported pause frame use: No + Supports auto-negotiation: Yes + Supported FEC modes: Not reported + Advertised link modes: 10baseT/Half 10baseT/Full + 100baseT/Half 100baseT/Full + 1000baseT/Full + Advertised pause frame use: Symmetric Receive-only + Advertised auto-negotiation: Yes + Advertised FEC modes: Not reported + Link partner advertised link modes: 10baseT/Full + 100baseT/Half 100baseT/Full + Link partner advertised pause frame use: Symmetric + Link partner advertised auto-negotiation: Yes + Link partner advertised FEC modes: Not reported + Speed: 100Mb/s + Duplex: Full + Port: MII + PHYAD: 32 + Transceiver: internal + Auto-negotiation: on + Supports Wake-on: pumbg + Wake-on: g + Current message level: 0x00007fff (32767) + drv probe link timer ifdown ifup rx_err tx_err tx_queued intr tx_done rx_status pktdata hw wol + Link detected: yes \ No newline at end of file diff --git a/testing/unit/conn/ethtool/ethtool_results_no_autononegotiation.txt b/testing/unit/conn/ethtool/ethtool_results_no_autononegotiation.txt new file mode 100644 index 000000000..76dd4cf5b --- /dev/null +++ b/testing/unit/conn/ethtool/ethtool_results_no_autononegotiation.txt @@ -0,0 +1,30 @@ +Settings for enx123456789: + Supported ports: [ TP MII ] + Supported link modes: 10baseT/Half 10baseT/Full + 100baseT/Half 100baseT/Full + 1000baseT/Half 1000baseT/Full + Supported pause frame use: No + Supports auto-negotiation: Yes + Supported FEC modes: Not reported + Advertised link modes: 10baseT/Half 10baseT/Full + 100baseT/Half 100baseT/Full + 1000baseT/Full + Advertised pause frame use: Symmetric Receive-only + Advertised auto-negotiation: Yes + Advertised FEC modes: Not reported + Link partner advertised link modes: 10baseT/Full + 100baseT/Half 100baseT/Full + Link partner advertised pause frame use: Symmetric + Link partner advertised auto-negotiation: Yes + Link partner advertised FEC modes: Not reported + Speed: 100Mb/s + Duplex: Full + Port: MII + PHYAD: 32 + Transceiver: internal + Auto-negotiation: off + Supports Wake-on: pumbg + Wake-on: g + Current message level: 0x00007fff (32767) + drv probe link timer ifdown ifup rx_err tx_err tx_queued intr tx_done rx_status pktdata hw wol + Link detected: yes \ No newline at end of file diff --git a/testing/unit/conn/ethtool/ethtool_results_noncompliant.txt b/testing/unit/conn/ethtool/ethtool_results_noncompliant.txt new file mode 100644 index 000000000..2ca90e7d0 --- /dev/null +++ b/testing/unit/conn/ethtool/ethtool_results_noncompliant.txt @@ -0,0 +1,30 @@ +Settings for enx123456789: + Supported ports: [ TP MII ] + Supported link modes: 10baseT/Half 10baseT/Full + 100baseT/Half 100baseT/Full + 1000baseT/Half 1000baseT/Full + Supported pause frame use: No + Supports auto-negotiation: Yes + Supported FEC modes: Not reported + Advertised link modes: 10baseT/Half 10baseT/Full + 100baseT/Half 100baseT/Full + 1000baseT/Full + Advertised pause frame use: Symmetric Receive-only + Advertised auto-negotiation: Yes + Advertised FEC modes: Not reported + Link partner advertised link modes: 10baseT/Full + 100baseT/Half 100baseT/Full + Link partner advertised pause frame use: Symmetric + Link partner advertised auto-negotiation: Yes + Link partner advertised FEC modes: Not reported + Speed: 10Mb/s + Duplex: Half + Port: MII + PHYAD: 32 + Transceiver: internal + Auto-negotiation: on + Supports Wake-on: pumbg + Wake-on: g + Current message level: 0x00007fff (32767) + drv probe link timer ifdown ifup rx_err tx_err tx_queued intr tx_done rx_status pktdata hw wol + Link detected: yes \ No newline at end of file diff --git a/testing/unit/run_tests.sh b/testing/unit/run_tests.sh index 01c2b36cf..2658636dd 100644 --- a/testing/unit/run_tests.sh +++ b/testing/unit/run_tests.sh @@ -27,6 +27,7 @@ PYTHONPATH="$PWD/framework/python/src:$PWD/framework/python/src/common" # Add the test module sources PYTHONPATH="$PYTHONPATH:$PWD/modules/test/base/python/src" +PYTHONPATH="$PYTHONPATH:$PWD/modules/test/conn/python/src" PYTHONPATH="$PYTHONPATH:$PWD/modules/test/tls/python/src" PYTHONPATH="$PYTHONPATH:$PWD/modules/test/dns/python/src" PYTHONPATH="$PYTHONPATH:$PWD/modules/test/nmap/python/src" @@ -39,6 +40,9 @@ export PYTHONPATH python3 -u $PWD/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py python3 -u $PWD/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.py +# Run the Conn Module Unit Tests +python3 -u $PWD/testing/unit/conn/conn_module_test.py + # Run the TLS Module Unit Tests python3 -u $PWD/testing/unit/tls/tls_module_test.py