Repository: StackStorm/st2

Issue:
'st2 execution tail --help' doesn't work
When running `st2 execution tail --help` it works as `st2 execution tail`, giving output for the latest execution (I guess).

It should print the help/usage instead.


Discussion:

Resolving patch:
diff --git a/st2client/st2client/commands/action.py b/st2client/st2client/commands/action.py
--- a/st2client/st2client/commands/action.py
+++ b/st2client/st2client/commands/action.py
@@ -978,13 +978,14 @@ def __init__(self, description, app, subparsers, parent_parser=None):
         self.commands['re-run'] = ActionExecutionReRunCommand(
             self.resource, self.app, self.subparsers, add_help=False)
         self.commands['cancel'] = ActionExecutionCancelCommand(
-            self.resource, self.app, self.subparsers, add_help=False)
+            self.resource, self.app, self.subparsers, add_help=True)
         self.commands['pause'] = ActionExecutionPauseCommand(
-            self.resource, self.app, self.subparsers, add_help=False)
+            self.resource, self.app, self.subparsers, add_help=True)
         self.commands['resume'] = ActionExecutionResumeCommand(
-            self.resource, self.app, self.subparsers, add_help=False)
+            self.resource, self.app, self.subparsers, add_help=True)
         self.commands['tail'] = ActionExecutionTailCommand(self.resource, self.app,
-                                                           self.subparsers, add_help=False)
+                                                           self.subparsers,
+                                                           add_help=True)
 
 
 POSSIBLE_ACTION_STATUS_VALUES = ('succeeded', 'running', 'scheduled', 'failed', 'canceled')
@@ -1296,7 +1297,7 @@ def __init__(self, resource, *args, **kwargs):
             resource.get_plural_display_name().lower(),
             *args, **kwargs)
 
-        self.parser.add_argument('id', nargs='?',
+        self.parser.add_argument('id', nargs='+',
                                  metavar='id',
                                  help='ID of action execution to pause.')
 
@@ -1330,7 +1331,7 @@ def __init__(self, resource, *args, **kwargs):
             resource.get_plural_display_name().lower(),
             *args, **kwargs)
 
-        self.parser.add_argument('id', nargs='?',
+        self.parser.add_argument('id', nargs='+',
                                  metavar='id',
                                  help='ID of action execution to resume.')
 
@@ -1373,9 +1374,6 @@ def __init__(self, resource, *args, **kwargs):
                                  default=False,
                                  help=('Include metadata (timestamp, output type) with the '
                                        'output.'))
-        self.parser.add_argument('-h', '--help',
-                                 action='store_true', dest='help',
-                                 help='Print usage for the given command.')
 
     def run(self, args, **kwargs):
         pass
@@ -1397,11 +1395,16 @@ def run_and_print(self, args, **kwargs):
         if not execution:
             raise ResourceNotFoundError('Execution  with id %s not found.' % (args.id))
 
+        # Note: For non-workflow actions child_execution_id always matches parent_execution_id so
+        # we don't need to do any other checks to determine if executions represents a workflow
+        # action
+        parent_execution_id = execution_id
+
         # Execution has already finished
         if execution.status in LIVEACTION_COMPLETED_STATES:
             output = self.manager.get_output(execution_id=execution_id, output_type=output_type)
             print(output)
-            print('Execution %s has completed.' % (execution_id))
+            print('Execution %s has completed (status=%s).' % (execution_id, execution.status))
             return
 
         stream_mgr = self.app.client.managers['Stream']
@@ -1411,15 +1414,37 @@ def run_and_print(self, args, **kwargs):
             status = event.get('status', None)
             is_execution_event = status is not None
 
+            # NOTE: Right now only a single level deep / nested workflows are supported
             if is_execution_event:
-                if status in LIVEACTION_COMPLETED_STATES:
-                    # Execution has completed
-                    print('')
-                    print('Execution %s has completed.' % (execution_id))
-                    break
+                context = self._get_normalized_context_execution_task_event(event=event)
+                task_execution_id = context['execution_id']
+                task_name = context['task_name']
+                task_parent_execution_id = context['parent_execution_id']
+                is_child_execution = (task_parent_execution_id == parent_execution_id)
+
+                if is_child_execution:
+                    if status == LIVEACTION_STATUS_RUNNING:
+                        print('Child execution (task=%s) %s has started.' % (task_name,
+                                                                             task_execution_id))
+                        print('')
+                        continue
+                    elif status in LIVEACTION_COMPLETED_STATES:
+                        print('')
+                        print('Child execution (task=%s) %s has finished (status=%s).' % (task_name,
+                              task_execution_id, status))
+                        continue
+                    else:
+                        # We don't care about other child events so we simply skip then
+                        continue
                 else:
