diff --git a/.travis.yml b/.travis.yml index 5529afa0..8ddc20ba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,7 @@ services: before_install: # update docker - - curl https://releases.rancher.com/install-docker/17.05.sh | sudo bash - + - curl https://releases.rancher.com/install-docker/17.09.sh | sudo bash - # build the Backslash docker image - python scripts/travis_version_fix.py @@ -47,8 +47,10 @@ install: - python manage.py bootstrap --develop - python manage.py db upgrade + # required to work around https://github.com/dbcli/cli_helpers/issues/25 + - .env/bin/pip uninstall -y cli_helpers # (optional) install latest backlash-python - #- .env/bin/pip install -e git://github.com/getslash/backslash-python.git@develop#egg=backslash-python + - .env/bin/pip install -e git://github.com/getslash/backslash-python.git@develop#egg=backslash-python # run docker-compose setup in testing mode - sudo docker-compose -f docker/docker-compose.yml build @@ -62,8 +64,10 @@ script: # build the frontend to make sure we can serve '/' in the unit tests - ./node_modules/.bin/ember build - cd .. + - .env/bin/pip install -e git+https://github.com/getslash/backslash-python@develop#egg=backslash + - .env/bin/pip install -e git+https://github.com/getslash/slash@develop#egg=slash - .env/bin/py.test tests - - .env/bin/py.test integration_tests --app-url http://127.0.0.1:8000 --driver SauceLabs --capability browserName Chrome --capability platform Linux --capability version 48.0 --capability tunnelIdentifier $TRAVIS_JOB_NUMBER + - if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then .env/bin/py.test integration_tests --app-url http://127.0.0.1:8000 --driver SauceLabs --capability browserName Chrome --capability platform Linux --capability version 48.0 --capability tunnelIdentifier $TRAVIS_JOB_NUMBER; fi after_success: - .env/bin/python scripts/travis_docker_publish.py diff --git a/CHANGES.md b/CHANGES.md index b132a48d..4e8a45f0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,17 @@ # Changelog +## Version 2.12.0 + +* Backslash now supports reporting interruption exceptions to help determine what caused the session to be interrupted. When the sessions are reported with a compatible toolchain (for example, Slash >= 1.5.0), the session and test views will show the interruption exceptions and their contexts +* Added an option to display metadata values in an accessible location for tests and sessions +* Warnings are now deduplicated within sessions and tests, preventing DB bloat +* Added a view for test cases. This is planned to evolve into a suite management feature over the next releases +* Fixed the order of display of quick-jump items, and they are now sorted by name +* Added the ability to search by product type +* Added a "status_description" field for tests, allowing setting more informative information on what the test is currently doing (via API) +* Added timing metrics API, enabling tests and sessions to display the total time distribution spent while running +* Fixed indication of search term syntax errors + ## Version 2.11.1 * Fixed handling of test timespan when the test starts and before a keepalive is received diff --git a/_sample_suites/simple/test_1.py b/_sample_suites/simple/test_1.py index a5aec328..0cff6023 100644 --- a/_sample_suites/simple/test_1.py +++ b/_sample_suites/simple/test_1.py @@ -1,5 +1,7 @@ import slash +import warnings + def test_1(): pass @@ -7,7 +9,8 @@ def test_1(): @slash.tag('tag_without_value') def test_2(): - slash.logger.warning('This is a warning') + for i in range(5): + warnings.warn('This is a warning!') @slash.tag('tag_with_value', 'some_value') diff --git a/deps/develop.txt b/deps/develop.txt index d9318460..f15281e6 100644 --- a/deps/develop.txt +++ b/deps/develop.txt @@ -7,3 +7,4 @@ tmuxp livereload ipython slash +yarl diff --git a/docker/Dockerfile b/docker/Dockerfile index 0acf0309..f1d060d0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,7 +4,7 @@ RUN npm install -g ember-cli bower ADD ./webapp/ /frontend/ RUN cd /frontend/ && yarn install && bower install --allow-root -RUN cd /frontend/ && node_modules/.bin/ember build +RUN cd /frontend/ && node_modules/.bin/ember build --environment production diff --git a/docs/installation.rst b/docs/installation.rst index 782b0268..335d6d03 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -87,3 +87,14 @@ You can start Backslash by running:: Now that the server is up and running, it's time to configure your server. You can read about it in the :ref:`configuration` section. + + + +Upgrade +------- + +The way to upgrade an existing deployment to the latest version is: +1. update the docker image: + $ docker pull getslash/backslash +2. restart the daemon: + $ sudo systemctl restart backslash-docker diff --git a/docs/server_configuration.rst b/docs/server_configuration.rst index e786a729..dde9efa9 100644 --- a/docs/server_configuration.rst +++ b/docs/server_configuration.rst @@ -94,6 +94,24 @@ page, you can specify it via the ``test_metadata_links`` variable:: The above will add a link to the test page pointing at the Jenkins build whenever a "jenkins_url" metadata key is found for a test +Metadata Details +~~~~~~~~~~~~~~~~ + +Similar to metadata links, you can choose specific metadata keys to be displayed as informational values for tests and/or sessionsL:: + + ... + test_metadata_display_items: + - key: my_metadata_key + name: My Metadata Key + +Or:: + + ... + session_metadata_display_items: + - key: my_metadata_key + name: My Metadata Key + + Deployment Customization ------------------------ diff --git a/flask_app/app.yml b/flask_app/app.yml index 2cfc65a3..b054d535 100644 --- a/flask_app/app.yml +++ b/flask_app/app.yml @@ -21,3 +21,7 @@ display_names: related_entity: related test_metadata_links: [] + +session_metadata_display_items: [] + +test_metadata_display_items: [] diff --git a/flask_app/blueprints/api/errors.py b/flask_app/blueprints/api/errors.py index 4277ad51..4b6fe144 100644 --- a/flask_app/blueprints/api/errors.py +++ b/flask_app/blueprints/api/errors.py @@ -20,12 +20,15 @@ NoneType = type(None) -@API(version=3) -def add_error(message: str, exception_type: (str, NoneType)=None, traceback: (list, NoneType)=None, timestamp: (float, int)=None, test_id: int=None, session_id: int=None, is_failure: bool=False): # pylint: disable=bad-whitespace +@API(version=4) +def add_error(message: str, exception_type: (str, NoneType)=None, traceback: (list, NoneType)=None, timestamp: (float, int)=None, test_id: int=None, session_id: int=None, is_failure: bool=False, is_interruption: bool=False): # pylint: disable=bad-whitespace # pylint: disable=superfluous-parens if not ((test_id is not None) ^ (session_id is not None)): error_abort('Either test_id or session_id required') + if is_failure and is_interruption: + error_abort('Interruptions cannot be marked as failures') + if timestamp is None: timestamp = get_current_time() if test_id is not None: @@ -37,16 +40,22 @@ def add_error(message: str, exception_type: (str, NoneType)=None, traceback: (li try: obj = cls.query.filter(cls.id == object_id).one() - increment_field = cls.num_failures if is_failure else cls.num_errors + if is_failure: + increment_field = cls.num_failures + elif is_interruption: + increment_field = cls.num_interruptions + else: + increment_field = cls.num_errors cls.query.filter(cls.id == object_id).update( {increment_field: increment_field + 1}) err = Error(message=message, exception_type=exception_type, traceback_url=_normalize_traceback_get_url(traceback), + is_interruption=is_interruption, is_failure=is_failure, timestamp=timestamp) obj.errors.append(err) - if obj.end_time is not None: + if not is_interruption and obj.end_time is not None: if cls is Test: if is_failure and obj.status not in (statuses.FAILURE, statuses.ERROR): obj.status = statuses.FAILURE diff --git a/flask_app/blueprints/api/main.py b/flask_app/blueprints/api/main.py index 016afded..460a846e 100644 --- a/flask_app/blueprints/api/main.py +++ b/flask_app/blueprints/api/main.py @@ -2,7 +2,7 @@ import logbook import requests -from flask import abort, current_app +from flask import abort from flask_simple_api import error_abort from flask_security import current_user @@ -11,7 +11,7 @@ from .blueprint import API from ... import metrics -from ...models import db, Session, Test, Comment, User, Role, Warning, Entity, TestVariation, TestMetadata +from ...models import db, Session, Test, Comment, User, Role, Entity, TestVariation, TestMetadata from ...utils import get_current_time, statuses from ...utils.api_utils import requires_role from ...utils.subjects import get_or_create_subject_instance @@ -29,6 +29,8 @@ from . import errors # pylint: disable=unused-import from . import labels # pylint: disable=unused-import from . import quick_search # pylint: disable=unused-import +from . import timing # pylint: disable=unused-import +from . import warnings # pylint: disable=unused-import from .blueprint import blueprint # pylint: disable=unused-import @@ -172,6 +174,12 @@ def report_test_distributed( return test +@API +def update_status_description(test_id: int, description: str): + Test.query.get_or_404(test_id).status_description = description + db.session.commit() + + @API def report_test_end(id: int, duration: (float, int)=None): test = Test.query.get(id) @@ -195,7 +203,7 @@ def report_test_end(id: int, duration: (float, int)=None): elif not test.interrupted and not test.skipped: test.status = statuses.SUCCESS - db.session.add(test) + test.status_description = None db.session.commit() @@ -260,30 +268,6 @@ def _update_running_test_status(test_id, status, ignore_conflict=False, addition abort(requests.codes.not_found) -@API -def add_warning(message: str, filename: str=None, lineno: int=None, test_id: int=None, session_id: int=None, timestamp: (int, float)=None): - # pylint: disable=superfluous-parens - if not ((test_id is not None) ^ (session_id is not None)): - error_abort('Either session_id or test_id required') - if session_id is not None: - obj = Session.query.get_or_404(session_id) - else: - obj = Test.query.get_or_404(test_id) - if timestamp is None: - timestamp = get_current_time() - if obj.num_warnings < current_app.config['MAX_WARNINGS_PER_ENTITY']: - db.session.add( - Warning(message=message, timestamp=timestamp, filename=filename, lineno=lineno, test_id=test_id, session_id=session_id)) - obj.num_warnings = type(obj).num_warnings + 1 - if session_id is None: - obj.session.num_test_warnings = Session.num_test_warnings + 1 - db.session.add(obj.session) - - db.session.add(obj) - db.session.commit() - - - @API(require_real_login=True) def post_comment(comment: str, session_id: int=None, test_id: int=None): if not (session_id is not None) ^ (test_id is not None): diff --git a/flask_app/blueprints/api/quick_search.py b/flask_app/blueprints/api/quick_search.py index 7e390009..2e51db9b 100644 --- a/flask_app/blueprints/api/quick_search.py +++ b/flask_app/blueprints/api/quick_search.py @@ -17,7 +17,10 @@ def quick_search(term: str): (select email as key, CASE WHEN first_name is NULL THEN email ELSE (first_name || ' ' || last_name || ' (' || email || ')') END as name, 'user' as type from "user")) u - where u.name ilike :term limit :num_hits""").params( + where u.name ilike :term + ORDER BY name asc + limit :num_hits + """).params( term='%{}%'.format(term), num_hits=num_hits, ) diff --git a/flask_app/blueprints/api/timing.py b/flask_app/blueprints/api/timing.py new file mode 100644 index 00000000..927913d7 --- /dev/null +++ b/flask_app/blueprints/api/timing.py @@ -0,0 +1,41 @@ +import flux +from ...models import Timing, db +from ...utils.db_utils import json_object_agg +from .blueprint import API + +from sqlalchemy import case + +NoneType = type(None) + + +@API +def report_timing_start(name: str, session_id: int, test_id: (int, NoneType)=None): # pylint: disable=bad-whitespace + db.session.execute( + ''' + INSERT INTO timing(session_id, test_id, name, total) + VALUES (:session_id, :test_id, :name, :interval) + ON CONFLICT(id) DO UPDATE SET total = timing.total + EXCLUDED.total''', + {'session_id': session_id, 'test_id': test_id, 'name': name, 'interval': -flux.current_timeline.time()}) + db.session.commit() + + +@API +def report_timing_end(name: str, session_id: int, test_id: (int, NoneType)=None): # pylint: disable=bad-whitespace + timing = Timing.query.filter_by(session_id=session_id, test_id=test_id, name=name).first_or_404() + timing.total = Timing.total + flux.current_timeline.time() + db.session.commit() + + +@API +def get_timings(session_id: (int, NoneType)=None, test_id: (int, NoneType)=None): + now = flux.current_timeline.time() + total_clause = case( + [ + (Timing.total < 0, now - Timing.total) + ], else_=Timing.total) + kwargs = {'test_id': test_id} + if session_id is not None: + kwargs['session_id'] = session_id + query = db.session.query(json_object_agg(Timing.name, total_clause)).\ + filter_by(**kwargs) + return query.scalar() or {} diff --git a/flask_app/blueprints/api/warnings.py b/flask_app/blueprints/api/warnings.py new file mode 100644 index 00000000..2903df61 --- /dev/null +++ b/flask_app/blueprints/api/warnings.py @@ -0,0 +1,37 @@ +# pylint: disable=bad-whitespace +from flask import current_app +from flask_simple_api import error_abort +from .blueprint import API +from ...models import Session, Warning, Test, db +from ...utils import get_current_time + + +@API +def add_warning(message:str, filename:str=None, lineno:int=None, test_id:int=None, session_id:int=None, timestamp:(int,float)=None): + # pylint: disable=superfluous-parens + if not ((test_id is not None) ^ (session_id is not None)): + error_abort('Either session_id or test_id required') + + if session_id is not None: + obj = Session.query.get_or_404(session_id) + else: + obj = Test.query.get_or_404(test_id) + + if timestamp is None: + timestamp = get_current_time() + + warning = Warning.query.filter_by(session_id=session_id, test_id=test_id, lineno=lineno, filename=filename, message=message).first() + if warning is None: + if obj.num_warnings < current_app.config['MAX_WARNINGS_PER_ENTITY']: + warning = Warning(message=message, timestamp=timestamp, filename=filename, lineno=lineno, test_id=test_id, session_id=session_id) + db.session.add(warning) + else: + warning.num_warnings = Warning.num_warnings + 1 + warning.timestamp = timestamp + + obj.num_warnings = type(obj).num_warnings + 1 + if session_id is None: + obj.session.num_test_warnings = Session.num_test_warnings + 1 + db.session.add(obj.session) + + db.session.commit() diff --git a/flask_app/blueprints/rest.py b/flask_app/blueprints/rest.py index b2e389b9..2775da8a 100644 --- a/flask_app/blueprints/rest.py +++ b/flask_app/blueprints/rest.py @@ -85,6 +85,7 @@ def _get_iterator(self): test_query_parser.add_argument('search', type=str, default=None) test_query_parser.add_argument('after_index', type=int, default=None) test_query_parser.add_argument('before_index', type=int, default=None) +test_query_parser.add_argument('id', type=str, default=None) @_resource('/tests', '/tests/', '/sessions//tests') @@ -100,6 +101,9 @@ def _get_object_by_id(self, object_id): def _get_iterator(self): args = test_query_parser.parse_args() + if args.id is not None: + return _get_query_by_id_or_logical_id(self.MODEL, args.id) + if args.session_id is None: args.session_id = request.view_args.get('session_id') @@ -207,6 +211,7 @@ def _get_object_by_id_or_logical_id(model, object_id): errors_query_parser = reqparse.RequestParser() errors_query_parser.add_argument('session_id', default=None) errors_query_parser.add_argument('test_id', default=None) +errors_query_parser.add_argument('interruptions', default=False, type=bool) @_resource('/warnings', '/warnings/') @@ -231,10 +236,14 @@ def _get_iterator(self): args = errors_query_parser.parse_args() if args.session_id is not None: - return Error.query.filter_by(session_id=parse_session_id(args.session_id)) + query = Error.query.filter_by(session_id=parse_session_id(args.session_id)) elif args.test_id is not None: - return Error.query.filter_by(test_id=parse_test_id(args.test_id)) - abort(requests.codes.bad_request) + query = Error.query.filter_by(test_id=parse_test_id(args.test_id)) + else: + abort(requests.codes.bad_request) + + query = query.filter_by(is_interruption=args.interruptions) + return query @blueprint.route('/tracebacks/') @@ -370,3 +379,19 @@ class MigrationsResource(ModelResource): 'total_num_objects', 'remaining_num_objects' ] + + +@_resource('/cases', '/cases/') +class CaseResource(ModelResource): + + MODEL = models.TestInformation + DEFAULT_SORT = (models.TestInformation.name, models.TestInformation.file_name, models.TestInformation.class_name) + + def _get_iterator(self): + search = request.args.get('search') + if search: + returned = get_orm_query_from_search_string('case', search, abort_on_syntax_error=True) + else: + returned = super()._get_iterator() + returned = returned.filter(~self.MODEL.file_name.like('/%')) + return returned diff --git a/flask_app/config.py b/flask_app/config.py index abf280d6..7ed9d810 100644 --- a/flask_app/config.py +++ b/flask_app/config.py @@ -16,8 +16,15 @@ def get_runtime_config_private_dict(): 'debug': current_app.config['DEBUG'], 'version': __version__, 'setup_needed': True, - 'display_names': current_app.config['display_names'], - 'test_metadata_links': current_app.config['test_metadata_links'], + **{ + key: current_app.config[key] + for key in ( + 'display_names', + 'test_metadata_links', + 'session_metadata_display_items', + 'test_metadata_display_items', + ) + } } returned.update( (cfg.key, cfg.value) diff --git a/flask_app/models.py b/flask_app/models.py index e576e395..4e7d8274 100644 --- a/flask_app/models.py +++ b/flask_app/models.py @@ -150,6 +150,7 @@ class Session(db.Model, TypenameMixin, StatusPredicatesMixin, HasSubjectsMixin, num_error_tests = db.Column(db.Integer, default=0) num_skipped_tests = db.Column(db.Integer, default=0) num_finished_tests = db.Column(db.Integer, default=0) + num_interruptions = db.Column(db.Integer, default=0) num_interrupted_tests = db.Column(db.Integer, server_default="0") num_warnings = db.Column(db.Integer, nullable=False, server_default="0") num_test_warnings = db.Column(db.Integer, nullable=False, server_default="0") @@ -339,7 +340,7 @@ class TestInformation(db.Model): @classmethod def get_typename(cls): - return 'test_info' + return 'case' @@ -451,6 +452,7 @@ def last_comment(self): is_interactive = db.Column(db.Boolean, server_default='FALSE') status = db.Column(db.String(20), nullable=False, default=statuses.STARTED) + status_description = db.Column(db.String(1024), nullable=True) skip_reason = db.Column(db.Text(), nullable=True) @@ -458,6 +460,7 @@ def last_comment(self): num_failures = db.Column(db.Integer, default=0) num_comments = db.Column(db.Integer, default=0) num_warnings = db.Column(db.Integer, nullable=False, server_default="0") + num_interruptions = db.Column(db.Integer, default=0) __table_args__ = ( Index('ix_test_start_time', start_time.desc()), @@ -513,6 +516,7 @@ class Error(db.Model, TypenameMixin): message = db.Column(db.Text()) timestamp = db.Column(db.Float, default=get_current_time) is_failure = db.Column(db.Boolean, default=False) + is_interruption = db.Column(db.Boolean, default=False) test_id = db.Column(db.ForeignKey('test.id', ondelete='CASCADE'), nullable=True, index=True) session_id = db.Column(db.ForeignKey('session.id', ondelete='CASCADE'), nullable=True, index=True) @@ -531,8 +535,13 @@ class Warning(db.Model, TypenameMixin): filename = db.Column(db.String(2048), nullable=True) lineno = db.Column(db.Integer, nullable=True) timestamp = db.Column(db.Float, nullable=False) + num_warnings = db.Column(db.Integer, nullable=True, default=1) - + __table_args__ = ( + Index('ix_warning_details', + session_id, test_id, filename, lineno, + postgresql_where=(session_id == None)), # pylint: disable=singleton-comparison + ) roles_users = db.Table('roles_users', db.Column('user_id', db.Integer(), db.ForeignKey('user.id', ondelete='CASCADE')), @@ -680,3 +689,19 @@ class BackgroundMigration(db.Model): @classmethod def get_typename(cls): return 'migration' + + +class Timing(db.Model): + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(1024), nullable=False) + total = db.Column(db.Float(), default=0) + + session_id = db.Column(db.ForeignKey('session.id', ondelete='CASCADE'), nullable=False) + test_id = db.Column(db.ForeignKey('test.id', ondelete='CASCADE'), nullable=True) + + __table_args__= ( + Index('ix_timing_test', test_id, name, postgresql_where=(test_id != None), unique=True), + Index('ix_timing_session', session_id, name), + Index('ix_timing_session_no_test', session_id, name, postgresql_where=(test_id == None), unique=True), # pylint: disable=singleton-comparison + ) diff --git a/flask_app/search/logic.py b/flask_app/search/logic.py index 7760e013..9e2c1b4d 100644 --- a/flask_app/search/logic.py +++ b/flask_app/search/logic.py @@ -1,18 +1,21 @@ -import time -import threading import operator +import threading +import time from flask import current_app from sqlalchemy import func + from psycopg2.extras import DateTimeTZRange -from ..models import Test, TestInformation, User, Session, db, session_label, Label, session_subject, SubjectInstance, Subject, Entity, session_entity, ProductVersion, ProductRevision, SessionMetadata from . import value_parsers +from ..filters import builders as filter_builders +from ..models import (Entity, Label, Product, ProductRevision, ProductVersion, Session, + SessionMetadata, Subject, SubjectInstance, Test, + TestInformation, User, db, session_entity, session_label, + session_subject) from .exceptions import UnknownField from .helpers import only_ops -from ..filters import builders as filter_builders - _current = threading.local() @@ -29,7 +32,7 @@ class SearchContext(object): def get_base_query(self): raise NotImplementedError() # pragma: no cover - def get_fallback_filter(self, search_term): + def get_fallback_filter(self, term): raise NotImplementedError() # pragma: no cover def __enter__(self): @@ -45,6 +48,8 @@ def get_for_type(cls, objtype): return TestSearchContext() if objtype is Session or (isinstance(objtype, str) and objtype.lower() == 'session'): return SessionSearchContext() + if objtype is TestInformation or (isinstance(objtype, str) and objtype.lower() == 'case'): + return TestCaseSearchContext() raise NotImplementedError() # pragma: no cover def search__start_time(self, op, value): @@ -149,6 +154,11 @@ def search__product_version(self, op, value): subquery = db.session.query(session_subject).join(SubjectInstance).join(ProductRevision).join(ProductVersion).filter(ProductVersion.version == value, session_subject.c.session_id == Test.session_id).exists().correlate(Test) return _negate_maybe(op, subquery) + @only_ops(['=', '!=']) + def search__product_type(self, op, value): + subquery = db.session.query(session_subject).join(SubjectInstance).join(ProductRevision).join(Product).filter(Product.name == value, session_subject.c.session_id == Test.session_id).exists().correlate(Test) + return _negate_maybe(op, subquery) + @only_ops(['=', '!=']) def search__label(self, op, value): labels = Label.query.filter(Label.name==value).all() @@ -212,6 +222,11 @@ def search__product_version(self, op, value): subquery = db.session.query(session_subject).join(SubjectInstance).join(ProductRevision).join(ProductVersion).filter(ProductVersion.version == value, session_subject.c.session_id == Session.id).exists().correlate(Session) return _negate_maybe(op, subquery) + @only_ops(['=', '!=']) + def search__product(self, op, value): + subquery = db.session.query(session_subject).join(SubjectInstance).join(ProductRevision).join(ProductVersion).join(Product).filter(Product.name == value, session_subject.c.session_id == Session.id).exists().correlate(Session) + return _negate_maybe(op, subquery) + @only_ops(['=']) def search__test(self, op, value): # pylint: disable=unused-argument return db.session.query(Test).join(TestInformation).filter(Test.session_id == Session.id, TestInformation.name == value).exists().correlate(Session) @@ -236,6 +251,26 @@ def _metadata_query(self, op, key, subkey, value): return _negate_maybe(op, returned) +class TestCaseSearchContext(SearchContext): + + MODEL = TestInformation + SEARCHABLE_FIELDS = { + 'id': True, + } + + def get_base_query(self): + return TestInformation.query + + def get_fallback_filter(self, term): + return TestInformation.name.contains(term) | TestInformation.file_name.contains(term) | TestInformation.class_name.contains(term) + + @only_ops(['=', '!=', '~']) + def search__file_name(self, op, value): + field = self.MODEL.file_name + return _negate_maybe(op, op.func(field, value)) + + + def _negate_maybe(op, query): if op.op == '!=': diff --git a/flask_app/search/search.py b/flask_app/search/search.py index 6b94e496..b19b1ae8 100644 --- a/flask_app/search/search.py +++ b/flask_app/search/search.py @@ -15,7 +15,7 @@ def get_orm_query_from_search_string(object_type, query, abort_on_syntax_error=F except SearchSyntaxError as e: if not abort_on_syntax_error: raise - error_abort('Syntax Error', code=requests.codes.bad_request, extra={'errors': [{'detail': e.reason}]}) + error_abort('Syntax Error', code=requests.codes.bad_request, extra={'errors': [{'syntax_error': e.reason}]}) return returned __all__ = ['get_orm_query_from_search_string'] diff --git a/flask_app/search/syntax.py b/flask_app/search/syntax.py index 8ec310e9..e6a96ac2 100644 --- a/flask_app/search/syntax.py +++ b/flask_app/search/syntax.py @@ -104,7 +104,7 @@ def _handle_logical_argument(arg): identifier + RPAR).setParseAction(_handle_func_call) -atom = func_call | identifier | (DQUOTE + Word(alphanums_plus + ' ') + DQUOTE) | (SQUOTE + Word(alphanums_plus + ' ') + SQUOTE) +atom = func_call | identifier | (DQUOTE + Word(alphanums_plus + ' <>') + DQUOTE) | (SQUOTE + Word(alphanums_plus + ' <>') + SQUOTE) binop = oneOf(list(_OPERATORS)) and_ = Keyword("and", caseless=True) diff --git a/flask_app/utils/rendering.py b/flask_app/utils/rendering.py index cb2782a3..5516af63 100644 --- a/flask_app/utils/rendering.py +++ b/flask_app/utils/rendering.py @@ -18,7 +18,7 @@ def render_api_object(obj, only_fields=None, extra_fields=None, is_single=False) except NotImplementedError: continue - if value is None and python_type is bool and column.default is not None: + if value is None and python_type in (bool, int) and column.default is not None: value = column.default.arg returned[field_name] = value diff --git a/integration_tests/ui/conftest.py b/integration_tests/ui/conftest.py index 8c4df03d..834cb22a 100644 --- a/integration_tests/ui/conftest.py +++ b/integration_tests/ui/conftest.py @@ -7,14 +7,28 @@ @pytest.fixture(scope='session') def recorded_session(integration_url): - session_id = run_suite(integration_url) + return _get_recorded_session(integration_url) + + +@pytest.fixture(scope='session') +def recorded_interrupted_session(integration_url): + return _get_recorded_session(integration_url, name='interrupted', interrupt=True) + + +def _get_recorded_session(integration_url, **kwargs): + session_id = run_suite(integration_url, **kwargs) return Munch(id=session_id) + @pytest.fixture def ui_session(recorded_session, ui): # pylint: disable=unused-argument assert recorded_session.id is not None ui.driver.refresh() - return ui.driver.find_element_by_xpath(f"//a[@href='/#/sessions/{recorded_session.id}']") + return ui.find_session_link(recorded_session) + +@pytest.fixture +def ui_interrupted_session(recorded_interrupted_session, ui): + return ui.find_session_link(recorded_interrupted_session) @pytest.fixture def ui(has_selenium, selenium, integration_url): # pylint: disable=unused-argument @@ -84,3 +98,7 @@ def logout(self): def assert_no_element(self, css_selector): assert self.driver.find_elements_by_css_selector(css_selector) == [] + + def find_session_link(self, session): + self.driver.refresh() + return self.driver.find_element_by_xpath(f"//a[@href='/#/sessions/{session.id}']") diff --git a/integration_tests/ui/test_cases_view.py b/integration_tests/ui/test_cases_view.py new file mode 100644 index 00000000..d170eeb1 --- /dev/null +++ b/integration_tests/ui/test_cases_view.py @@ -0,0 +1,2 @@ +def test_test_cases_view(ui): + ui.driver.find_element_by_link_text('Test Cases').click() diff --git a/integration_tests/ui/test_interrupted_sessions.py b/integration_tests/ui/test_interrupted_sessions.py new file mode 100644 index 00000000..a133e1f5 --- /dev/null +++ b/integration_tests/ui/test_interrupted_sessions.py @@ -0,0 +1,13 @@ +def test_session_interruption(ui, ui_interrupted_session): + ui_interrupted_session.click() + interrupted_test = ui.driver.find_element_by_css_selector('a.test') + css_classes = interrupted_test.get_attribute('class') + assert 'success' not in css_classes + assert 'fail' not in css_classes + interrupted_test.click() + assert 'errors' not in ui.driver.current_url + ui.driver.find_element_by_partial_link_text('Interruptions').click() + error_boxes = ui.driver.find_elements_by_class_name('error-box') + assert len(error_boxes) == 1 + [err] = error_boxes + assert 'interruption' in err.get_attribute('class') diff --git a/integration_tests/ui/test_sessions.py b/integration_tests/ui/test_sessions.py new file mode 100644 index 00000000..25568ff5 --- /dev/null +++ b/integration_tests/ui/test_sessions.py @@ -0,0 +1,5 @@ +from yarl import URL + +def test_session_not_found(ui): + ui.driver.get(str(URL(ui.driver.current_url).with_fragment('/sessions/blap'))) + assert ui.driver.find_element_by_css_selector(".error-details h1").text.lower() == 'not found' diff --git a/integration_tests/utils.py b/integration_tests/utils.py index 9762f717..9460152c 100644 --- a/integration_tests/utils.py +++ b/integration_tests/utils.py @@ -10,7 +10,7 @@ from slash.frontend.slash_run import slash_run -def run_suite(backslash_url, name='simple'): +def run_suite(backslash_url, name='simple', interrupt=False): session_id = None @@ -26,6 +26,10 @@ def session_start(): plugins.manager.install(plugin, activate=True) stack.callback(plugins.manager.uninstall, plugin) - slash_run([os.path.join('_sample_suites', name), '--session-label', 'testing']) + try: + slash_run([os.path.join('_sample_suites', name), '--session-label', 'testing']) + except KeyboardInterrupt: + if not interrupt: + raise return session_id diff --git a/migrations/versions/4af90bfc558d_add_error_is_interruption.py b/migrations/versions/4af90bfc558d_add_error_is_interruption.py new file mode 100644 index 00000000..f63f0a5a --- /dev/null +++ b/migrations/versions/4af90bfc558d_add_error_is_interruption.py @@ -0,0 +1,26 @@ +"""Add Error.is_interruption + +Revision ID: 4af90bfc558d +Revises: f0bfc3f425c7 +Create Date: 2017-10-19 13:58:05.413915 + +""" + +# revision identifiers, used by Alembic. +revision = '4af90bfc558d' +down_revision = 'f0bfc3f425c7' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('error', sa.Column('is_interruption', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('error', 'is_interruption') + # ### end Alembic commands ### diff --git a/migrations/versions/534d739192db_add_interruption_counters.py b/migrations/versions/534d739192db_add_interruption_counters.py new file mode 100644 index 00000000..0538eda6 --- /dev/null +++ b/migrations/versions/534d739192db_add_interruption_counters.py @@ -0,0 +1,28 @@ +"""Add interruption counters + +Revision ID: 534d739192db +Revises: 4af90bfc558d +Create Date: 2017-10-19 15:21:47.063851 + +""" + +# revision identifiers, used by Alembic. +revision = '534d739192db' +down_revision = '4af90bfc558d' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('session', sa.Column('num_interruptions', sa.Integer(), nullable=True)) + op.add_column('test', sa.Column('num_interruptions', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('test', 'num_interruptions') + op.drop_column('session', 'num_interruptions') + # ### end Alembic commands ### diff --git a/migrations/versions/95dad744de97_add_test_status_description.py b/migrations/versions/95dad744de97_add_test_status_description.py new file mode 100644 index 00000000..251643de --- /dev/null +++ b/migrations/versions/95dad744de97_add_test_status_description.py @@ -0,0 +1,26 @@ +"""Add test.status_description + +Revision ID: 95dad744de97 +Revises: 7de4c23aaddd +Create Date: 2017-10-09 13:03:19.677995 + +""" + +# revision identifiers, used by Alembic. +revision = '95dad744de97' +down_revision = '7de4c23aaddd' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('test', sa.Column('status_description', sa.String(length=1024), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('test', 'status_description') + # ### end Alembic commands ### diff --git a/migrations/versions/ad9b81f99615_add_timing_metrics.py b/migrations/versions/ad9b81f99615_add_timing_metrics.py new file mode 100644 index 00000000..34867a9e --- /dev/null +++ b/migrations/versions/ad9b81f99615_add_timing_metrics.py @@ -0,0 +1,41 @@ +"""Add timing metrics + +Revision ID: ad9b81f99615 +Revises: 95dad744de97 +Create Date: 2017-10-10 14:40:42.445701 + +""" + +# revision identifiers, used by Alembic. +revision = 'ad9b81f99615' +down_revision = '95dad744de97' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('timing', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=1024), nullable=False), + sa.Column('total', sa.Float(), nullable=True), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('test_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['session_id'], ['session.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['test_id'], ['test.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_timing_session', 'timing', ['session_id', 'name'], unique=False) + op.create_index('ix_timing_session_no_test', 'timing', ['session_id', 'name'], unique=True, postgresql_where=sa.text('test_id IS NULL')) + op.create_index('ix_timing_test', 'timing', ['test_id', 'name'], unique=True, postgresql_where=sa.text('test_id IS NOT NULL')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_timing_test', table_name='timing') + op.drop_index('ix_timing_session_no_test', table_name='timing') + op.drop_index('ix_timing_session', table_name='timing') + op.drop_table('timing') + # ### end Alembic commands ### diff --git a/migrations/versions/f0bfc3f425c7_add_warning_deduplication.py b/migrations/versions/f0bfc3f425c7_add_warning_deduplication.py new file mode 100644 index 00000000..c9826b85 --- /dev/null +++ b/migrations/versions/f0bfc3f425c7_add_warning_deduplication.py @@ -0,0 +1,28 @@ +"""Add warning deduplication + +Revision ID: f0bfc3f425c7 +Revises: ad9b81f99615 +Create Date: 2017-10-15 13:37:53.475324 + +""" + +# revision identifiers, used by Alembic. +revision = 'f0bfc3f425c7' +down_revision = 'ad9b81f99615' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('warning', sa.Column('num_warnings', sa.Integer(), nullable=True)) + op.create_index('ix_warning_details', 'warning', ['session_id', 'test_id', 'filename', 'lineno'], unique=False, postgresql_where=sa.text('session_id IS NULL')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_warning_details', table_name='warning') + op.drop_column('warning', 'num_warnings') + # ### end Alembic commands ### diff --git a/tests/conftest.py b/tests/conftest.py index 8cf8abf6..c6eeba1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -408,8 +408,6 @@ def invalid_variation(request): return request.param - - @pytest.fixture def test_info(file_name, test_name, class_name): return {'file_name': file_name, 'name': test_name, 'class_name': class_name} @@ -423,6 +421,9 @@ def error_container(request, client): def label_container(request, client): return _get_api_object_by_typename(client=client, typename=request.param) +@pytest.fixture(params=['session', 'test']) +def timing_container(request, client): + return _get_api_object_by_typename(client=client, typename=request.param) @pytest.fixture(params=['session', 'test']) def related_entity_container(request, client): diff --git a/tests/test_test_api.py b/tests/test_test_api.py index 9416d560..1e24c977 100644 --- a/tests/test_test_api.py +++ b/tests/test_test_api.py @@ -11,6 +11,15 @@ def test_test_information_filename(started_test, file_name): assert started_test.info['file_name'] == file_name +def test_test_status_description(started_test): + assert started_test.status_description is None + description = 'blap' + started_test.update_status_description(description) + assert started_test.refresh().status_description == description + started_test.report_end() + assert started_test.refresh().status_description is None + + def test_test_information_classname(started_test, class_name): assert started_test.info['class_name'] == class_name diff --git a/tests/test_timing_api.py b/tests/test_timing_api.py new file mode 100644 index 00000000..ac8dbd7e --- /dev/null +++ b/tests/test_timing_api.py @@ -0,0 +1,29 @@ +import flux +import pytest + + +def test_timing_start_end(timing_container, timing_action): + assert timing_container.get_timings() == {} + start_time = flux.current_timeline.time() + timing_container.report_timing_start(timing_action) + _validate_timing_ongoing(timing_container, timing_action) + timing_container.report_timing_end(timing_action) + total_time = flux.current_timeline.time() - start_time + assert timing_container.get_timings()[timing_action] == total_time + + +def _validate_timing_ongoing(timing_container, timing_action): + prev = None + for _ in range(3): + timings = timing_container.get_timings() + assert len(timings) == 1 + if prev is not None: + assert 0 < prev < timings[timing_action] + else: + prev = timings[timing_action] + assert prev > 0 + flux.current_timeline.sleep(1) + +@pytest.fixture +def timing_action(): + return 'waiting for something' diff --git a/tests/test_warnings.py b/tests/test_warnings.py index ba36f2c3..bf478ead 100644 --- a/tests/test_warnings.py +++ b/tests/test_warnings.py @@ -13,6 +13,7 @@ def test_add_warnings(warning_container, filename, lineno, message, timestamp): assert fetched.lineno == lineno assert fetched.timestamp == timestamp assert fetched.filename == filename + assert fetched.num_warnings == 1 if isinstance(warning_container, test.Test): assert warning_container.get_session().num_test_warnings == 1 assert warning_container.get_session().num_warnings == 0 @@ -27,14 +28,25 @@ def test_max_warnings_per_entity(warning_container, message, webapp): max_warnings = 3 webapp.app.config['MAX_WARNINGS_PER_ENTITY'] = max_warnings - for _ in range(max_warnings + 1): - warning_container.add_warning(message=message) + for i in range(max_warnings + 1): + warning_container.add_warning(message=f'{message}{i}' ) warning_container.refresh() assert warning_container.num_warnings == max_warnings + 1 assert len(warning_container.query_warnings().all()) == max_warnings + +def test_add_warning_twice(warning_container, filename, lineno, message, timestamp): + num_warnings = 5 + for i in range(num_warnings): + warning_container.add_warning( + filename=filename, lineno=lineno, message=message, + timestamp=timestamp + i) + [fetched] = warning_container.query_warnings() + assert fetched.num_warnings == num_warnings + + @pytest.fixture def filename(): return 'some_filename.py' diff --git a/webapp/.ember-cli b/webapp/.ember-cli index 927fabe4..b4934f38 100644 --- a/webapp/.ember-cli +++ b/webapp/.ember-cli @@ -5,6 +5,6 @@ Setting `disableAnalytics` to true will prevent any data from being sent. */ - "disableAnalytics": false, + "disableAnalytics": true, "usePods": true } diff --git a/webapp/.gitignore b/webapp/.gitignore index 5ad14dd6..8fa39a63 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -14,4 +14,10 @@ /coverage/* /libpeerconnection.log npm-debug.log* +yarn-error.log testem.log + +# ember-try +.node_modules.ember-try/ +bower.json.ember-try +package.json.ember-try diff --git a/webapp/app/app.js b/webapp/app/app.js index cfc235b5..a706aaff 100644 --- a/webapp/app/app.js +++ b/webapp/app/app.js @@ -1,11 +1,9 @@ -import Ember from "ember"; -import Resolver from "./resolver"; -import loadInitializers from "ember-load-initializers"; -import config from "./config/environment"; +import Application from '@ember/application'; +import Resolver from './resolver'; +import loadInitializers from 'ember-load-initializers'; +import config from './config/environment'; -let App; - -App = Ember.Application.extend({ +const App = Application.extend({ modulePrefix: config.modulePrefix, podModulePrefix: config.podModulePrefix, Resolver diff --git a/webapp/app/application/route.js b/webapp/app/application/route.js index 8fa90943..fd1d6d82 100644 --- a/webapp/app/application/route.js +++ b/webapp/app/application/route.js @@ -41,10 +41,10 @@ export default Ember.Route.extend(ApplicationRouteMixin, { load_current_user() { let self = this; if (self.get("session.data.authenticated")) { - return retry(() => { - return self.store.queryRecord("user", {current_user: true}); - }).then(function(u) { - self.set("session.data.authenticated.current_user", u); + return retry(async function() { + let users = await self.store.query("user", {current_user: true}); + let user = await users.get('firstObject'); + self.set("session.data.authenticated.current_user", user); return self.get("user_prefs").get_all(); }); } diff --git a/webapp/app/application/template.hbs b/webapp/app/application/template.hbs index c8042373..9d6a324e 100644 --- a/webapp/app/application/template.hbs +++ b/webapp/app/application/template.hbs @@ -22,7 +22,8 @@ {{#if u.email }}