diff --git a/TODO.md b/TODO.md index cb3be08..11a97ea 100644 --- a/TODO.md +++ b/TODO.md @@ -9,3 +9,4 @@ 8. [ ] better loading experience 9. [x] refactor js scripts 10.[>] manage css file via require js +11.[ ] auto clean up result files diff --git a/testcube/core/api/client_auth.py b/testcube/core/api/client_auth.py index 36915fa..dbbf71c 100644 --- a/testcube/core/api/client_auth.py +++ b/testcube/core/api/client_auth.py @@ -12,7 +12,7 @@ from testcube.core.models import TestClient -version = '1.2' +version = '1.3' @csrf_exempt diff --git a/testcube/core/api/serializers.py b/testcube/core/api/serializers.py index acc55d4..5b4877c 100644 --- a/testcube/core/api/serializers.py +++ b/testcube/core/api/serializers.py @@ -167,7 +167,7 @@ class Meta: fields = ( 'id', 'stdout', 'get_reset_status_display', 'get_outcome_display', 'duration', 'run_on', 'reset_on', 'reset_by', - 'test_client', 'reset_reason') + 'test_client', 'reset_reason', 'error') depth = 1 diff --git a/testcube/core/api/views.py b/testcube/core/api/views.py index 740d86f..f5657c5 100644 --- a/testcube/core/api/views.py +++ b/testcube/core/api/views.py @@ -275,7 +275,7 @@ class ResultFileViewSet(viewsets.ModelViewSet): class ResetResultViewSet(viewsets.ModelViewSet): queryset = ResetResult.objects.all() serializer_class = ResetResultSerializer - filter_fields = () + filter_fields = ('id',) search_fields = filter_fields @list_route() diff --git a/testcube/core/forms.py b/testcube/core/forms.py index 38fe01c..ce9a050 100644 --- a/testcube/core/forms.py +++ b/testcube/core/forms.py @@ -141,7 +141,9 @@ def _parse_command(command, result, reset): will be parsed according under current result context. """ try: - cmd = command.format(result=result, reset=reset) + assert isinstance(result, TestResult) + run_variables = result.test_run.run_variables.data_json if result.test_run.run_variables else {} + cmd = command.format(result=result, reset=reset, **run_variables) return cmd, None except Exception as e: logger.exception('Failed to parse command: {}'.format(command)) diff --git a/testcube/runner/api.py b/testcube/runner/api.py index 75bff53..ad869f6 100644 --- a/testcube/runner/api.py +++ b/testcube/runner/api.py @@ -1,6 +1,7 @@ -from .views import ProfileViewSet, TaskViewSet +from .views import ProfileViewSet, TaskViewSet, RunVariablesViewSet def api_registration(router): + router.register('run_variables', RunVariablesViewSet) router.register('profiles', ProfileViewSet) router.register('tasks', TaskViewSet) diff --git a/testcube/runner/migrations/0002_auto_20171030_1544.py b/testcube/runner/migrations/0002_auto_20171030_1544.py new file mode 100644 index 0000000..daafaa1 --- /dev/null +++ b/testcube/runner/migrations/0002_auto_20171030_1544.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-30 07:44 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0010_auto_20171009_1634'), + ('runner', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='RunVariables', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.TextField(default=None, null=True)), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('updated_on', models.DateTimeField(auto_now=True)), + ('test_run', + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='run_variables', + to='core.TestRun')), + ], + ), + migrations.AlterField( + model_name='task', + name='status', + field=models.IntegerField(choices=[(-1, 'Pending'), (0, 'Sent'), (1, 'Error')], default=-1), + ), + ] diff --git a/testcube/runner/models.py b/testcube/runner/models.py index 8f86790..e89bc73 100644 --- a/testcube/runner/models.py +++ b/testcube/runner/models.py @@ -1,6 +1,18 @@ from django.db import models -from testcube.core.models import Product +from testcube.core.models import Product, TestRun +from testcube.utils import to_json + + +class RunVariables(models.Model): + test_run = models.OneToOneField(TestRun, on_delete=models.CASCADE, related_name='run_variables') + data = models.TextField(null=True, default=None) + created_on = models.DateTimeField(auto_now_add=True) + updated_on = models.DateTimeField(auto_now=True) + + @property + def data_json(self): + return to_json(self.data) class Profile(models.Model): diff --git a/testcube/runner/serializers.py b/testcube/runner/serializers.py index 656bb51..4ef7d42 100644 --- a/testcube/runner/serializers.py +++ b/testcube/runner/serializers.py @@ -3,6 +3,11 @@ from .models import * +class RunVariablesSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = RunVariables + fields = '__all__' + class ProfileSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Profile diff --git a/testcube/runner/views.py b/testcube/runner/views.py index bf4824d..7e0a109 100644 --- a/testcube/runner/views.py +++ b/testcube/runner/views.py @@ -9,6 +9,13 @@ from .serializers import * +class RunVariablesViewSet(viewsets.ModelViewSet): + queryset = RunVariables.objects.all() + serializer_class = RunVariablesSerializer + filter_fields = ('test_run',) + search_fields = filter_fields + + class ProfileViewSet(viewsets.ModelViewSet): queryset = Profile.objects.all() serializer_class = ProfileSerializer diff --git a/testcube/static/docs/faq.md b/testcube/static/docs/faq.md index 8e4276b..9b80ae1 100644 --- a/testcube/static/docs/faq.md +++ b/testcube/static/docs/faq.md @@ -104,3 +104,22 @@ Then add finish command to run last step. ``` testcube-client --finish-run -x "**/results/*.xml" -i "**/*.png" ``` + +## Advanced feature: Reset a test result + +TestCube provide the feature to reset a failed test result, there are lots of reason when a test failed, sometimes you want to run the failed test again, **reset** means rerun a failed test. + +From test result detail page, you can see a reset tab provide reset feature, before it works, you have to do a few works: + +A. Setup a job to process reset work + - This job requires varialbes so your test engine can run the failed test case and generate xunit files, e.g. testcase name, environments, result id + - Upload the xunits by command: `testcube-client --reset-result 123 -x "**/*.xml"` + +B. Add job A as the reset command to a product + - Open /api/profiles/ to add a profile to your product, with step A command + +C. Setup a job to handle reset tasks automatically, e.g. running every 5 minutes + - command: `testcube-client --handle-task` + +When you reset a result, TestCube will add a pending task with required variable, job C will handle this task automatically, then run a reset command according to current product profile, if the command has been run successfully, job A will be executed, and pending task will be marked as done. Once job A done, it will upload generated results to TestCube, at the same time, the failed results will be updated to new state, the reset process finished. + diff --git a/testcube/static/modules/result-detail.js b/testcube/static/modules/result-detail.js index 74d103d..d69a638 100644 --- a/testcube/static/modules/result-detail.js +++ b/testcube/static/modules/result-detail.js @@ -1,5 +1,5 @@ -define(['jquery', './table-support', './chart-support', './utils', 'bootstrapTable', 'bootstrapSelect'], - function ($, support, chart, utils) { +define(['jquery', './table-support', './chart-support', './utils', 'bootstrap-dialog', 'bootstrapTable', 'bootstrapSelect'], + function ($, support, chart, utils, BootstrapDialog) { "use strict"; let f = support.formatter; @@ -41,7 +41,7 @@ define(['jquery', './table-support', './chart-support', './utils', 'bootstrapTab {title: 'Duration', field: 'duration', formatter: f.durationFormatter}, {title: 'Status', field: 'get_reset_status_display'}, {title: 'Outcome', field: 'get_outcome_display'}, - {title: 'Detail', field: 'stdout', formatter: f.resetDetailFormatter} + {title: 'Detail', field: 'error', formatter: f.resetDetailFormatter} ]; @@ -90,7 +90,8 @@ define(['jquery', './table-support', './chart-support', './utils', 'bootstrapTab pagination: false, sortable: false, showFooter: false, - columns: resultResetsColumns + columns: resultResetsColumns, + onPostBody: setupResetResultDetailViewEvent }); if (result.testcase) { @@ -112,6 +113,23 @@ define(['jquery', './table-support', './chart-support', './utils', 'bootstrapTab } } + function setupResetResultDetailViewEvent() { + $('.reset-result').click(function () { + waitForLoading(); + let output = $(this).attr('data-text'); + output = output.replace('||', '\n\n').replace('||', '\n---------- OUTPUT --------\n'); + let detail = `
${output}
`; + BootstrapDialog.show({ + title: 'Reset Detail', + message: detail, + size: BootstrapDialog.SIZE_WIDE + }); + + utils.startLogHighlight(); + loadingCompleted(); + }); + } + function resultHistoryTableDataHandler(data) { window.app.resultHistory = data; return data.results; diff --git a/testcube/static/modules/table-support.js b/testcube/static/modules/table-support.js index 21eccb4..97a6d22 100644 --- a/testcube/static/modules/table-support.js +++ b/testcube/static/modules/table-support.js @@ -83,8 +83,12 @@ define(['moment', './utils', 'bootstrapTable'], function (moment, utils) { return `${url}`; }; - formatter.resetDetailFormatter = function (detail) { - return `View`; + formatter.resetDetailFormatter = function (error) { + let message = 'Nothing'; + if (error) { + message = error.message + '||' + error.stacktrace + '||' + error.stdout; + } + return `View`; }; support.defaultTableOptions = { diff --git a/testcube/static/require-config.js b/testcube/static/require-config.js index 463f87a..9270a30 100644 --- a/testcube/static/require-config.js +++ b/testcube/static/require-config.js @@ -1,4 +1,4 @@ -let version = '1.7'; +let version = '1.8'; function jsVersion(id, url) { return url.includes('libs/') ? '' : '?v' + version; diff --git a/testcube/tests/test_core_api_views.py b/testcube/tests/test_core_api_views.py index 0d9df2b..6eb2f06 100644 --- a/testcube/tests/test_core_api_views.py +++ b/testcube/tests/test_core_api_views.py @@ -1,9 +1,11 @@ import json +import os from django.contrib.auth.models import User from django.test import TestCase as TC, Client from testcube.core.models import Configuration, TestCase, Team, Product, TestResult, TestRun, TestClient +from testcube.runner.models import RunVariables class ModelsTestCase(TC): @@ -111,3 +113,11 @@ def test_get_run_tags(self): r = self.client.get(api) expected = [('tag3', 2), ('tag4', 2), ('tag5', 2), ('tag1', 1), ('tag2', 1)] assert r.data == expected, r.data + + def test_use_run_variables(self): + self.client.login(username='admin', password='admin') + run = TestRun.objects.create(name='test-run', owner='test', start_by='test', product=self.product) + env_vars = json.dumps(dict(os.environ)) + var = RunVariables.objects.create(test_run=run, data=env_vars) + assert var.data == env_vars + assert isinstance(var.data_json, dict) diff --git a/testcube/utils.py b/testcube/utils.py index 04cb825..3933936 100644 --- a/testcube/utils.py +++ b/testcube/utils.py @@ -54,12 +54,7 @@ def setup_logger(log_dir=None, debug=False): def append_json(origin_txt, field, value): - try: - obj = json.loads(origin_txt) - except: - from testcube.settings import logger - logger.warning('Cannot parse to json: {}'.format(origin_txt)) - obj = {} + obj = to_json(origin_txt) if field in obj: obj[field] += '|*|' + value @@ -68,3 +63,12 @@ def append_json(origin_txt, field, value): obj[field] = value return json.dumps(obj) + + +def to_json(data_text): + try: + return json.loads(data_text) + except: + from testcube.settings import logger + logger.exception('Cannot parse to json: {}'.format(data_text)) + return {}