diff --git a/src/lib/api.py b/src/lib/api.py index 508b9ca..92f9acd 100644 --- a/src/lib/api.py +++ b/src/lib/api.py @@ -4,6 +4,7 @@ SUPPORTED_API = [ "setgd", "delgd", "add_plot_values", - "last_plot_value" + "last_plot_value", + "text_mode", ] diff --git a/src/testium/__init__.py b/src/testium/__init__.py index e072cd0..179f3e7 100755 --- a/src/testium/__init__.py +++ b/src/testium/__init__.py @@ -102,7 +102,7 @@ def main(): if (lf != '') or (rf != '') or (tf != '') or (pn != []): print('"-l", "-p", "-t", "-n" options are not supported in this mode.') - t = Terminal(os.getcwd(), cf, defines, args.no_color) + t = Terminal(os.getcwd(), cf, defines, args.no_color, text_mode=True) loop = 1 while loop: @@ -124,7 +124,7 @@ def main(): print('"-l" option is not supported in this mode.') from interpreter.batch import Batch - b = Batch(tf, cf, defines, rf, args.report_type, pn, args.no_color) + b = Batch(tf, cf, defines, rf, args.report_type, pn, args.no_color, text_mode=True) else: from main_win.testium_win import MainWin diff --git a/src/testium/interpreter/batch.py b/src/testium/interpreter/batch.py index 5e2a8ab..dc765ee 100644 --- a/src/testium/interpreter/batch.py +++ b/src/testium/interpreter/batch.py @@ -22,6 +22,7 @@ class Batch: report_type, report_pattern, no_color, + text_mode=False, ): try: try: @@ -59,6 +60,7 @@ class Batch: self.tst_ctrl, config_files, defines, + text_mode=text_mode, ) tst_proc.start() @@ -82,6 +84,8 @@ class Batch: # No id -> finished break except Empty: + if not tst_proc.is_alive(): + break continue # Close the process and wait for termination @@ -95,4 +99,7 @@ class Batch: stdio_redir.restore() def sigint_handler(self, signal_received, frame): - self.tst_ctrl.control("stop") + try: + self.tst_ctrl.control("stop", timeout=5) + except Exception: + pass diff --git a/src/testium/interpreter/process.py b/src/testium/interpreter/process.py index f79a0ab..2fd93c6 100644 --- a/src/testium/interpreter/process.py +++ b/src/testium/interpreter/process.py @@ -1,4 +1,5 @@ import os +import signal from multiprocessing import Process, Queue, Pipe from queue import Empty from threading import Thread @@ -41,6 +42,7 @@ class TestProcess(Process): config_files, defines, gui_defaults={}, + text_mode=False, ) -> None: super().__init__() self.__fname = file_name @@ -49,6 +51,7 @@ class TestProcess(Process): self.__cfgf = config_files self.__defs = defines self.__gui_defaults = gui_defaults # default values coming from GUI prefs + self.__text_mode = text_mode self.__exec = False self.__loaded = False self.__closed = False @@ -194,6 +197,7 @@ class TestProcess(Process): def run(self): + signal.signal(signal.SIGINT, signal.SIG_IGN) try: try: # Thread for stdout redirection @@ -224,6 +228,10 @@ Is the python exec path correct ?""" # Load the test file test_dict, param_files = self._load_test(init_param_files, glob_variables) + if self.__text_mode: + tm.setgd("_text_mode", True) + tm.setgd("_interactive", False) + # Backup the global dict in case of restart of the test gdict = backup_gd() @@ -421,7 +429,7 @@ Is the python exec path correct ?""" try: # read the pipe data data = cconn.recv() - print(data, end="") + print(data, end="", flush=True) except EOFError: # exit the loop is the pipe is closed break diff --git a/src/testium/interpreter/terminal.py b/src/testium/interpreter/terminal.py index aa6ca3d..1910ecb 100644 --- a/src/testium/interpreter/terminal.py +++ b/src/testium/interpreter/terminal.py @@ -56,7 +56,7 @@ class Terminal(Cmd): cst.TYPE_CYCLE ] - def __init__(self, working_dir, config_files, defines, no_color): + def __init__(self, working_dir, config_files, defines, no_color, text_mode=False): super().__init__() self.working_dir = working_dir self.config_files = config_files @@ -69,6 +69,8 @@ class Terminal(Cmd): # Define the builtin variables set_standard_gd_keys("Unnamed", self.working_dir, '', config_files) update_global([], defines) + if text_mode: + tm.setgd("_text_mode", True) # creation of the functions for tst in self.SUPPORTED_TESTS: diff --git a/src/testium/interpreter/test_items/test_item_choices_dialog.py b/src/testium/interpreter/test_items/test_item_choices_dialog.py index e8a4f50..15449a8 100644 --- a/src/testium/interpreter/test_items/test_item_choices_dialog.py +++ b/src/testium/interpreter/test_items/test_item_choices_dialog.py @@ -1,7 +1,6 @@ from interpreter.test_items.test_item import test_run from interpreter.test_items.test_result import TestValue -from interpreter.test_items.dialog_choices_files import choices_dialog -from interpreter.test_items.test_item_dialog_base import TestItemDialogBase +from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive from interpreter.utils.constants import TestItemType as cst from lib.tum_except import item_load_context import libs.testium as tm @@ -19,11 +18,54 @@ class TestItemChoicesDialog(TestItemDialogBase): self._default_icon = self._prms.getParam("icon", required=False, default=None) self._auto_result = self._prms.getParam("auto_result", required=False, default=None) + def _print_choices(self, choices, indent=0): + if not isinstance(choices, list): + return + for choice in choices: + name = choice.get("name", "") + desc = choice.get("description", "") + line = " " * indent + f"- {name}" + if desc: + line += f": {desc}" + print(line) + sub = choice.get("choices", None) + if sub: + self._print_choices(sub, indent + 1) + + def _all_checked(self, choices): + result = [] + if not isinstance(choices, list): + return result + for choice in choices: + item = {"name": choice.get("name", ""), "checked": True} + sub = choice.get("choices", None) + if sub is not None: + item["choices"] = self._all_checked(sub) + result.append(item) + return result + @test_run def execute(self): q = self._prms.expanse(self._question) choices = self._prms.expanse(self._choices) icon = self._prms.expanse(self._default_icon) + if _is_text_mode(): + print(f"Choices: {q}") + self._print_choices(choices) + if _is_interactive(): + ans = input("Accept all? (y/n) [default: y]: ").strip().lower() + else: + ans = '' + if ans in ('n', 'no'): + tm.delgd("cs_" + self._name) + self.result.set(TestValue.FAILURE, "Cancelled") + else: + val = self._all_checked(choices) + self.result.value = val + tm.setgd("cs_" + self._name, val) + self.result.set(TestValue.SUCCESS, str(val)) + return + from interpreter.test_items.dialog_choices_files import choices_dialog ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None args = [self.name(), q, choices, icon] + ([ar] if ar is not None else []) result = self._run_dialog_with_result(choices_dialog.main, args) diff --git a/src/testium/interpreter/test_items/test_item_dialog_base.py b/src/testium/interpreter/test_items/test_item_dialog_base.py index be1c7ea..5e580cc 100644 --- a/src/testium/interpreter/test_items/test_item_dialog_base.py +++ b/src/testium/interpreter/test_items/test_item_dialog_base.py @@ -1,7 +1,16 @@ import multiprocessing +import libs.testium as tm from interpreter.test_items.test_item import TestItem + +def _is_text_mode(): + return tm.text_mode() + + +def _is_interactive(): + return bool(tm.gd("_interactive", True)) + _spawn_ctx = multiprocessing.get_context('spawn') diff --git a/src/testium/interpreter/test_items/test_item_image_dialog.py b/src/testium/interpreter/test_items/test_item_image_dialog.py index 8b34c73..24b47a5 100644 --- a/src/testium/interpreter/test_items/test_item_image_dialog.py +++ b/src/testium/interpreter/test_items/test_item_image_dialog.py @@ -2,8 +2,7 @@ import os from interpreter.test_items.test_item import test_run from interpreter.test_items.test_result import TestValue -from interpreter.test_items.dialog_image_files import dialog_image -from interpreter.test_items.test_item_dialog_base import TestItemDialogBase +from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive from interpreter.utils.constants import TestItemType as cst from lib.tum_except import item_load_context import libs.testium as tm @@ -32,6 +31,17 @@ class TestItemImageDialog(TestItemDialogBase): image_path = os.path.normpath( os.path.join(tm.gd("test_directory"), image_path) ) + if _is_text_mode(): + if _is_interactive(): + ans = input("Accept? (y/n) [default: y]: ").strip().lower() + else: + ans = '' + if ans in ('n', 'no'): + self.result.set(TestValue.FAILURE) + else: + self.result.set(TestValue.SUCCESS) + return + from interpreter.test_items.dialog_image_files import dialog_image ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None args = [self.name(), q, image_path] + ([ar] if ar is not None else []) succ = self._run_dialog_with_result(dialog_image.main, args) diff --git a/src/testium/interpreter/test_items/test_item_msg_dialog.py b/src/testium/interpreter/test_items/test_item_msg_dialog.py index 38c71aa..849af6f 100644 --- a/src/testium/interpreter/test_items/test_item_msg_dialog.py +++ b/src/testium/interpreter/test_items/test_item_msg_dialog.py @@ -3,8 +3,7 @@ import sys from interpreter.test_items.test_item import test_run from interpreter.test_items.test_result import TestValue -from interpreter.test_items.dialog_msg_files import msg_dialog -from interpreter.test_items.test_item_dialog_base import TestItemDialogBase +from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive from interpreter.utils.constants import TestItemType as cst from lib.tum_except import item_load_context @@ -26,6 +25,12 @@ class TestItemMsgDialog(TestItemDialogBase): def execute(self): q = self._prms.expanse(self._question) print("Message Displayed:\n" + q) + if _is_text_mode(): + if _is_interactive(): + input("Press Enter to continue...") + self.result.set(TestValue.SUCCESS) + return + from interpreter.test_items.dialog_msg_files import msg_dialog ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None args = [self.name(), q] + ([ar] if ar is not None else []) exitcode = self._run_dialog(msg_dialog.main, args) diff --git a/src/testium/interpreter/test_items/test_item_note_dialog.py b/src/testium/interpreter/test_items/test_item_note_dialog.py index eab04c3..8c0b010 100644 --- a/src/testium/interpreter/test_items/test_item_note_dialog.py +++ b/src/testium/interpreter/test_items/test_item_note_dialog.py @@ -1,7 +1,6 @@ from interpreter.test_items.test_item import test_run from interpreter.test_items.test_result import TestValue -from interpreter.test_items.dialog_note_files import test_dialog -from interpreter.test_items.test_item_dialog_base import TestItemDialogBase +from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive from interpreter.utils.constants import TestItemType as cst from lib.tum_except import item_load_context import libs.testium as tm @@ -22,6 +21,29 @@ class TestItemNoteDialog(TestItemDialogBase): def execute(self): q = self._prms.expanse(self._question) print("Question:\n" + q) + if _is_text_mode(): + lines = [] + if _is_interactive(): + print("Enter your note (type '.' on a new line to finish, empty line to cancel):") + while True: + line = input() + if line == '.': + break + lines.append(line) + val = '\n'.join(lines) + tm.setgd(self.name(), val) + print("\n" + ("-" * 80) + "\n") + print("- Test note\n") + print("-" * 80 + "\n") + print(val) + print("-" * 80 + "\n") + self.result.reported = {'note': val} + if val: + self.result.set(TestValue.SUCCESS, val) + else: + self.result.set(TestValue.FAILURE, val) + return + from interpreter.test_items.dialog_note_files import test_dialog ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None av = self._prms.expanse(self._auto_value) if self._auto_value is not None else None args = [self.name(), q] + ([ar, av] if ar is not None else []) diff --git a/src/testium/interpreter/test_items/test_item_question_dialog.py b/src/testium/interpreter/test_items/test_item_question_dialog.py index 88db0c3..3737ea9 100644 --- a/src/testium/interpreter/test_items/test_item_question_dialog.py +++ b/src/testium/interpreter/test_items/test_item_question_dialog.py @@ -1,9 +1,6 @@ -from PySide6.QtWidgets import QMessageBox - from interpreter.test_items.test_item import test_run from interpreter.test_items.test_result import TestValue -from interpreter.test_items.dialog_question_files import question_dialog -from interpreter.test_items.test_item_dialog_base import TestItemDialogBase +from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive from interpreter.utils.constants import TestItemType as cst from lib.tum_except import item_load_context @@ -25,12 +22,26 @@ class TestItemQuestionDialog(TestItemDialogBase): def execute(self): q = self._prms.expanse(self._question) print('Question asked:\n' + q + '\n') + if _is_text_mode(): + if _is_interactive(): + ans = input("Answer yes (y) or no (n) [default: y]: ").strip().lower() + else: + ans = '' + if ans in ('n', 'no'): + self.result.set(TestValue.FAILURE) + print('Answer: NO\n') + else: + self.result.set(TestValue.SUCCESS) + print('Answer: YES\n') + return + from interpreter.test_items.dialog_question_files import question_dialog ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None args = [self.name(), q] + ([ar] if ar is not None else []) succ = self._run_dialog_with_result(question_dialog.main, args) if succ is None: self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") return + from PySide6.QtWidgets import QMessageBox if succ == QMessageBox.Yes: self.result.set(TestValue.SUCCESS) print('Answer: YES\n') diff --git a/src/testium/interpreter/test_items/test_item_sleep.py b/src/testium/interpreter/test_items/test_item_sleep.py index 1b6a15d..e07a4ae 100644 --- a/src/testium/interpreter/test_items/test_item_sleep.py +++ b/src/testium/interpreter/test_items/test_item_sleep.py @@ -3,9 +3,9 @@ from time import sleep from datetime import timedelta from multiprocessing import Process, Pipe +import libs.testium as tm from interpreter.test_items.test_item import (TestItem, test_run) from interpreter.test_items.test_result import (TestValue) -from interpreter.test_items.dialog_sleep_files import dialog_sleep from interpreter.utils.constants import TestItemType as cst from lib.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context @@ -43,6 +43,20 @@ class TestItemSleep(TestItem): #test core function if has_dialog: + if tm.text_mode(): + import time as _time + print(f"Sleep {timeout}s (press Ctrl+C to abort)...") + end_time = _time.time() + float(timeout) + while _time.time() < end_time and not self._is_stopped: + sleep(0.2) + if self._is_stopped: + print("Aborted") + self.result.set(TestValue.FAILURE, 'Sleep aborted') + else: + self.result.set(TestValue.SUCCESS, f'Sleep {timeout} sec') + return + + from interpreter.test_items.dialog_sleep_files import dialog_sleep parent_conn, child_conn = Pipe() p=Process(target=dialog_sleep.main, args=([self.name(), timeout],child_conn)) p.start() diff --git a/src/testium/interpreter/test_items/test_item_tested_references.py b/src/testium/interpreter/test_items/test_item_tested_references.py index c3ecf92..f661e4a 100644 --- a/src/testium/interpreter/test_items/test_item_tested_references.py +++ b/src/testium/interpreter/test_items/test_item_tested_references.py @@ -1,7 +1,6 @@ from interpreter.test_items.test_item import test_run from interpreter.test_items.test_result import TestValue -from interpreter.test_items.tested_references_files import tested_refs_dialog -from interpreter.test_items.test_item_dialog_base import TestItemDialogBase +from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive from interpreter.utils.constants import TestItemType as cst from lib.tum_except import item_load_context import libs.testium as tm @@ -22,9 +21,26 @@ class TestItemTestedRefsDialog(TestItemDialogBase): def execute(self): q = self._prms.expanse(self._question) init_values = ','.join(self._init_values) - ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None - args = [self.name(), q, init_values] + ([ar] if ar is not None else []) - result = self._run_dialog_with_result(tested_refs_dialog.main, args) + if _is_text_mode(): + print(f"References: {q}") + rows = init_values.split(',') if init_values else [''] + result_rows = [] + for i, row in enumerate(rows): + parts = (row.split('/') + ['', '', ''])[:3] + if _is_interactive(): + ref = input(f"Row {i+1} - Reference [{parts[0]}]: ").strip() or parts[0] + rev = input(f"Row {i+1} - Revision [{parts[1]}]: ").strip() or parts[1] + serial = input(f"Row {i+1} - Serial [{parts[2]}]: ").strip() or parts[2] + else: + ref, rev, serial = parts[0], parts[1], parts[2] + result_rows.append(f"{ref}/{rev}/{serial}") + val = ','.join(result_rows) + result = [val, True] + else: + from interpreter.test_items.tested_references_files import tested_refs_dialog + ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None + args = [self.name(), q, init_values] + ([ar] if ar is not None else []) + result = self._run_dialog_with_result(tested_refs_dialog.main, args) if result is None: self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") return diff --git a/src/testium/interpreter/test_items/test_item_value_dialog.py b/src/testium/interpreter/test_items/test_item_value_dialog.py index 64ad25a..684be5f 100644 --- a/src/testium/interpreter/test_items/test_item_value_dialog.py +++ b/src/testium/interpreter/test_items/test_item_value_dialog.py @@ -1,7 +1,6 @@ from interpreter.test_items.test_item import test_run from interpreter.test_items.test_result import TestValue -from interpreter.test_items.dialog_value_files import test_dialog -from interpreter.test_items.test_item_dialog_base import TestItemDialogBase +from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive from interpreter.utils.constants import TestItemType as cst from lib.tum_except import item_load_context import libs.testium as tm @@ -27,6 +26,23 @@ class TestItemValueDialog(TestItemDialogBase): q = self._prms.expanse(self._question) d = self._prms.expanse(self._default) print("Question:\n" + q) + if _is_text_mode(): + if _is_interactive(): + prompt = f"Enter value [{d}]: " if d else "Enter value: " + ans = input(prompt).strip() + else: + ans = '' + val = ans if ans else d + tm.setgd(self.name(), val) + print("Answer: " + str(val)) + if val: + self.result.reported = {'question': q, 'answer': val} + self.result.value = val + self.result.set(TestValue.SUCCESS, val) + else: + self.result.set(TestValue.FAILURE, 'No value entered') + return + from interpreter.test_items.dialog_value_files import test_dialog ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None av = self._prms.expanse(self._auto_value) if self._auto_value is not None else None args = [self.name(), q, d] + ([ar, av] if ar is not None else []) diff --git a/src/testium/interpreter/utils/lua_process.py b/src/testium/interpreter/utils/lua_process.py index 2ccc771..ca19fe3 100644 --- a/src/testium/interpreter/utils/lua_process.py +++ b/src/testium/interpreter/utils/lua_process.py @@ -189,7 +189,10 @@ class LuaProcessBase: if tm.debug_enabled() and tm.gd("debug_rpc", False): params.append("--verbose") - self._process = subprocess.Popen(params, env=env, cwd=func_proc_path) + self._process = subprocess.Popen( + params, env=env, cwd=func_proc_path, + stdin=subprocess.DEVNULL, restore_signals=False, + ) self._rpc = JsonRpcClient( "localhost", self._port, req_handler=self._req_handler diff --git a/src/testium/interpreter/utils/py_process.py b/src/testium/interpreter/utils/py_process.py index ba0d313..531dbc6 100644 --- a/src/testium/interpreter/utils/py_process.py +++ b/src/testium/interpreter/utils/py_process.py @@ -158,7 +158,10 @@ class PyProcessBase: if tm.debug_enabled() and tm.gd("debug_rpc", False): params.append("-v") - self._process = subprocess.Popen(params, env=env, cwd=func_proc_path) + self._process = subprocess.Popen( + params, env=env, cwd=func_proc_path, + stdin=subprocess.DEVNULL, restore_signals=False, + ) self._rpc = JsonRpcClient( "localhost", self._port, req_handler=self._req_handler diff --git a/src/testium/libs/testium.py b/src/testium/libs/testium.py index acce435..11ca2ba 100644 --- a/src/testium/libs/testium.py +++ b/src/testium/libs/testium.py @@ -209,6 +209,15 @@ def OS(): return platform.system() +def text_mode(): + """Whether testium is running in text mode (batch ``-b`` or terminal ``-m``). + + :return: ``True`` if running in text mode, ``False`` otherwise. + :rtype: bool + """ + return bool(globdict.gd("_text_mode", False)) + + def sys_encoding(): if OS() == "Windows": enc = "oem" diff --git a/test/validation/items/plot/test.tum b/test/validation/items/plot/test.tum index 1897780..bd2019f 100644 --- a/test/validation/items/plot/test.tum +++ b/test/validation/items/plot/test.tum @@ -1,74 +1,73 @@ -- plot: - name: Open the plot - condition: $(validation_dialogs) - key: $(test)_PASS - plot_name: Mon Plot - steps: - - open: - log_path: $(validation_report_path) - -- plot: - name: Add periodic to the plot - condition: $(validation_dialogs) - key: $(test)_PASS - plot_name: Mon Plot - steps: - - periodic: - period: 1 - file: $(test_path)$(psep)plot.py - func_name: random_value - eval: '{"periodic": $(result)}' - -- sleep: - name: sleep - condition: $(validation_dialogs) - dialog: true - timeout: 3 - -- loop: - name: Add of other data in the plot - condition: $(validation_dialogs) - iterator: 10 - steps: - - - plot: - name: Add to the plot - key: $(test)_PASS - plot_name: Mon Plot - steps: - - add: - value1: $(loop_index) - value2: $(loop_index)+2 - - - sleep: - name: sleep between values - timeout: 1 - - - py_func: - name: last plot values - key: $(test)_PASS - file: $(test_path)$(psep)plot.py - func_name: LastValues - param: - - Mon Plot - -- plot: - name: Export - execute_on_stop: True - condition: $(validation_dialogs) - key: $(test)_PASS - plot_name: Mon Plot - steps: - - export: $(validation_report_path)/plot_export.pdf - - export: $(validation_report_path)/plot_export.csv - -- plot: - name: Close the plot - execute_on_stop: True - condition: $(validation_dialogs) - key: $(test)_PASS - plot_name: Mon Plot - steps: - - close: - wait_dialog_exit: True - timeout: 2 +- group: + name: Plot test + condition: <| $(validation_dialogs) and not tm.text_mode() |> + steps: + + - plot: + name: Open the plot + key: $(test)_PASS + plot_name: Mon Plot + steps: + - open: + log_path: $(validation_report_path) + + - plot: + name: Add periodic to the plot + key: $(test)_PASS + plot_name: Mon Plot + steps: + - periodic: + period: 1 + file: $(test_path)$(psep)plot.py + func_name: random_value + eval: '{"periodic": $(result)}' + + - sleep: + name: sleep + dialog: true + timeout: 3 + + - loop: + name: Add of other data in the plot + iterator: 10 + steps: + + - plot: + name: Add to the plot + key: $(test)_PASS + plot_name: Mon Plot + steps: + - add: + value1: $(loop_index) + value2: $(loop_index)+2 + + - sleep: + name: sleep between values + timeout: 1 + + - py_func: + name: last plot values + key: $(test)_PASS + file: $(test_path)$(psep)plot.py + func_name: LastValues + param: + - Mon Plot + + - plot: + name: Export + execute_on_stop: True + key: $(test)_PASS + plot_name: Mon Plot + steps: + - export: $(validation_report_path)/plot_export.pdf + - export: $(validation_report_path)/plot_export.csv + + - plot: + name: Close the plot + execute_on_stop: True + key: $(test)_PASS + plot_name: Mon Plot + steps: + - close: + wait_dialog_exit: True + timeout: 2