-                    # We don't care about other execution events
-                    continue
+                    if status in LIVEACTION_COMPLETED_STATES:
+                        # Bail out once parent execution has finished
+                        print('')
+                        print('Execution %s has completed (status=%s).' % (execution_id, status))
+                        break
+                    else:
+                        # We don't care about other execution events
+                        continue
 
             # Filter on output_type if provided
             event_output_type = event.get('output_type', None)
@@ -1431,3 +1456,29 @@ def run_and_print(self, args, **kwargs):
                                                   event['data']))
             else:
                 sys.stdout.write(event['data'])
+
+    def _get_normalized_context_execution_task_event(self, event):
+        """
+        Return a dictionary with normalized context attributes for Action-Chain and Mistral
+        workflows.
+        """
+        context = event.get('context', {})
+
+        result = {
+            'parent_execution_id': None,
+            'execution_id': None,
+            'task_name': None
+        }
+
+        if 'mistral' in context:
+            # Mistral workflow
+            result['parent_execution_id'] = context.get('parent', {}).get('execution_id', None)
+            result['execution_id'] = event['id']
+            result['task_name'] = context.get('mistral', {}).get('task_name', 'unknown')
+        else:
+            # Action chain workflow
+            result['parent_execution_id'] = context.get('parent', {}).get('execution_id', None)
+            result['execution_id'] = event['id']
+            result['task_name'] = context.get('chain', {}).get('name', 'unknown')
+
+        return result


Test patch:
diff --git a/st2client/tests/base.py b/st2client/tests/base.py
--- a/st2client/tests/base.py
+++ b/st2client/tests/base.py
@@ -107,3 +107,17 @@ def tearDown(self):
             # Reset to original stdout and stderr.
             sys.stdout = sys.__stdout__
             sys.stderr = sys.__stderr__
+
+    def _reset_output_streams(self):
+        """
+        Reset / clear stdout and stderr stream.
+        """
+
+        self.stdout.seek(0)
+        self.stdout.truncate()
+        self.stderr.seek(0)
+        self.stderr.truncate()
+
+        # Verify it has been reset correctly
+        self.assertEqual(self.stdout.getvalue(), '')
+        self.assertEqual(self.stderr.getvalue(), '')
diff --git a/st2client/tests/unit/test_commands.py b/st2client/tests/unit/test_commands.py
--- a/st2client/tests/unit/test_commands.py
+++ b/st2client/tests/unit/test_commands.py
@@ -23,7 +23,9 @@
 from collections import namedtuple
 
 from tests import base
+from tests.base import BaseCLITestCase
 
+from st2client.shell import Shell
 from st2client import models
 from st2client.utils import httpclient
 from st2client.commands import resource
@@ -271,3 +273,78 @@ def test_get_exclude_attributes(self):
         args = cls(attr=['result.stdout', 'trigger_instance.id'])
         result = ActionExecutionReadCommand._get_exclude_attributes(args=args)
         self.assertEqual(result, [])
