Merge "Refactor _find_caller into a public test finder utility"
diff --git a/tempest/api/image/v1/test_images.py b/tempest/api/image/v1/test_images.py
index 8466c7b..b90891b 100644
--- a/tempest/api/image/v1/test_images.py
+++ b/tempest/api/image/v1/test_images.py
@@ -290,6 +290,7 @@
return image_id
@test.attr(type='gate')
+ @test.services('compute')
def test_index_server_id(self):
# The images should contain images filtered by server id
resp, images = self.client.image_list_detail(
@@ -299,6 +300,7 @@
self.assertEqual(self.snapshot_set, result_set)
@test.attr(type='gate')
+ @test.services('compute')
def test_index_type(self):
# The list of servers should be filtered by image type
params = {'image_type': 'snapshot'}
@@ -309,6 +311,7 @@
self.assertIn(self.snapshot, result_set)
@test.attr(type='gate')
+ @test.services('compute')
def test_index_limit(self):
# Verify only the expected number of results are returned
resp, images = self.client.image_list_detail(limit=1)
@@ -317,6 +320,7 @@
self.assertEqual(1, len(images))
@test.attr(type='gate')
+ @test.services('compute')
def test_index_by_change_since(self):
# Verify an update image is returned
# Becoming ACTIVE will modify the updated time
diff --git a/tempest/api/telemetry/test_telemetry_alarming_api.py b/tempest/api/telemetry/test_telemetry_alarming_api.py
index a59d3ae..3472b31 100644
--- a/tempest/api/telemetry/test_telemetry_alarming_api.py
+++ b/tempest/api/telemetry/test_telemetry_alarming_api.py
@@ -11,6 +11,7 @@
# under the License.
from tempest.api.telemetry import base
+from tempest.common.utils import data_utils
from tempest import exceptions
from tempest.test import attr
@@ -18,46 +19,96 @@
class TelemetryAlarmingAPITestJSON(base.BaseTelemetryTest):
_interface = 'json'
+ @classmethod
+ def setUpClass(cls):
+ super(TelemetryAlarmingAPITestJSON, cls).setUpClass()
+ cls.rule = {'meter_name': 'cpu_util',
+ 'comparison_operator': 'gt',
+ 'threshold': 80.0,
+ 'period': 70}
+ for i in range(2):
+ cls.create_alarm(threshold_rule=cls.rule)
+
@attr(type="gate")
def test_alarm_list(self):
- # Create an alarm to verify in the list of alarms
- created_alarm_ids = list()
- fetched_ids = list()
- rules = {'meter_name': 'cpu_util',
- 'comparison_operator': 'gt',
- 'threshold': 80.0,
- 'period': 70}
- for i in range(3):
- resp, body = self.create_alarm(threshold_rule=rules)
- created_alarm_ids.append(body['alarm_id'])
-
# List alarms
resp, alarm_list = self.telemetry_client.list_alarms()
- self.assertEqual(int(resp['status']), 200)
+ self.assertEqual(200, resp.status)
# Verify created alarm in the list
fetched_ids = [a['alarm_id'] for a in alarm_list]
- missing_alarms = [a for a in created_alarm_ids if a not in fetched_ids]
+ missing_alarms = [a for a in self.alarm_ids if a not in fetched_ids]
self.assertEqual(0, len(missing_alarms),
"Failed to find the following created alarm(s)"
" in a fetched list: %s" %
', '.join(str(a) for a in missing_alarms))
@attr(type="gate")
- def test_create_alarm(self):
- rules = {'meter_name': 'cpu_util',
- 'comparison_operator': 'gt',
- 'threshold': 80.0,
- 'period': 70}
- resp, body = self.create_alarm(threshold_rule=rules)
- self.alarm_id = body['alarm_id']
- self.assertEqual(int(resp['status']), 201)
- self.assertDictContainsSubset(rules, body['threshold_rule'])
- resp, body = self.telemetry_client.get_alarm(self.alarm_id)
- self.assertEqual(int(resp['status']), 200)
- self.assertDictContainsSubset(rules, body['threshold_rule'])
- resp, _ = self.telemetry_client.delete_alarm(self.alarm_id)
- self.assertEqual(int(resp['status']), 204)
+ def test_create_update_get_delete_alarm(self):
+ # Create an alarm
+ alarm_name = data_utils.rand_name('telemetry_alarm')
+ resp, body = self.telemetry_client.create_alarm(
+ name=alarm_name, type='threshold', threshold_rule=self.rule)
+ self.assertEqual(201, resp.status)
+ self.assertEqual(alarm_name, body['name'])
+ alarm_id = body['alarm_id']
+ self.assertDictContainsSubset(self.rule, body['threshold_rule'])
+ # Update alarm with new rule and new name
+ new_rule = {'meter_name': 'cpu',
+ 'comparison_operator': 'eq',
+ 'threshold': 70.0,
+ 'period': 60}
+ alarm_name = data_utils.rand_name('telemetry-alarm-update')
+ resp, body = self.telemetry_client.update_alarm(
+ alarm_id,
+ threshold_rule=new_rule,
+ name=alarm_name,
+ type='threshold')
+ self.assertEqual(200, resp.status)
+ self.assertEqual(alarm_name, body['name'])
+ self.assertDictContainsSubset(new_rule, body['threshold_rule'])
+ # Get and verify details of an alarm after update
+ resp, body = self.telemetry_client.get_alarm(alarm_id)
+ self.assertEqual(200, resp.status)
+ self.assertEqual(alarm_name, body['name'])
+ self.assertDictContainsSubset(new_rule, body['threshold_rule'])
+ # Delete alarm and verify if deleted
+ resp, _ = self.telemetry_client.delete_alarm(alarm_id)
+ self.assertEqual(204, resp.status)
self.assertRaises(exceptions.NotFound,
- self.telemetry_client.get_alarm,
- self.alarm_id)
+ self.telemetry_client.get_alarm, alarm_id)
+
+ @attr(type="gate")
+ def test_set_get_alarm_state(self):
+ alarm_states = ['ok', 'alarm', 'insufficient data']
+ _, alarm = self.create_alarm(threshold_rule=self.rule)
+ # Set alarm state and verify
+ new_state =\
+ [elem for elem in alarm_states if elem != alarm['state']][0]
+ resp, state = self.telemetry_client.alarm_set_state(alarm['alarm_id'],
+ new_state)
+ self.assertEqual(200, resp.status)
+ self.assertEqual(new_state, state)
+ # Get alarm state and verify
+ resp, state = self.telemetry_client.alarm_get_state(alarm['alarm_id'])
+ self.assertEqual(200, resp.status)
+ self.assertEqual(new_state, state)
+
+ @attr(type="gate")
+ def test_create_delete_alarm_with_combination_rule(self):
+ rule = {"alarm_ids": self.alarm_ids,
+ "operator": "or"}
+ # Verifies alarm create
+ alarm_name = data_utils.rand_name('combination_alarm')
+ resp, body = self.telemetry_client.create_alarm(name=alarm_name,
+ combination_rule=rule,
+ type='combination')
+ self.assertEqual(201, resp.status)
+ self.assertEqual(alarm_name, body['name'])
+ alarm_id = body['alarm_id']
+ self.assertDictContainsSubset(rule, body['combination_rule'])
+ # Verify alarm delete
+ resp, _ = self.telemetry_client.delete_alarm(alarm_id)
+ self.assertEqual(204, resp.status)
+ self.assertRaises(exceptions.NotFound,
+ self.telemetry_client.get_alarm, alarm_id)
diff --git a/tempest/api_schema/compute/quotas.py b/tempest/api_schema/compute/quotas.py
new file mode 100644
index 0000000..f49771e
--- /dev/null
+++ b/tempest/api_schema/compute/quotas.py
@@ -0,0 +1,41 @@
+# Copyright 2014 NEC Corporation. All rights reserved.
+#
+# 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
+#
+# http://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.
+
+common_quota_set = {
+ 'status_code': [200],
+ 'response_body': {
+ 'type': 'object',
+ 'properties': {
+ 'quota_set': {
+ 'type': 'object',
+ 'properties': {
+ 'instances': {'type': 'integer'},
+ 'cores': {'type': 'integer'},
+ 'ram': {'type': 'integer'},
+ 'floating_ips': {'type': 'integer'},
+ 'fixed_ips': {'type': 'integer'},
+ 'metadata_items': {'type': 'integer'},
+ 'key_pairs': {'type': 'integer'},
+ 'security_groups': {'type': 'integer'},
+ 'security_group_rules': {'type': 'integer'}
+ },
+ 'required': ['instances', 'cores', 'ram',
+ 'floating_ips', 'fixed_ips',
+ 'metadata_items', 'key_pairs',
+ 'security_groups', 'security_group_rules']
+ }
+ },
+ 'required': ['quota_set']
+ }
+}
diff --git a/tempest/api_schema/compute/v2/quotas.py b/tempest/api_schema/compute/v2/quotas.py
index 17dc4dd..31c0458 100644
--- a/tempest/api_schema/compute/v2/quotas.py
+++ b/tempest/api_schema/compute/v2/quotas.py
@@ -12,39 +12,36 @@
# License for the specific language governing permissions and limitations
# under the License.
-quota_set = {
- 'status_code': [200],
- 'response_body': {
- 'type': 'object',
- 'properties': {
- 'quota_set': {
- 'type': 'object',
- 'properties': {
- 'id': {'type': 'string'},
- 'instances': {'type': 'integer'},
- 'cores': {'type': 'integer'},
- 'ram': {'type': 'integer'},
- 'floating_ips': {'type': 'integer'},
- 'fixed_ips': {'type': 'integer'},
- 'metadata_items': {'type': 'integer'},
- 'injected_files': {'type': 'integer'},
- 'injected_file_content_bytes': {'type': 'integer'},
- 'injected_file_path_bytes': {'type': 'integer'},
- 'key_pairs': {'type': 'integer'},
- 'security_groups': {'type': 'integer'},
- 'security_group_rules': {'type': 'integer'}
- },
- 'required': ['id', 'instances', 'cores', 'ram',
- 'floating_ips', 'fixed_ips',
- 'metadata_items', 'injected_files',
- 'injected_file_content_bytes',
- 'injected_file_path_bytes', 'key_pairs',
- 'security_groups', 'security_group_rules']
- }
- },
- 'required': ['quota_set']
- }
-}
+import copy
+
+from tempest.api_schema.compute import quotas
+
+quota_set = copy.deepcopy(quotas.common_quota_set)
+quota_set['response_body']['properties']['quota_set']['properties'][
+ 'id'] = {'type': 'string'}
+quota_set['response_body']['properties']['quota_set']['properties'][
+ 'injected_files'] = {'type': 'integer'}
+quota_set['response_body']['properties']['quota_set']['properties'][
+ 'injected_file_content_bytes'] = {'type': 'integer'}
+quota_set['response_body']['properties']['quota_set']['properties'][
+ 'injected_file_path_bytes'] = {'type': 'integer'}
+quota_set['response_body']['properties']['quota_set']['required'].extend([
+ 'id',
+ 'injected_files',
+ 'injected_file_content_bytes',
+ 'injected_file_path_bytes'])
+
+quota_set_update = copy.deepcopy(quotas.common_quota_set)
+quota_set_update['response_body']['properties']['quota_set']['properties'][
+ 'injected_files'] = {'type': 'integer'}
+quota_set_update['response_body']['properties']['quota_set']['properties'][
+ 'injected_file_content_bytes'] = {'type': 'integer'}
+quota_set_update['response_body']['properties']['quota_set']['properties'][
+ 'injected_file_path_bytes'] = {'type': 'integer'}
+quota_set_update['response_body']['properties']['quota_set'][
+ 'required'].extend(['injected_files',
+ 'injected_file_content_bytes',
+ 'injected_file_path_bytes'])
delete_quota = {
'status_code': [202]
diff --git a/tempest/api_schema/compute/v3/quotas.py b/tempest/api_schema/compute/v3/quotas.py
index aec1e80..a3212ed 100644
--- a/tempest/api_schema/compute/v3/quotas.py
+++ b/tempest/api_schema/compute/v3/quotas.py
@@ -12,34 +12,15 @@
# License for the specific language governing permissions and limitations
# under the License.
-quota_set = {
- 'status_code': [200],
- 'response_body': {
- 'type': 'object',
- 'properties': {
- 'quota_set': {
- 'type': 'object',
- 'properties': {
- 'id': {'type': 'string'},
- 'instances': {'type': 'integer'},
- 'cores': {'type': 'integer'},
- 'ram': {'type': 'integer'},
- 'floating_ips': {'type': 'integer'},
- 'fixed_ips': {'type': 'integer'},
- 'metadata_items': {'type': 'integer'},
- 'key_pairs': {'type': 'integer'},
- 'security_groups': {'type': 'integer'},
- 'security_group_rules': {'type': 'integer'}
- },
- 'required': ['id', 'instances', 'cores', 'ram',
- 'floating_ips', 'fixed_ips',
- 'metadata_items', 'key_pairs',
- 'security_groups', 'security_group_rules']
- }
- },
- 'required': ['quota_set']
- }
-}
+import copy
+
+from tempest.api_schema.compute import quotas
+
+quota_set = copy.deepcopy(quotas.common_quota_set)
+quota_set['response_body']['properties']['quota_set']['properties'][
+ 'id'] = {'type': 'string'}
+quota_set['response_body']['properties']['quota_set'][
+ 'required'].extend(['id'])
quota_common_info = {
'type': 'object',
@@ -51,34 +32,27 @@
'required': ['reserved', 'limit', 'in_use']
}
-quota_set_detail = {
- 'status_code': [200],
- 'response_body': {
- 'type': 'object',
- 'properties': {
- 'quota_set': {
- 'type': 'object',
- 'properties': {
- 'id': {'type': 'string'},
- 'instances': quota_common_info,
- 'cores': quota_common_info,
- 'ram': quota_common_info,
- 'floating_ips': quota_common_info,
- 'fixed_ips': quota_common_info,
- 'metadata_items': quota_common_info,
- 'key_pairs': quota_common_info,
- 'security_groups': quota_common_info,
- 'security_group_rules': quota_common_info
- },
- 'required': ['id', 'instances', 'cores', 'ram',
- 'floating_ips', 'fixed_ips',
- 'metadata_items', 'key_pairs',
- 'security_groups', 'security_group_rules']
- }
- },
- 'required': ['quota_set']
- }
-}
+quota_set_detail = copy.deepcopy(quotas.common_quota_set)
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'id'] = {'type': 'string'}
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'instances'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'cores'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'ram'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'floating_ips'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'fixed_ips'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'metadata_items'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'key_pairs'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'security_groups'] = quota_common_info
+quota_set_detail['response_body']['properties']['quota_set']['properties'][
+ 'security_group_rules'] = quota_common_info
delete_quota = {
'status_code': [204]
diff --git a/tempest/services/compute/json/quotas_client.py b/tempest/services/compute/json/quotas_client.py
index 9bddf2c..7e828d8 100644
--- a/tempest/services/compute/json/quotas_client.py
+++ b/tempest/services/compute/json/quotas_client.py
@@ -110,6 +110,7 @@
post_body)
body = json.loads(body)
+ self.validate_response(schema.quota_set_update, resp, body)
return resp, body['quota_set']
def delete_quota_set(self, tenant_id):
diff --git a/tempest/services/telemetry/telemetry_client_base.py b/tempest/services/telemetry/telemetry_client_base.py
index 610f07b..a073f54 100644
--- a/tempest/services/telemetry/telemetry_client_base.py
+++ b/tempest/services/telemetry/telemetry_client_base.py
@@ -73,7 +73,10 @@
return resp, body
def put(self, uri, body):
- return self.rest_client.put(uri, body)
+ body = self.serialize(body)
+ resp, body = self.rest_client.put(uri, body)
+ body = self.deserialize(body)
+ return resp, body
def get(self, uri):
resp, body = self.rest_client.get(uri)
@@ -133,3 +136,15 @@
def create_alarm(self, **kwargs):
uri = "%s/alarms" % self.uri_prefix
return self.post(uri, kwargs)
+
+ def update_alarm(self, alarm_id, **kwargs):
+ uri = "%s/alarms/%s" % (self.uri_prefix, alarm_id)
+ return self.put(uri, kwargs)
+
+ def alarm_get_state(self, alarm_id):
+ uri = "%s/alarms/%s/state" % (self.uri_prefix, alarm_id)
+ return self.get(uri)
+
+ def alarm_set_state(self, alarm_id, state):
+ uri = "%s/alarms/%s/state" % (self.uri_prefix, alarm_id)
+ return self.put(uri, state)
diff --git a/tempest/tests/test_wrappers.py b/tempest/tests/test_wrappers.py
index f6ed445..49809eb 100644
--- a/tempest/tests/test_wrappers.py
+++ b/tempest/tests/test_wrappers.py
@@ -33,6 +33,7 @@
# Setup Test files
self.testr_conf_file = os.path.join(self.directory, '.testr.conf')
self.setup_cfg_file = os.path.join(self.directory, 'setup.cfg')
+ self.subunit_trace = os.path.join(self.directory, 'subunit-trace.py')
self.passing_file = os.path.join(self.test_dir, 'test_passing.py')
self.failing_file = os.path.join(self.test_dir, 'test_failing.py')
self.init_file = os.path.join(self.test_dir, '__init__.py')
@@ -43,6 +44,7 @@
shutil.copy('setup.py', self.setup_py)
shutil.copy('tempest/tests/files/setup.cfg', self.setup_cfg_file)
shutil.copy('tempest/tests/files/__init__.py', self.init_file)
+ shutil.copy('tools/subunit-trace.py', self.subunit_trace)
def test_pretty_tox(self):
# Copy wrapper script and requirements:
diff --git a/tools/pretty_tox.sh b/tools/pretty_tox.sh
index 07c35a0..f3c88f3 100755
--- a/tools/pretty_tox.sh
+++ b/tools/pretty_tox.sh
@@ -3,4 +3,4 @@
set -o pipefail
TESTRARGS=$1
-python setup.py testr --slowest --testr-args="--subunit $TESTRARGS" | subunit2pyunit
+python setup.py testr --slowest --testr-args="--subunit $TESTRARGS" | $(dirname $0)/subunit-trace.py
diff --git a/tools/pretty_tox_serial.sh b/tools/pretty_tox_serial.sh
index 42ce760..1634b8e 100755
--- a/tools/pretty_tox_serial.sh
+++ b/tools/pretty_tox_serial.sh
@@ -7,7 +7,7 @@
if [ ! -d .testrepository ]; then
testr init
fi
-testr run --subunit $TESTRARGS | subunit2pyunit
+testr run --subunit $TESTRARGS | $(dirname $0)/subunit-trace.py
retval=$?
testr slowest
exit $retval
diff --git a/tools/subunit-trace.py b/tools/subunit-trace.py
new file mode 100755
index 0000000..cb710aa
--- /dev/null
+++ b/tools/subunit-trace.py
@@ -0,0 +1,227 @@
+#!/usr/bin/env python
+
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+# Copyright 2014 Samsung Electronics
+# All Rights Reserved.
+#
+# 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
+#
+# http://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.
+
+"""Trace a subunit stream in reasonable detail and high accuracy."""
+
+import functools
+import sys
+
+import mimeparse
+import subunit
+import testtools
+
+DAY_SECONDS = 60 * 60 * 24
+FAILS = []
+RESULTS = {}
+
+
+class Starts(testtools.StreamResult):
+
+ def __init__(self, output):
+ super(Starts, self).__init__()
+ self._output = output
+
+ def startTestRun(self):
+ self._neednewline = False
+ self._emitted = set()
+
+ def status(self, test_id=None, test_status=None, test_tags=None,
+ runnable=True, file_name=None, file_bytes=None, eof=False,
+ mime_type=None, route_code=None, timestamp=None):
+ super(Starts, self).status(
+ test_id, test_status,
+ test_tags=test_tags, runnable=runnable, file_name=file_name,
+ file_bytes=file_bytes, eof=eof, mime_type=mime_type,
+ route_code=route_code, timestamp=timestamp)
+ if not test_id:
+ if not file_bytes:
+ return
+ if not mime_type or mime_type == 'test/plain;charset=utf8':
+ mime_type = 'text/plain; charset=utf-8'
+ primary, sub, parameters = mimeparse.parse_mime_type(mime_type)
+ content_type = testtools.content_type.ContentType(
+ primary, sub, parameters)
+ content = testtools.content.Content(
+ content_type, lambda: [file_bytes])
+ text = content.as_text()
+ if text and text[-1] not in '\r\n':
+ self._neednewline = True
+ self._output.write(text)
+ elif test_status == 'inprogress' and test_id not in self._emitted:
+ if self._neednewline:
+ self._neednewline = False
+ self._output.write('\n')
+ worker = ''
+ for tag in test_tags or ():
+ if tag.startswith('worker-'):
+ worker = '(' + tag[7:] + ') '
+ if timestamp:
+ timestr = timestamp.isoformat()
+ else:
+ timestr = ''
+ self._output.write('%s: %s%s [start]\n' %
+ (timestr, worker, test_id))
+ self._emitted.add(test_id)
+
+
+def cleanup_test_name(name, strip_tags=True, strip_scenarios=False):
+ """Clean up the test name for display.
+
+ By default we strip out the tags in the test because they don't help us
+ in identifying the test that is run to it's result.
+
+ Make it possible to strip out the testscenarios information (not to
+ be confused with tempest scenarios) however that's often needed to
+ indentify generated negative tests.
+ """
+ if strip_tags:
+ tags_start = name.find('[')
+ tags_end = name.find(']')
+ if tags_start > 0 and tags_end > tags_start:
+ newname = name[:tags_start]
+ newname += name[tags_end + 1:]
+ name = newname
+
+ if strip_scenarios:
+ tags_start = name.find('(')
+ tags_end = name.find(')')
+ if tags_start > 0 and tags_end > tags_start:
+ newname = name[:tags_start]
+ newname += name[tags_end + 1:]
+ name = newname
+
+ return name
+
+
+def get_duration(timestamps):
+ start, end = timestamps
+ if not start or not end:
+ duration = ''
+ else:
+ delta = end - start
+ duration = '%d.%06ds' % (
+ delta.days * DAY_SECONDS + delta.seconds, delta.microseconds)
+ return duration
+
+
+def find_worker(test):
+ for tag in test['tags']:
+ if tag.startswith('worker-'):
+ return int(tag[7:])
+ return 'NaN'
+
+
+# Print out stdout/stderr if it exists, always
+def print_attachments(stream, test, all_channels=False):
+ """Print out subunit attachments.
+
+ Print out subunit attachments that contain content. This
+ runs in 2 modes, one for successes where we print out just stdout
+ and stderr, and an override that dumps all the attachments.
+ """
+ channels = ('stdout', 'stderr')
+ for name, detail in test['details'].items():
+ # NOTE(sdague): the subunit names are a little crazy, and actually
+ # are in the form pythonlogging:'' (with the colon and quotes)
+ name = name.split(':')[0]
+ if detail.content_type.type == 'test':
+ detail.content_type.type = 'text'
+ if (all_channels or name in channels) and detail.as_text():
+ title = "Captured %s:" % name
+ stream.write("\n%s\n%s\n" % (title, ('~' * len(title))))
+ # indent attachment lines 4 spaces to make them visually
+ # offset
+ for line in detail.as_text().split('\n'):
+ stream.write(" %s\n" % line)
+
+
+def show_outcome(stream, test):
+ global RESULTS
+ status = test['status']
+ # TODO(sdague): ask lifeless why on this?
+ if status == 'exists':
+ return
+
+ worker = find_worker(test)
+ name = cleanup_test_name(test['id'])
+ duration = get_duration(test['timestamps'])
+
+ if worker not in RESULTS:
+ RESULTS[worker] = []
+ RESULTS[worker].append(test)
+
+ # don't count the end of the return code as a fail
+ if name == 'process-returncode':
+ return
+
+ if status == 'success':
+ stream.write('{%s} %s [%s] ... ok\n' % (
+ worker, name, duration))
+ print_attachments(stream, test)
+ elif status == 'fail':
+ FAILS.append(test)
+ stream.write('{%s} %s [%s] ... FAILED\n' % (
+ worker, name, duration))
+ print_attachments(stream, test, all_channels=True)
+ elif status == 'skip':
+ stream.write('{%s} %s ... SKIPPED: %s\n' % (
+ worker, name, test['details']['reason'].as_text()))
+ else:
+ stream.write('{%s} %s [%s] ... %s\n' % (
+ worker, name, duration, test['status']))
+ print_attachments(stream, test, all_channels=True)
+
+ stream.flush()
+
+
+def print_fails(stream):
+ """Print summary failure report.
+
+ Currently unused, however there remains debate on inline vs. at end
+ reporting, so leave the utility function for later use.
+ """
+ if not FAILS:
+ return
+ stream.write("\n==============================\n")
+ stream.write("Failed %s tests - output below:" % len(FAILS))
+ stream.write("\n==============================\n")
+ for f in FAILS:
+ stream.write("\n%s\n" % f['id'])
+ stream.write("%s\n" % ('-' * len(f['id'])))
+ print_attachments(stream, f, all_channels=True)
+ stream.write('\n')
+
+
+def main():
+ stream = subunit.ByteStreamToStreamResult(
+ sys.stdin, non_subunit_name='stdout')
+ starts = Starts(sys.stdout)
+ outcomes = testtools.StreamToDict(
+ functools.partial(show_outcome, sys.stdout))
+ summary = testtools.StreamSummary()
+ result = testtools.CopyStreamResult([starts, outcomes, summary])
+ result.startTestRun()
+ try:
+ stream.run(result)
+ finally:
+ result.stopTestRun()
+ return (0 if summary.wasSuccessful() else 1)
+
+
+if __name__ == '__main__':
+ sys.exit(main())