diff --git a/README.md b/README.md index 83f70d12..5d1b41e9 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,6 @@ Copyright 2022 COMETA ROCKS S.L. Portions of this software are licensed as follows: -* All content that resides under "ee/" directory of this repository (Enterprise Edition) is licensed under the license defined in "ee/LICENSE". (Work in porgress) +* All content that resides under "ee/" directory of this repository (Enterprise Edition) is licensed under the license defined in "ee/LICENSE". (Work in progress) * All third party components incorporated into the cometa.rocks Software are licensed under the original license provided by the owner of the applicable component. * Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined in `LICENSE` file. diff --git a/backend/behave/behave_django/schedules/tasks/runBrowser.py b/backend/behave/behave_django/schedules/tasks/runBrowser.py index 11e2832d..cda48641 100644 --- a/backend/behave/behave_django/schedules/tasks/runBrowser.py +++ b/backend/behave/behave_django/schedules/tasks/runBrowser.py @@ -2,6 +2,10 @@ from django.conf import settings from django_rq import job from rq.timeouts import JobTimeoutException +from rq.command import send_stop_job_command +from rq.job import Job +from rq import get_current_job +import django_rq # just to import secrets sys.path.append("/code") @@ -23,38 +27,42 @@ @job def run_browser(json_path, env, **kwargs): # Start running feature with current browser - process = subprocess.Popen(["bash", settings.RUNTEST_COMMAND_PATH, json_path], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - try: - # wait for the process to finish - process.wait() - logger.debug(f"Process Return Code: {process.returncode}") - if process.returncode > 0: - out, _ = process.communicate() - out = str(out.decode('utf-8')) - logger.error(f"Error ocurred during the feature startup ... please check the output:\n{out}") - if 'Parser failure' in out: - raise Exception("Parsing error in the feature, please recheck the steps.") - except JobTimeoutException as err: - # job was timed out, kill the process - logger.error("Job timed out.") - logger.exception(err) - subprocess.run(["pkill", "-TERM", "-P", "%d" % int(process.pid)]) - raise - # TODO: - # Check if process has been stopped - # Send mail? - except Exception as e: - logger.error("run_browser threw an exception:") - logger.exception(e) - requests.post('http://cometa_socket:3001/feature/%s/error' % kwargs.get('feature_id', None), data={ - "browser_info": kwargs.get('browser', None), - "feature_result_id": kwargs.get('feature_result_id', None), - "run_id": kwargs.get('feature_run', None), - "datetime": datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), - "error": str(e), - "user_id": kwargs.get('user_data', {}).get('user_id', None) - }) + with subprocess.Popen(["bash", settings.RUNTEST_COMMAND_PATH, json_path], env=env, stdout=subprocess.PIPE) as process: + try: + logger.debug(f"Process id: {process.pid}") + # wait for the process to finish + process.wait() + logger.debug(f"Process Return Code: {process.returncode}") + if process.returncode > 0: + out, _ = process.communicate() + out = str(out.decode('utf-8')) + logger.error(f"Error ocurred during the feature execution ... please check the output:\n{out}") + if 'Parser failure' in out: + raise Exception("Parsing error in the feature, please recheck the steps.") + except JobTimeoutException as err: + # job was timed out, kill the process + logger.error("Job timed out.") + logger.exception(err) + with subprocess.Popen(f"ps -o pid --ppid {process.pid} --noheaders | xargs kill -15", shell=True) as p2: + p2.wait() + process.wait() + job: Job = get_current_job() + send_stop_job_command(django_rq.get_connection(), job.id) + raise + # TODO: + # Check if process has been stopped + # Send mail? + except Exception as e: + logger.error("run_browser threw an exception:") + logger.exception(e) + requests.post('http://cometa_socket:3001/feature/%s/error' % kwargs.get('feature_id', None), data={ + "browser_info": kwargs.get('browser', None), + "feature_result_id": kwargs.get('feature_result_id', None), + "run_id": kwargs.get('feature_run', None), + "datetime": datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), + "error": str(e), + "user_id": kwargs.get('user_data', {}).get('user_id', None) + }) @job def run_finished(feature_run, feature_id, user_data): diff --git a/backend/behave/behave_django/schedules/views.py b/backend/behave/behave_django/schedules/views.py index 458481ab..3ff9a897 100755 --- a/backend/behave/behave_django/schedules/views.py +++ b/backend/behave/behave_django/schedules/views.py @@ -91,7 +91,8 @@ def run_test(request): 'PROXY_USER': PROXY_USER, 'VARIABLES': VARIABLES, 'PARAMETERS': PARAMETERS, - 'department': department + 'department': department, + 'feature_id': feature_id } """ os.environ['feature_run'] = str(feature_run) @@ -212,7 +213,8 @@ def run_test(request): feature_id=feature_id, feature_result_id=feature_result_id, user_data=user_data, - feature_run=feature_run) + feature_run=feature_run, + job_timeout=7500) jobs.append(job) notify = run_finished.delay(feature_run, feature_id, user_data, depends_on=jobs) diff --git a/backend/behave/cometa_itself/environment.py b/backend/behave/cometa_itself/environment.py index f64c42ec..ca185a29 100755 --- a/backend/behave/cometa_itself/environment.py +++ b/backend/behave/cometa_itself/environment.py @@ -15,9 +15,10 @@ import secret_variables from src.backend.common import * +LOGGER_FORMAT = '\33[96m[%(asctime)s][%(feature_id)s][%(current_step)s/%(total_steps)s][%(levelname)s][%(filename)s:%(lineno)d](%(funcName)s) -\33[0m %(message)s' # setup logging logging.setLoggerClass(CometaLogger) -logger = logging.getLogger(__name__) +logger = logging.getLogger('FeatureExecution') logger.setLevel(BEHAVE_DEBUG_LEVEL) # create a formatter for the logger formatter = logging.Formatter(LOGGER_FORMAT, LOGGER_DATE_FORMAT) @@ -37,6 +38,7 @@ # handle SIGTERM when user stops the testcase def stopExecution(signum, frame, context): + logger.warn("SIGTERM Found, will stop the session") context.aborted = True # check if context has a variable @@ -82,6 +84,10 @@ def decorated(*args, **kwargs): @error_handling() def before_all(context): + # Create a logger for file handler + fileHandle = logging.FileHandler(f"/code/src/logs/{os.environ['feature_result_id']}.log") + fileHandle.setFormatter(formatter) + logger.addHandler(fileHandle) # handle SIGTERM signal signal.signal(signal.SIGTERM, lambda signum, frame, ctx=context: stopExecution(signum, frame, ctx)) # create index counter for steps @@ -311,6 +317,7 @@ def before_all(context): # update counters total context.counters['total'] = len(response.json()['results']) + os.environ['total_steps'] = str(context.counters['total']) # send a websocket request about that feature has been started request = requests.get('http://cometa_socket:3001/feature/%s/started' % context.feature_id, data={ @@ -319,7 +326,9 @@ def before_all(context): "feature_result_id": os.environ['feature_result_id'], "run_id": os.environ['feature_run'], "datetime": datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') - }) + }) + + logger.info("Processing done ... will continue with the steps.") # Get video url with context of browser def get_video_url(context): @@ -329,6 +338,8 @@ def get_video_url(context): @error_handling() def after_all(context): + del os.environ['current_step'] + del os.environ['total_steps'] # check if any alertboxes are open before quiting the browser try: while(context.browser.switch_to.alert): @@ -492,8 +503,10 @@ def after_all(context): @error_handling() def before_step(context, step): + os.environ['current_step'] = str(context.counters['index'] + 1) # complete step name to let front know about the step that will be executed next step_name = "%s %s" % (step.keyword, step.name) + logger.info(f"-> {step_name}") # step index index = context.counters['index'] # pass all the data about the step to the step_data in context, step_data has name, screenshot, compare, enabled and type diff --git a/backend/behave/cometa_itself/steps/actions.py b/backend/behave/cometa_itself/steps/actions.py index a81aaa44..31aab7dd 100755 --- a/backend/behave/cometa_itself/steps/actions.py +++ b/backend/behave/cometa_itself/steps/actions.py @@ -75,7 +75,7 @@ ENCRYPTION_START = getattr(secret_variables, 'COMETA_ENCRYPTION_START', '') # setup logging -logger = logging.getLogger(__name__) +logger = logging.getLogger('FeatureExecution') DATETIMESTRING=time.strftime("%Y%m%d-%H%M%S") diff --git a/backend/behave/cometa_itself/steps/tools/cognos.py b/backend/behave/cometa_itself/steps/tools/cognos.py index 716eb4f3..891f6a98 100644 --- a/backend/behave/cometa_itself/steps/tools/cognos.py +++ b/backend/behave/cometa_itself/steps/tools/cognos.py @@ -8,17 +8,7 @@ from src.backend.common import * # setup logging -logger = logging.getLogger(__name__) -logger.setLevel(BEHAVE_DEBUG_LEVEL) -# create a formatter for the logger -formatter = logging.Formatter(LOGGER_FORMAT, LOGGER_DATE_FORMAT) -# create a stream logger -streamLogger = logging.StreamHandler() -# set the format of streamLogger to formatter -streamLogger.setFormatter(formatter) -# add the stream handle to logger -logger.addHandler(streamLogger) - +logger = logging.getLogger('FeatureExecution') """ Python library with functions used for IBM Cognos @@ -469,7 +459,7 @@ def selectCognosPrompt_rro(context, **kwargs): elm[0].click() logger.debug("Clicked on option") except: - elm[0].selected=true + elm[0].selected=True logger.debug("Setting selected to true") # we have no value for this selector ... fallback to choosing the one with index=optionIndex else: diff --git a/backend/behave/cometa_itself/steps/tools/common.py b/backend/behave/cometa_itself/steps/tools/common.py index bf300d54..52b9cdf2 100644 --- a/backend/behave/cometa_itself/steps/tools/common.py +++ b/backend/behave/cometa_itself/steps/tools/common.py @@ -5,6 +5,7 @@ from .variables import * from functools import wraps from selenium.webdriver.remote.webelement import WebElement +from selenium.common.exceptions import InvalidSelectorException import time, requests, json, os, datetime, sys, subprocess, re, shutil from src.backend.common import * from src.backend.utility.cometa_logger import CometaLogger @@ -13,16 +14,7 @@ # setup logging logging.setLoggerClass(CometaLogger) -logger = logging.getLogger(__name__) -logger.setLevel(BEHAVE_DEBUG_LEVEL) -# create a formatter for the logger -formatter = logging.Formatter(LOGGER_FORMAT, LOGGER_DATE_FORMAT) -# create a stream logger -streamLogger = logging.StreamHandler() -# set the format of streamLogger to formatter -streamLogger.setFormatter(formatter) -# add the stream handle to logger -logger.addHandler(streamLogger) +logger = logging.getLogger('FeatureExecution') """ Python library with common utility functions @@ -131,6 +123,8 @@ def waitSelector(context, selector_type, selector, max_timeout=None): logger.exception(err) # Max retries exceeded, raise error raise + except InvalidSelectorException as err: + logger.debug(f"Invalid Selector Exception: Selector Type: {selec_type}, Selector: {selector}.") except Exception as err: # logger.error("Exception occured during the selector find, will continue looking for the element.") # logger.exception(err) diff --git a/backend/src/backend/middlewares/authentication.py b/backend/src/backend/middlewares/authentication.py index d4ccecff..4f63c6f5 100644 --- a/backend/src/backend/middlewares/authentication.py +++ b/backend/src/backend/middlewares/authentication.py @@ -36,6 +36,8 @@ def __call__(self, request): try: # get the host from request HTTP_HOST = request.META.get('HTTP_HOST', DOMAIN) + if HTTP_HOST == 'cometa.local': + raise Exception("User session none existent from behave.") if not re.match(r'^(cometa.*\.amvara\..*)|(.*\.cometa\.rocks)$', HTTP_HOST): HTTP_HOST = 'cometa_front' # make a request to cometa_front to get info about the logged in user @@ -46,7 +48,7 @@ def __call__(self, request): }) # save user_info to self self.user_info = response.json().get('userinfo', {}) - except Exception as error: # if executed from crontab + except Exception as error: # if executed from crontab or sent by behave self.user_info = {} # create a session variable diff --git a/backend/src/backend/utility/cometa_logger.py b/backend/src/backend/utility/cometa_logger.py index 26d26788..09002e81 100644 --- a/backend/src/backend/utility/cometa_logger.py +++ b/backend/src/backend/utility/cometa_logger.py @@ -1,4 +1,4 @@ -import logging, threading, re +import logging, threading, re, os class CometaLogger(logging.Logger): @@ -22,6 +22,9 @@ def mask_values(self, msg): msg = re.sub(rf"(?:{words_to_mask})\b", '[MASKED]', str(msg)) return msg - def _log(self, level, msg, args, exc_info = None, extra = None, stack_info = False, stacklevel = 1): + def _log(self, level, msg, args, exc_info = None, extra = {}, stack_info = False, stacklevel = 1): msg = self.mask_values(msg) + extra['feature_id'] = os.environ.get('feature_id', "n/a") + extra['current_step'] = os.environ.get('current_step', "?") + extra['total_steps'] = os.environ.get('total_steps', "?") return super()._log(level, msg, args, exc_info, extra, stack_info, stacklevel) \ No newline at end of file diff --git a/backend/src/backend/views.py b/backend/src/backend/views.py index a581ba2f..5b39ccef 100755 --- a/backend/src/backend/views.py +++ b/backend/src/backend/views.py @@ -2148,22 +2148,18 @@ def patch(self, request, *args, **kwargs): Process schedule if requested """ # Set schedule of feature if provided in data, if schedule is empty will be removed + set_schedule = False if 'schedule' in data: schedule = data['schedule'] logger.debug("Saveing schedule: "+str(schedule) ) # Check if schedule is not 'now' - if schedule != 'now': + if schedule != '' and schedule != 'now': # Validate cron format before sending to Behave if schedule != "" and not CronSlices.is_valid(schedule): return JsonResponse({ 'success': False, "error": 'Schedule format is invalid.' }, status=200) - # Save schedule in Behave docker Crontab - response = set_test_schedule(feature.feature_id, schedule, request.session['user']['user_id']) - if response.status_code != 200: - # Oops, something went wrong while saving schedule - logger.debug("Ooops - something went wrong saveing the schedule. You should probably check the crontab file mounted into docker to be a file and not a directory.") - json_data = response.json() - return JsonResponse({ 'success': False, "error": json_data.get('error', 'Something went wrong while saving schedule. Check crontab directory of docker.') }, status=200) + set_schedule = True # Save schedule, at this point is 100% valid and saved + logger.debug("Adding schedule to database") feature.schedule = schedule """ @@ -2179,6 +2175,15 @@ def patch(self, request, *args, **kwargs): # Save without steps result = feature.save() + if set_schedule: + # Save schedule in Behave docker Crontab + response = set_test_schedule(feature.feature_id, schedule, request.session['user']['user_id']) + if response.status_code != 200: + # Oops, something went wrong while saving schedule + logger.debug("Ooops - something went wrong saveing the schedule. You should probably check the crontab file mounted into docker to be a file and not a directory.") + json_data = response.json() + return JsonResponse({ 'success': False, "error": json_data.get('error', 'Something went wrong while saving schedule. Check crontab directory of docker.') }, status=200) + """ Send WebSockets """ diff --git a/front/src/app/app.module.ts b/front/src/app/app.module.ts index 35e452aa..669b5706 100755 --- a/front/src/app/app.module.ts +++ b/front/src/app/app.module.ts @@ -40,6 +40,7 @@ import { NgxNetworkErrorModule } from 'ngx-network-error'; /* Services */ import { ApiService } from '@services/api.service'; +import { DownloadService } from '@services/download.service'; import { PaymentsService } from '@services/payments.service'; import { SocketService } from '@services/socket.service'; import { ConfigService } from '@services/config.service'; @@ -282,6 +283,7 @@ export function getStripeApiKey() { providers: [ ConfigService, ApiService, + DownloadService, PaymentsService, SocketService, ConfigService, diff --git a/front/src/app/components/feature-run/feature-run.component.ts b/front/src/app/components/feature-run/feature-run.component.ts index d54b8d97..b41e83cb 100644 --- a/front/src/app/components/feature-run/feature-run.component.ts +++ b/front/src/app/components/feature-run/feature-run.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, ChangeDetectionStrategy, Optional, Host } from '@angular/core'; +import { Component, Input, ChangeDetectionStrategy, Optional, Host, OnInit } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { VideoComponent } from '@dialogs/video/video.component'; diff --git a/front/src/app/components/feature-titles/feature-titles.component.html b/front/src/app/components/feature-titles/feature-titles.component.html index a99ac0a3..7318f4db 100755 --- a/front/src/app/components/feature-titles/feature-titles.component.html +++ b/front/src/app/components/feature-titles/feature-titles.component.html @@ -1,5 +1,6 @@ - App: {{ app }} + Department: {{ dep }} + | Application: {{ app }} | Environment: {{ env }} | Test: {{ name }} \ No newline at end of file diff --git a/front/src/app/components/l1-feature-list/l1-feature-list.component.scss b/front/src/app/components/l1-feature-list/l1-feature-list.component.scss index 2cc2a3c7..3680b90e 100644 --- a/front/src/app/components/l1-feature-list/l1-feature-list.component.scss +++ b/front/src/app/components/l1-feature-list/l1-feature-list.component.scss @@ -67,6 +67,7 @@ } } } + td { font-weight: bold; color: $dark; diff --git a/front/src/app/dialogs/edit-feature/edit-feature.component.html b/front/src/app/dialogs/edit-feature/edit-feature.component.html index 66be5ce8..1e7270c2 100755 --- a/front/src/app/dialogs/edit-feature/edit-feature.component.html +++ b/front/src/app/dialogs/edit-feature/edit-feature.component.html @@ -54,7 +54,7 @@