+
+
+class CommandsHelpStringTestCase(BaseCLITestCase):
+    """
+    Test case which verifies that all the commands support -h / --help flag.
+    """
+
+    capture_output = True
+
+    # TODO: Automatically iterate all the available commands
+    COMMANDS = [
+        # action
+        ['action', 'list'],
+        ['action', 'get'],
+        ['action', 'create'],
+        ['action', 'update'],
+        ['action', 'delete'],
+        ['action', 'enable'],
+        ['action', 'disable'],
+        ['action', 'execute'],
+
+        # execution
+        ['execution', 'cancel'],
+        ['execution', 'pause'],
+        ['execution', 'resume'],
+        ['execution', 'tail']
+    ]
+
+    def test_help_command_line_arg_works_for_supported_commands(self):
+        shell = Shell()
+
+        for command in self.COMMANDS:
+            # First test longhang notation
+            argv = command + ['--help']
+
+            try:
+                result = shell.run(argv)
+            except SystemExit as e:
+                self.assertEqual(e.code, 0)
+            else:
+                self.assertEqual(result, 0)
+
+            stdout = self.stdout.getvalue()
+
+            self.assertTrue('usage:' in stdout)
+            self.assertTrue(' '.join(command) in stdout)
+            # self.assertTrue('positional arguments:' in stdout)
+            self.assertTrue('optional arguments:' in stdout)
+
+            # Reset stdout and stderr after each iteration
+            self._reset_output_streams()
+
+            # Then shorthand notation
+            argv = command + ['-h']
+
+            try:
+                result = shell.run(argv)
+            except SystemExit as e:
+                self.assertEqual(e.code, 0)
+            else:
+                self.assertEqual(result, 0)
+
+            stdout = self.stdout.getvalue()
+
+            self.assertTrue('usage:' in stdout)
+            self.assertTrue(' '.join(command) in stdout)
+            # self.assertTrue('positional arguments:' in stdout)
+            self.assertTrue('optional arguments:' in stdout)
+
+            # Verify that the actual help usage string was triggered and not the invalid
+            # "too few arguments" which would indicate command doesn't actually correctly handle
+            # --help flag
+            self.assertTrue('too few arguments' not in stdout)
+
+            self._reset_output_streams()
diff --git a/st2client/tests/unit/test_execution_tail_command.py b/st2client/tests/unit/test_execution_tail_command.py
new file mode 100644
--- /dev/null
+++ b/st2client/tests/unit/test_execution_tail_command.py
@@ -0,0 +1,459 @@
+# Licensed to the StackStorm, Inc ('StackStorm') under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+import mock
+import json
+
+from tests import base
+from tests.base import BaseCLITestCase
+
+from st2client.utils import httpclient
+from st2client.commands.action import LIVEACTION_STATUS_RUNNING
+from st2client.commands.action import LIVEACTION_STATUS_SUCCEEDED
+from st2client.commands.action import LIVEACTION_STATUS_FAILED
+from st2client.commands.action import LIVEACTION_STATUS_TIMED_OUT
+from st2client.shell import Shell
+
+__all__ = [
+    'ActionExecutionTailCommandTestCase'
+]
+
+# Mock objects
+MOCK_LIVEACTION_1_SUCCEEDED = {
+    'id': 'idfoo1',
+    'status': LIVEACTION_STATUS_SUCCEEDED
+}
+
+MOCK_LIVEACTION_2_FAILED = {
+    'id': 'idfoo2',
+    'status': LIVEACTION_STATUS_FAILED
+}
+
+# Mock liveaction objects for ActionChain workflow
+MOCK_LIVEACTION_3_RUNNING = {
+    'id': 'idfoo3',
+    'status': LIVEACTION_STATUS_RUNNING
+}
+
+MOCK_LIVEACTION_3_CHILD_1_RUNNING = {
+    'id': 'idchild1',
+    'context': {
+        'parent': {
+            'execution_id': 'idfoo3'
+        },
+        'chain': {
+            'name': 'task_1'
+        }
+    },
+    'status': LIVEACTION_STATUS_RUNNING
+}
+
+MOCK_LIVEACTION_3_CHILD_1_SUCCEEDED = {
+    'id': 'idchild1',
+    'context': {
+        'parent': {
+            'execution_id': 'idfoo3'
+        },
+        'chain': {
+            'name': 'task_1'
+        }
+    },
+    'status': LIVEACTION_STATUS_SUCCEEDED
+}
+
+MOCK_LIVEACTION_3_CHILD_1_OUTPUT_1 = {
+    'execution_id': 'idchild1',
+    'timestamp': '1505732598',
+    'output_type': 'stdout',
+    'data': 'line ac 4\n'
+}
+
+MOCK_LIVEACTION_3_CHILD_1_OUTPUT_2 = {
+    'execution_id': 'idchild1',
+    'timestamp': '1505732598',
+    'output_type': 'stderr',
+    'data': 'line ac 5\n'
+}
+
+MOCK_LIVEACTION_3_CHILD_2_RUNNING = {
+    'id': 'idchild2',
+    'context': {
+        'parent': {
+            'execution_id': 'idfoo3'
+        },
+        'chain': {
+            'name': 'task_2'
+        }
+    },
+    'status': LIVEACTION_STATUS_RUNNING
+}
+
+MOCK_LIVEACTION_3_CHILD_2_FAILED = {
+    'id': 'idchild2',
+    'context': {
+        'parent': {
+            'execution_id': 'idfoo3'
+        },
+        'chain': {
+            'name': 'task_2'
+        }
+    },
+    'status': LIVEACTION_STATUS_FAILED
+}
+
+MOCK_LIVEACTION_3_CHILD_2_OUTPUT_1 = {
+    'execution_id': 'idchild2',
+    'timestamp': '1505732598',
+    'output_type': 'stdout',
+    'data': 'line ac 100\n'
+}
+
+MOCK_LIVEACTION_3_SUCCEDED = {
+    'id': 'idfoo3',
+    'status': LIVEACTION_STATUS_SUCCEEDED
+}
+
+# Mock objects for Mistral workflow execution
+MOCK_LIVEACTION_4_RUNNING = {
+    'id': 'idfoo4',
+    'status': LIVEACTION_STATUS_RUNNING
+}
+
+MOCK_LIVEACTION_4_CHILD_1_RUNNING = {
+    'id': 'idmistralchild1',
+    'context': {
+        'mistral': {
+            'task_name': 'task_1'
+        },
+        'parent': {
+            'execution_id': 'idfoo4'
+        }
+    },
+    'status': LIVEACTION_STATUS_RUNNING
+}
+
+MOCK_LIVEACTION_4_CHILD_1_SUCCEEDED = {
+    'id': 'idmistralchild1',
+    'context': {
+        'mistral': {
+            'task_name': 'task_1',
+        },
+        'parent': {
+            'execution_id': 'idfoo4'
+        }
+    },
+    'status': LIVEACTION_STATUS_SUCCEEDED
+}
+
+MOCK_LIVEACTION_4_CHILD_1_OUTPUT_1 = {
+    'execution_id': 'idmistralchild1',
+    'timestamp': '1505732598',
+    'output_type': 'stdout',
+    'data': 'line mistral 4\n'
+}
+
+MOCK_LIVEACTION_4_CHILD_1_OUTPUT_2 = {
+    'execution_id': 'mistral',
+    'timestamp': '1505732598',
+    'output_type': 'stderr',
+    'data': 'line mistral 5\n'
+}
+
+MOCK_LIVEACTION_4_CHILD_2_RUNNING = {
+    'id': 'idmistralchild2',
+    'context': {
+        'mistral': {
+            'task_name': 'task_2',
+        },
+        'parent': {
+            'execution_id': 'idfoo4'
+        }
+    },
+    'status': LIVEACTION_STATUS_RUNNING
+}
+
+MOCK_LIVEACTION_4_CHILD_2_TIMED_OUT = {
+    'id': 'idmistralchild2',
+    'context': {
+        'mistral': {
+            'task_name': 'task_2',
+        },
+        'parent': {
+            'execution_id': 'idfoo4'
+        }
+    },
+    'status': LIVEACTION_STATUS_TIMED_OUT
+}
+
+MOCK_LIVEACTION_4_CHILD_2_OUTPUT_1 = {
+    'execution_id': 'idchild2',
+    'timestamp': '1505732598',
+    'output_type': 'stdout',
+    'data': 'line mistral 100\n'
+}
+
+MOCK_LIVEACTION_4_SUCCEDED = {
+    'id': 'idfoo4',
+    'status': LIVEACTION_STATUS_SUCCEEDED
+}
+
+# Mock objects for simple actions
+MOCK_OUTPUT_1 = {
+    'execution_id': 'idfoo3',
+    'timestamp': '1505732598',
+    'output_type': 'stdout',
+    'data': 'line 1\n'
+}
+
+MOCK_OUTPUT_2 = {
+    'execution_id': 'idfoo3',
+    'timestamp': '1505732598',
+    'output_type': 'stderr',
+    'data': 'line 2\n'
+}
+
+
+class ActionExecutionTailCommandTestCase(BaseCLITestCase):
+    capture_output = True
+
+    def __init__(self, *args, **kwargs):
+        super(ActionExecutionTailCommandTestCase, self).__init__(*args, **kwargs)
+        self.shell = Shell()
+
+    @mock.patch.object(
+        httpclient.HTTPClient, 'get',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(MOCK_LIVEACTION_1_SUCCEEDED),
+                                                      200, 'OK')))
+    def test_tail_simple_execution_already_finished_succeeded(self):
+        argv = ['execution', 'tail', 'idfoo1']
+
+        self.assertEqual(self.shell.run(argv), 0)
+        stdout = self.stdout.getvalue()
+        stderr = self.stderr.getvalue()
+        self.assertTrue('Execution idfoo1 has completed (status=succeeded)' in stdout)
+        self.assertEqual(stderr, '')
+
+    @mock.patch.object(
+        httpclient.HTTPClient, 'get',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(MOCK_LIVEACTION_2_FAILED),
+                                                      200, 'OK')))
+    def test_tail_simple_execution_already_finished_failed(self):
+        argv = ['execution', 'tail', 'idfoo2']
+
+        self.assertEqual(self.shell.run(argv), 0)
+        stdout = self.stdout.getvalue()
+        stderr = self.stderr.getvalue()
+        self.assertTrue('Execution idfoo2 has completed (status=failed)' in stdout)
+        self.assertEqual(stderr, '')
+
+    @mock.patch.object(
+        httpclient.HTTPClient, 'get',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(MOCK_LIVEACTION_3_RUNNING),
+                                                      200, 'OK')))
+    @mock.patch('st2client.client.StreamManager', autospec=True)
+    def test_tail_simple_execution_running_no_data_produced(self, mock_stream_manager):
+        argv = ['execution', 'tail', 'idfoo1']
+
+        MOCK_EVENTS = [
+            MOCK_LIVEACTION_1_SUCCEEDED
+        ]
+
+        mock_cls = mock.Mock()
+        mock_cls.listen = mock.Mock()
+        mock_listen_generator = mock.Mock()
+        mock_listen_generator.return_value = MOCK_EVENTS
+        mock_cls.listen.side_effect = mock_listen_generator
+        mock_stream_manager.return_value = mock_cls
+
+        self.assertEqual(self.shell.run(argv), 0)
+        self.assertEqual(mock_listen_generator.call_count, 1)
+
+        stdout = self.stdout.getvalue()
+        stderr = self.stderr.getvalue()
+
+        expected_result = """
+Execution idfoo1 has completed (status=succeeded).
+"""
+        self.assertEqual(stdout, expected_result)
+        self.assertEqual(stderr, '')
+
+    @mock.patch.object(
+        httpclient.HTTPClient, 'get',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(MOCK_LIVEACTION_3_RUNNING),
+                                                      200, 'OK')))
+    @mock.patch('st2client.client.StreamManager', autospec=True)
+    def test_tail_simple_execution_running_with_data(self, mock_stream_manager):
+        argv = ['execution', 'tail', 'idfoo3']
+
+        MOCK_EVENTS = [
+            MOCK_LIVEACTION_3_RUNNING,
+            MOCK_OUTPUT_1,
+            MOCK_OUTPUT_2,
+            MOCK_LIVEACTION_3_SUCCEDED
+        ]
+
+        mock_cls = mock.Mock()
+        mock_cls.listen = mock.Mock()
+        mock_listen_generator = mock.Mock()
+        mock_listen_generator.return_value = MOCK_EVENTS
+        mock_cls.listen.side_effect = mock_listen_generator
+        mock_stream_manager.return_value = mock_cls
+
+        self.assertEqual(self.shell.run(argv), 0)
+        self.assertEqual(mock_listen_generator.call_count, 1)
+
+        stdout = self.stdout.getvalue()
+        stderr = self.stderr.getvalue()
+
+        expected_result = """
+line 1
+line 2
+
+Execution idfoo3 has completed (status=succeeded).
+""".lstrip()
+        self.assertEqual(stdout, expected_result)
+        self.assertEqual(stderr, '')
+
+    @mock.patch.object(
+        httpclient.HTTPClient, 'get',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(MOCK_LIVEACTION_3_RUNNING),
+                                                      200, 'OK')))
+    @mock.patch('st2client.client.StreamManager', autospec=True)
+    def test_tail_action_chain_workflow_execution(self, mock_stream_manager):
+        argv = ['execution', 'tail', 'idfoo3']
+
+        MOCK_EVENTS = [
+            # Workflow started running
+            MOCK_LIVEACTION_3_RUNNING,
+
+            # Child task 1 started running
+            MOCK_LIVEACTION_3_CHILD_1_RUNNING,
+
+            # Output produced by the child task
+            MOCK_LIVEACTION_3_CHILD_1_OUTPUT_1,
+            MOCK_LIVEACTION_3_CHILD_1_OUTPUT_2,
+
+            # Child task 1 finished
+            MOCK_LIVEACTION_3_CHILD_1_SUCCEEDED,
+
+            # Child task 2 started running
+            MOCK_LIVEACTION_3_CHILD_2_RUNNING,
+
+            # Output produced by child task
+            MOCK_LIVEACTION_3_CHILD_2_OUTPUT_1,
+
+            # Child task 2 finished
+            MOCK_LIVEACTION_3_CHILD_2_FAILED,
+
+            # Parent workflow task finished
+            MOCK_LIVEACTION_3_SUCCEDED
+        ]
+
+        mock_cls = mock.Mock()
+        mock_cls.listen = mock.Mock()
+        mock_listen_generator = mock.Mock()
+        mock_listen_generator.return_value = MOCK_EVENTS
+        mock_cls.listen.side_effect = mock_listen_generator
+        mock_stream_manager.return_value = mock_cls
+
+        self.assertEqual(self.shell.run(argv), 0)
+        self.assertEqual(mock_listen_generator.call_count, 1)
+
+        stdout = self.stdout.getvalue()
+        stderr = self.stderr.getvalue()
+
+        expected_result = """
+Child execution (task=task_1) idchild1 has started.
+
+line ac 4
+line ac 5
+
+Child execution (task=task_1) idchild1 has finished (status=succeeded).
+Child execution (task=task_2) idchild2 has started.
+
+line ac 100
+
+Child execution (task=task_2) idchild2 has finished (status=failed).
+
+Execution idfoo3 has completed (status=succeeded).
+""".lstrip()
+        self.assertEqual(stdout, expected_result)
+        self.assertEqual(stderr, '')
+
+    @mock.patch.object(
+        httpclient.HTTPClient, 'get',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(MOCK_LIVEACTION_4_RUNNING),
+                                                     200, 'OK')))
+    @mock.patch('st2client.client.StreamManager', autospec=True)
+    def test_tail_mistral_workflow_execution(self, mock_stream_manager):
+        argv = ['execution', 'tail', 'idfoo4']
+
+        MOCK_EVENTS = [
+            # Workflow started running
+            MOCK_LIVEACTION_4_RUNNING,
+
+            # Child task 1 started running
+            MOCK_LIVEACTION_4_CHILD_1_RUNNING,
+
+            # Output produced by the child task
+            MOCK_LIVEACTION_4_CHILD_1_OUTPUT_1,
+            MOCK_LIVEACTION_4_CHILD_1_OUTPUT_2,
+
+            # Child task 1 finished
+            MOCK_LIVEACTION_4_CHILD_1_SUCCEEDED,
+
+            # Child task 2 started running
+            MOCK_LIVEACTION_4_CHILD_2_RUNNING,
+
+            # Output produced by child task
+            MOCK_LIVEACTION_4_CHILD_2_OUTPUT_1,
+
+            # Child task 2 finished
+            MOCK_LIVEACTION_4_CHILD_2_TIMED_OUT,
+
+            # Parent workflow task finished
+            MOCK_LIVEACTION_4_SUCCEDED
+        ]
+
+        mock_cls = mock.Mock()
+        mock_cls.listen = mock.Mock()
+        mock_listen_generator = mock.Mock()
+        mock_listen_generator.return_value = MOCK_EVENTS
+        mock_cls.listen.side_effect = mock_listen_generator
+        mock_stream_manager.return_value = mock_cls
+
+        self.assertEqual(self.shell.run(argv), 0)
+        self.assertEqual(mock_listen_generator.call_count, 1)
+
+        stdout = self.stdout.getvalue()
+        stderr = self.stderr.getvalue()
+
+        expected_result = """
+Child execution (task=task_1) idmistralchild1 has started.
+
+line mistral 4
+line mistral 5
+
+Child execution (task=task_1) idmistralchild1 has finished (status=succeeded).
+Child execution (task=task_2) idmistralchild2 has started.
+
+line mistral 100
+
+Child execution (task=task_2) idmistralchild2 has finished (status=timeout).
+
+Execution idfoo4 has completed (status=succeeded).
+""".lstrip()
+        self.assertEqual(stdout, expected_result)
+        self.assertEqual(stderr, '')