Clone feature

-
+
info Selecting Default department will make this feature visible to everyone, use it with caution!
diff --git a/front/src/app/dialogs/video/video.component.scss b/front/src/app/dialogs/video/video.component.scss index 362c0871..1b934f98 100644 --- a/front/src/app/dialogs/video/video.component.scss +++ b/front/src/app/dialogs/video/video.component.scss @@ -1,13 +1,6 @@ @import 'color'; @import 'breakpoints'; -:host { - display: block; - margin-bottom: -1px; - margin-right: -1px; - min-width: 300px; -} - video { width: 100%; height: auto; diff --git a/front/src/app/dialogs/video/video.component.ts b/front/src/app/dialogs/video/video.component.ts index ddecdef7..1ee61b01 100644 --- a/front/src/app/dialogs/video/video.component.ts +++ b/front/src/app/dialogs/video/video.component.ts @@ -1,5 +1,5 @@ import { Component, ChangeDetectionStrategy, Inject } from '@angular/core'; -import { MatLegacyDialogRef as MatDialogRef, MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA } from '@angular/material/legacy-dialog'; +import { MatDialogRef as MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { BehaviorSubject } from 'rxjs'; diff --git a/front/src/app/services/download.service.ts b/front/src/app/services/download.service.ts new file mode 100644 index 00000000..ace4776b --- /dev/null +++ b/front/src/app/services/download.service.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, Inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { API_BASE, API_URL } from 'app/tokens'; + +@Injectable() +export class DownloadService { + + constructor( + private _http: HttpClient, + @Inject(API_URL) public api: string, + @Inject(API_BASE) public base: string, + private _snack: MatSnackBar + ) { } + + downloadFile(response, file: UploadedFile) { + const downloading = this._snack.open('Generating file to download, please be patient.', 'OK', { duration: 1000 }) + const blob = new Blob([this.base64ToArrayBuffer(response.body)], { type: file.mime }); + this.downloadFileBlob(blob, file); + } + + base64ToArrayBuffer(data: string) { + let byteArray; + try { + byteArray = atob(data); + } catch(DOMException) { + byteArray = data; + } + const uint = new Uint8Array(byteArray.length) + for (let i = 0; i < byteArray.length; i++) { + let ascii = byteArray.charCodeAt(i); + uint[i] = ascii; + } + return uint; + } + + downloadFileBlob(blob: Blob, file: UploadedFile) { + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = file.name; + link.click(); + } + +} \ No newline at end of file diff --git a/front/src/app/views/main-view/main-view.component.html b/front/src/app/views/main-view/main-view.component.html index a864e29d..df79c3a9 100755 --- a/front/src/app/views/main-view/main-view.component.html +++ b/front/src/app/views/main-view/main-view.component.html @@ -3,7 +3,7 @@
-
+
@@ -12,14 +12,14 @@
-
+
- +
-
+
shown when there is no charts

This screen shows the results of your testruns.

Once you have more then 10 results, co.meta will show you a beautiful linechart with execution times and more.

@@ -30,7 +30,7 @@
Last test
-
+
{{ lastRun.total}}
{{ 1 }}
{{ passed ? 'OK' : 'NOK' }}
@@ -38,32 +38,111 @@
-
- - - - - - - - -
-

Please execute your first feature clicking the blue run-button.

-
-

If you just added a feature and it's executing now you will have to wait until it's finished.

-
-

These results are reloaded automatically.

-
+ + + + + +
+ +
+ Passed
+ +
Failed
- -
+ + + + + + + + +
+ + + + {{ row.result_date | amParse | amDateFormat:'MMMM d yyyy, HH:mm' }} + + + + {{ row.execution_time | secondsToHumanReadable }} + + + + {{ row.pixel_diff | pixelDifference }} + + + + + + + + +
+ Show archived items + +
+ + + +
Clear results
+ + + + + +
Options
+ + +
+
+ + + +
+

Please execute your first feature clicking the blue run-button.

+
+

If you just added a feature and it's executing now you will have to wait until it's finished.

+
+

These results are reloaded automatically.

+
+
\ No newline at end of file diff --git a/front/src/app/views/main-view/main-view.component.scss b/front/src/app/views/main-view/main-view.component.scss index 54a3206e..b5fe3acf 100755 --- a/front/src/app/views/main-view/main-view.component.scss +++ b/front/src/app/views/main-view/main-view.component.scss @@ -11,6 +11,124 @@ height: calc(100vh - var(--header-height)); } +:host::ng-deep { + .mtx-grid{ + .mtx-grid-toolbar-content { + flex-grow: 1; + .custom_toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-right: 5px; + } + button { + color: #000; + mat-icon { + font-size: 1.125rem; + width: 1.125rem; + height: 1.125rem; + margin-left: -4px; + margin-right: 8px; + display: inline-block; + } + } + } + table { + border-radius: 5px; + background-color: $table-background-opaque; + border-spacing: 0; + box-shadow: 0 3px 1px -2px $table-low-shadow, 0 2px 2px 0 $table-medium-shadow, 0 1px 5px 0 $table-high-shadow; + + thead { + background-color: #474747; + [role="columnheader"] { + span { + text-transform: uppercase; + font-weight: 400; + font-size: 16px; + color: #fff; + } + + svg { + display: none; + } + } + + .mat-sort-header-arrow div:not(.mat-sort-header-indicator) { + background-color: #fff; + } + } + + tbody tr { + height: 45px; + cursor: pointer; + &:nth-child(odd) { + background-color: $low-white; + } + &:nth-child(even) { + background-color: #f2f2f2; + } + &:hover { + background-color: #cecece; + } + &.selected { + background-color: $selected-black-opaque; + td { + &.name { + color: $blue; + } + } + } + } + + td { + font-weight: bold; + color: $dark; + font-family: 'CorpoS, sans-serif'; + font-size: 16px; + } + + // th .mat-header-cell-inner { + // justify-content: center; + // } + + // .aligned-center { + // text-align: center; + // } + // .aligned-right { + // text-align: right; + // } + + .mdc-icon-button { + color: #0000008a; + + &:hover { + color: var(--mdc-icon-button-icon-color, inherit); + } + } + } + thead .mat-table-sticky-right { + border-left-color: rgba(255,255,255,.2); + } + tbody .mat-table-sticky-right { + border-left-color: rgba(0,0,0,.2); + } + .mat-mdc-paginator-container { + background-color: $body-bg-color; + } + + .browser-icon { + width: 100%; + height: 20px; + display: block; + background-size: 20px; + background-repeat: no-repeat; + // background-position: center; + position: relative; + } + } +} + .no-chart { max-height: 400px; padding: 10px 5%; diff --git a/front/src/app/views/main-view/main-view.component.ts b/front/src/app/views/main-view/main-view.component.ts index 1e97c9d0..b1d1fea2 100755 --- a/front/src/app/views/main-view/main-view.component.ts +++ b/front/src/app/views/main-view/main-view.component.ts @@ -1,113 +1,291 @@ -import { Component, OnInit, ChangeDetectionStrategy, ViewChild, AfterViewInit } from '@angular/core'; +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Actions, ofActionDispatched, Select } from '@ngxs/store'; import { combineLatest, Observable, fromEvent } from 'rxjs'; import { CustomSelectors } from '@others/custom-selectors'; -import { NetworkPaginatedListComponent } from '@components/network-paginated-list/network-paginated-list.component'; -import { WebSockets } from '@store/actions/results.actions'; import { map } from 'rxjs/operators'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { startWith } from 'rxjs/operators'; import { Store } from '@ngxs/store'; -import { MainViewFieldsDesktop, MainViewFieldsMobile, MainViewFieldsTabletLandscape, MainViewFieldsTabletPortrait } from '@others/variables'; +import { MtxGridColumn } from '@ng-matero/extensions/grid'; +import { HttpClient } from '@angular/common/http'; +import { PageEvent } from '@angular/material/paginator'; +import { SharedActionsService } from '@services/shared-actions.service'; +import { WebSockets } from '@store/actions/results.actions'; +import { Configuration } from '@store/actions/config.actions'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { VideoComponent } from '@dialogs/video/video.component'; +import { ApiService } from '@services/api.service'; +import { LoadingSnack } from '@components/snacks/loading/loading.snack'; +import { MatCheckboxChange } from '@angular/material/checkbox'; +import { PdfLinkPipe } from '@pipes/pdf-link.pipe'; +import { DownloadService } from '@services/download.service'; +import { InterceptorParams } from 'ngx-network-error'; @UntilDestroy() @Component({ selector: 'main-view', templateUrl: './main-view.component.html', styleUrls: ['./main-view.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + PdfLinkPipe + ] }) -export class MainViewComponent implements OnInit, AfterViewInit { +export class MainViewComponent implements OnInit { - isloaded: boolean = false; + @Select(CustomSelectors.GetConfigProperty('internal.showArchived')) showArchived$: Observable; - @ViewChild(NetworkPaginatedListComponent, { static: false }) paginatedList: NetworkPaginatedListComponent; + columns: MtxGridColumn[] = [ + {header: 'Status', field: 'status', sortable: true, class: 'aligned-center'}, + {header: 'Execution Date', field: 'result_date', sortable: true, width: '230px', sortProp: { start: 'desc', id: 'result_date'}}, + {header: 'Total', field: 'total', sortable: true, class: 'aligned-center'}, + {header: 'OK', field: 'ok', sortable: true, class: 'aligned-center'}, + {header: 'NOK', field: 'fails', sortable: true, class: 'aligned-center'}, + {header: 'Skipped', field: 'skipped', class: 'aligned-center'}, + {header: 'Browser', field: 'browser', class: 'aligned-center'}, + {header: 'Browser Version', field: 'browser.browser_version', hide: true, sortable: true, class: 'aligned-center'}, + {header: 'Duration', field: 'execution_time', sortable: true, class: "aligned-right"}, + {header: 'Pixel Difference', field: 'pixel_diff', sortable: true, class: "aligned-right"}, + { + header: 'Options', + field: 'options', + width: '230px', + pinned: 'right', + right: '0px', + type: 'button', + buttons: [ + { + type: 'icon', + text: 'replay', + icon: 'videocam', + tooltip: 'View results replay', + color: 'primary', + disabled: (result: FeatureResult) => !result.video_url ? true : false, + click: (result: FeatureResult) => this.openVideo(result), + }, + { + type: 'icon', + text: 'pdf', + icon: 'picture_as_pdf', + tooltip: 'Download result PDF', + color: 'primary', + click: (result: FeatureResult) => { + const pdfLink = this._pdfLinkPipe.transform(result.feature_result_id) + this._http.get(pdfLink, { + params: new InterceptorParams({ + skipInterceptor: true, + }), + responseType: 'text', + observe: 'response' + }).subscribe({ + next: (res) => { + this._downloadService.downloadFile(res, { + mime: 'application/pdf', + name: `${result.feature_name}_${result.feature_result_id}.pdf` + }) + }, + error: console.error + }) + }, + }, + { + type: 'icon', + text: 'archive', + icon: 'archive', + tooltip: 'Archive result', + color: 'accent', + click: (result: FeatureResult) => { + this._sharedActions.archive(result).subscribe(_ => this.getResults()) + }, + iif: (result: FeatureResult) => !result.archived + }, + { + type: 'icon', + text: 'unarchive', + icon: 'unarchive', + tooltip: 'Unarchive result', + color: 'accent', + click: (result: FeatureResult) => { + this._sharedActions.archive(result).subscribe(_ => this.getResults()) + }, + iif: (result: FeatureResult) => result.archived + }, + { + type: 'icon', + text: 'delete', + icon: 'delete', + tooltip: 'Delete result', + color: 'warn', + click: (result: FeatureResult) => { + this._sharedActions.deleteFeatureResult(result).subscribe(_ => this.getResults()) + }, + iif: (result: FeatureResult) => !result.archived + } + ] + } + ]; - @Select(CustomSelectors.GetConfigProperty('internal.showArchived')) showArchived$: Observable; + results = []; + total = 0; + isLoading = true; + showPagination = true; + + query = { + page: 0, + size: 10 + } + get params() { + const p = { ...this.query }; + p.page += 1; + return p + } constructor( private _route: ActivatedRoute, private _actions: Actions, private _store: Store, - private _router: Router + private _router: Router, + public _sharedActions: SharedActionsService, + private _http: HttpClient, + private cdRef: ChangeDetectorRef, + private _dialog: MatDialog, + private _snack: MatSnackBar, + private _api: ApiService, + private _pdfLinkPipe: PdfLinkPipe, + private _downloadService: DownloadService ) { } - featureRunsUrl$: Observable; featureId$: Observable; + openContent(feature_result: FeatureResult) { + this._router.navigate([ + this._route.snapshot.paramMap.get('app'), + this._route.snapshot.paramMap.get('environment'), + this._route.snapshot.paramMap.get('feature'), + 'step', + feature_result.feature_result_id + ]); + } + + getResults() { + this.isLoading = true; + combineLatest([this.featureId$,this.showArchived$]).subscribe(([featureId, archived]) => { + this._http.get(`/backend/api/feature_results_by_featureid/`, { + params: { + feature_id: featureId, + archived: archived, + ...this.params + } + }).subscribe({ + next: (res: any) => { + this.results = res.results + this.total = res.count + this.showPagination = this.total > 0 ? true : false + }, + error: (err) => { + console.error(err) + }, + complete: () => { + this.isLoading = false + this.cdRef.detectChanges(); + } + }) + }) + } + + updateData(e: PageEvent) { + this.query.page = e.pageIndex + this.query.size = e.pageSize + this.getResults() + + // create a localstorage session + localStorage.setItem('co_results_page_size', e.pageSize.toString()) + } + + /** + * Performs the overriding action through the Store + */ + setResultStatus(results: FeatureResult, status: 'Success' | 'Failed' | '') { + this._sharedActions.setResultStatus(results, status).subscribe(_ => { + this.getResults(); + }) + } + + openVideo(result: FeatureResult) { + this._sharedActions.loadingObservable( + this._sharedActions.checkVideo(result.video_url), + 'Loading video' + ).subscribe({ + next: _ => { + this._dialog.open(VideoComponent, { + backdropClass: 'video-player-backdrop', + panelClass: 'video-player-panel', + data: result + }) + }, + error: err => this._snack.open('An error ocurred', 'OK') + }) + } + + /** + * Clears runs depending on the type of clearing passed + * @param clearing ClearRunsType + * @returns void + */ + clearRuns(clearing: ClearRunsType) { + // Open Loading Snack + const loadingRef = this._snack.openFromComponent(LoadingSnack, { + data: 'Clearing history...', + duration: 60000 + }); + const featureId = +this._route.snapshot.params.feature; + const deleteTemplateWithResults = this._store.selectSnapshot(CustomSelectors.GetConfigProperty('deleteTemplateWithResults')); + this._api.removeMultipleFeatureRuns(featureId, clearing, deleteTemplateWithResults).subscribe({ + next: _ => { + this.getResults(); + }, + error: (err) => { + console.error(err) + }, + complete: () => { + // Close loading snack + loadingRef.dismiss(); + // Show completed snack + this._snack.open('History cleared', 'OK', { + duration: 5000 + }); + } + }) + } + + handleDeleteTemplateWithResults({ checked }: MatCheckboxChange) { + return this._store.dispatch(new Configuration.SetProperty('deleteTemplateWithResults', checked)); + } + + /** + * Enables or disables archived runs from checkbox + * @param change MatCheckboxChange + */ + handleArchived = (change: MatCheckboxChange) => this._store.dispatch(new Configuration.SetProperty('internal.showArchived', change.checked)); + ngOnInit() { this.featureId$ = this._route.paramMap.pipe( map(params => +params.get('feature')) ) - // Subscribe to URL params - this.featureRunsUrl$ = combineLatest([ - // Get featureId parameter - this.featureId$, - // Get latest value from archived$ - this.showArchived$ - ]).pipe( - // Return endpointUrl for paginated list - map(([featureId, archived]) => `feature_results_by_featureid/?feature_id=${featureId}&archived=${archived}`) - ) + this.query.size = parseInt(localStorage.getItem('co_results_page_size')) || 10; + this.getResults() + // Reload current page of runs whenever a feature run completes this._actions.pipe( untilDestroyed(this), ofActionDispatched(WebSockets.FeatureRunCompleted) ).subscribe(_ => { - if (this.paginatedList) this.paginatedList.reloadCurrentPage().subscribe() + this.getResults() }); } - ngAfterViewInit() { - // Change the run elements' visibility whenever the user changes the window size - combineLatest([ - this._store.select(CustomSelectors.RetrieveResultHeaders(true)).pipe( - // Add some virtual headers - map(headers => ([ - { id: 'bar', enable: true }, - // @ts-ignore - ...headers, - { id: 'video', enable: true}, - { id: 'options', enable: true } - ])) - ), - fromEvent(window, 'resize').pipe( - map((event: Event) => (event.target as Window).innerWidth), - startWith(window.innerWidth) - ) - ]).pipe( - untilDestroyed(this) - ).subscribe(([headers, windowWidth]) => { - var showVariables = []; - if (windowWidth < 600) { - // Mobile - showVariables = MainViewFieldsMobile; - } else if (windowWidth < 900) { - // Tablet Portrait - showVariables = MainViewFieldsTabletPortrait; - } else if (windowWidth < 1200) { - // Tablet Landscape - showVariables = MainViewFieldsTabletLandscape; - } else { - // Desktop - showVariables = MainViewFieldsDesktop; - } - for (let i = 0; i < headers.length; i++) { - if (showVariables.includes(headers[i].id)) { - document.documentElement.style.setProperty(`--${headers[i].id}-display`, headers.find(header => header.id === headers[i].id).enable ? 'flex' : 'none'); - document.documentElement.style.setProperty(`--${headers[i].id}-order`, (headers.findIndex(header => header.id === headers[i].id) + 1).toString()); - } else { - document.documentElement.style.setProperty(`--${headers[i].id}-display`, 'none'); - document.documentElement.style.setProperty(`--${headers[i].id}-order`, '0'); - } - } - }) - - this.isloaded = true; - } - // return to v2 dashboard returnToMain() { this._router.navigate(['/']);