Add --app-ready-pattern option to python test arguments (#35871)

* Reuse Subprocess in run_python_test.py script

* Add --app-ready-pattern option to run_python_test.py

* Replace script-start-delay with app-ready-pattern

* Drop support for script-start-delay

* Use rmtree() instead of explicit "rm -rf" calls

* Update missed python.md section

* Silence output from tests

* Fix removing files

* Restyled by prettier-markdown

* Fix documentation builder warning

* Remove unused import

* Fix Metadata unit test

---------

Co-authored-by: Restyled.io <commits@restyled.io>
diff --git a/docs/testing/python.md b/docs/testing/python.md
index 07e157c..a186e9b 100644
--- a/docs/testing/python.md
+++ b/docs/testing/python.md
@@ -50,12 +50,19 @@
 # for details about the block below.
 #
 # === BEGIN CI TEST ARGUMENTS ===
-# test-runner-runs: run1
-# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
-# test-runner-run/run1/factoryreset: True
-# test-runner-run/run1/quiet: True
-# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
-# test-runner-run/run1/script-args: --storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+# test-runner-runs:
+#   run1:
+#     app: ${ALL_CLUSTERS_APP}
+#     app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
+#     script-args: >
+#       --storage-path admin_storage.json
+#       --commissioning-method on-network
+#       --discriminator 1234
+#       --passcode 20202021
+#       --trace-to json:${TRACE_TEST_JSON}.json
+#       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+#     factoryreset: true
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 class TC_MYTEST_1_1(MatterBaseTest):
@@ -669,10 +676,10 @@
 # test-runner-runs:
 #   run1:
 #     app: ${TYPE_OF_APP}
-#     factoryreset: <true|false>
-#     quiet: <true|false>
 #     app-args: <app_arguments>
 #     script-args: <script_arguments>
+#     factoryreset: <true|false>
+#     quiet: <true|false>
 # === END CI TEST ARGUMENTS ===
 ```
 
@@ -701,19 +708,18 @@
     -   Example:
         `--discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json`
 
+-   `app-ready-pattern`: Regular expression pattern to match against the output
+    of the application to determine when the application is ready. If this
+    parameter is specified, the test runner will not run the test script until
+    the pattern is found.
+
+    -   Example: `"Manual pairing code: \\[\\d+\\]"`
+
 -   `script-args`: Specifies the arguments to be passed to the test script.
 
     -   Example:
         `--storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto`
 
--   `script-start-delay`: Specifies the number of seconds to wait before
-    starting the test script. This parameter can be used to allow the
-    application to initialize itself properly before the test script will try to
-    commission it (e.g. in case if the application needs to be commissioned to
-    some other controller first). By default, the delay is 0 seconds.
-
-    -   Example: `10`
-
 This structured format ensures that all necessary configurations are clearly
 defined and easily understood, allowing for consistent and reliable test
 execution.
diff --git a/scripts/tests/run_python_test.py b/scripts/tests/run_python_test.py
index 6474819..d7d3c69 100755
--- a/scripts/tests/run_python_test.py
+++ b/scripts/tests/run_python_test.py
@@ -15,23 +15,21 @@
 # limitations under the License.
 
 import datetime
+import glob
 import io
 import logging
 import os
 import os.path
-import queue
+import pathlib
 import re
 import shlex
-import signal
-import subprocess
 import sys
-import threading
 import time
-import typing
 
 import click
 import coloredlogs
 from chip.testing.metadata import Metadata, MetadataReader
+from chip.testing.tasks import Subprocess
 from colorama import Fore, Style
 
 DEFAULT_CHIP_ROOT = os.path.abspath(
@@ -39,34 +37,35 @@
 
 MATTER_DEVELOPMENT_PAA_ROOT_CERTS = "credentials/development/paa-root-certs"
 
+TAG_PROCESS_APP = f"[{Fore.GREEN}APP {Style.RESET_ALL}]".encode()
+TAG_PROCESS_TEST = f"[{Fore.GREEN}TEST{Style.RESET_ALL}]".encode()
+TAG_STDOUT = f"[{Fore.YELLOW}STDOUT{Style.RESET_ALL}]".encode()
+TAG_STDERR = f"[{Fore.RED}STDERR{Style.RESET_ALL}]".encode()
 
-def EnqueueLogOutput(fp, tag, output_stream, q):
-    for line in iter(fp.readline, b''):
-        timestamp = time.time()
-        if len(line) > len('[1646290606.901990]') and line[0:1] == b'[':
-            try:
-                timestamp = float(line[1:18].decode())
-                line = line[19:]
-            except Exception:
-                pass
-        output_stream.write(
-            (f"[{datetime.datetime.fromtimestamp(timestamp).isoformat(sep=' ')}]").encode() + tag + line)
-        sys.stdout.flush()
-    fp.close()
+# RegExp which matches the timestamp in the output of CHIP application
+OUTPUT_TIMESTAMP_MATCH = re.compile(r'(?P<prefix>.*)\[(?P<ts>\d+\.\d+)\](?P<suffix>\[\d+:\d+\].*)'.encode())
 
 
-def RedirectQueueThread(fp, tag, stream_output, queue) -> threading.Thread:
-    log_queue_thread = threading.Thread(target=EnqueueLogOutput, args=(
-        fp, tag, stream_output, queue))
-    log_queue_thread.start()
-    return log_queue_thread
+def chip_output_extract_timestamp(line: bytes) -> (float, bytes):
+    """Try to extract timestamp from a CHIP application output line."""
+    if match := OUTPUT_TIMESTAMP_MATCH.match(line):
+        return float(match.group(2)), match.group(1) + match.group(3) + b'\n'
+    return time.time(), line
 
 
-def DumpProgramOutputToQueue(thread_list: typing.List[threading.Thread], tag: str, process: subprocess.Popen, stream_output, queue: queue.Queue):
-    thread_list.append(RedirectQueueThread(process.stdout,
-                                           (f"[{tag}][{Fore.YELLOW}STDOUT{Style.RESET_ALL}]").encode(), stream_output, queue))
-    thread_list.append(RedirectQueueThread(process.stderr,
-                                           (f"[{tag}][{Fore.RED}STDERR{Style.RESET_ALL}]").encode(), stream_output, queue))
+def process_chip_output(line: bytes, is_stderr: bool, process_tag: bytes = b"") -> bytes:
+    """Rewrite the output line to add the timestamp and the process tag."""
+    timestamp, line = chip_output_extract_timestamp(line)
+    timestamp = datetime.datetime.fromtimestamp(timestamp).isoformat(sep=' ')
+    return f"[{timestamp}]".encode() + process_tag + (TAG_STDERR if is_stderr else TAG_STDOUT) + line
+
+
+def process_chip_app_output(line, is_stderr):
+    return process_chip_output(line, is_stderr, TAG_PROCESS_APP)
+
+
+def process_test_script_output(line, is_stderr):
+    return process_chip_output(line, is_stderr, TAG_PROCESS_TEST)
 
 
 @click.command()
@@ -77,7 +76,9 @@
 @click.option("--factoryreset-app-only", is_flag=True,
               help='Remove app config and repl configs (/tmp/chip* and /tmp/repl*) before running the tests, but not the controller config')
 @click.option("--app-args", type=str, default='',
-              help='The extra arguments passed to the device. Can use placholders like {SCRIPT_BASE_NAME}')
+              help='The extra arguments passed to the device. Can use placeholders like {SCRIPT_BASE_NAME}')
+@click.option("--app-ready-pattern", type=str, default=None,
+              help='Delay test script start until given regular expression pattern is found in the application output.')
 @click.option("--script", type=click.Path(exists=True), default=os.path.join(DEFAULT_CHIP_ROOT,
                                                                              'src',
                                                                              'controller',
@@ -87,14 +88,12 @@
                                                                              'mobile-device-test.py'), help='Test script to use.')
 @click.option("--script-args", type=str, default='',
               help='Script arguments, can use placeholders like {SCRIPT_BASE_NAME}.')
-@click.option("--script-start-delay", type=int, default=0,
-              help='Delay in seconds before starting the script.')
 @click.option("--script-gdb", is_flag=True,
               help='Run script through gdb')
 @click.option("--quiet", is_flag=True, help="Do not print output from passing tests. Use this flag in CI to keep github log sizes manageable.")
 @click.option("--load-from-env", default=None, help="YAML file that contains values for environment variables.")
 def main(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: str,
-         script: str, script_args: str, script_start_delay: int, script_gdb: bool, quiet: bool, load_from_env):
+         app_ready_pattern: str, script: str, script_args: str, script_gdb: bool, quiet: bool, load_from_env):
     if load_from_env:
         reader = MetadataReader(load_from_env)
         runs = reader.parse_script(script)
@@ -105,10 +104,10 @@
                 run="cmd-run",
                 app=app,
                 app_args=app_args,
+                app_ready_pattern=app_ready_pattern,
                 script_args=script_args,
-                script_start_delay=script_start_delay,
-                factoryreset=factoryreset,
-                factoryreset_app_only=factoryreset_app_only,
+                factory_reset=factoryreset,
+                factory_reset_app_only=factoryreset_app_only,
                 script_gdb=script_gdb,
                 quiet=quiet
             )
@@ -118,49 +117,38 @@
         raise Exception(
             "No valid runs were found. Make sure you add runs to your file, see https://github.com/project-chip/connectedhomeip/blob/master/docs/testing/python.md document for reference/example.")
 
+    coloredlogs.install(level='INFO')
+
     for run in runs:
-        print(f"Executing {run.py_script_path.split('/')[-1]} {run.run}")
-        main_impl(run.app, run.factoryreset, run.factoryreset_app_only, run.app_args,
-                  run.py_script_path, run.script_args, run.script_start_delay, run.script_gdb, run.quiet)
+        logging.info("Executing %s %s", run.py_script_path.split('/')[-1], run.run)
+        main_impl(run.app, run.factory_reset, run.factory_reset_app_only, run.app_args or "",
+                  run.app_ready_pattern, run.py_script_path, run.script_args or "", run.script_gdb, run.quiet)
 
 
-def main_impl(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: str,
-              script: str, script_args: str, script_start_delay: int, script_gdb: bool, quiet: bool):
+def main_impl(app: str, factory_reset: bool, factory_reset_app_only: bool, app_args: str,
+              app_ready_pattern: str, script: str, script_args: str, script_gdb: bool, quiet: bool):
 
     app_args = app_args.replace('{SCRIPT_BASE_NAME}', os.path.splitext(os.path.basename(script))[0])
     script_args = script_args.replace('{SCRIPT_BASE_NAME}', os.path.splitext(os.path.basename(script))[0])
 
-    if factoryreset or factoryreset_app_only:
+    if factory_reset or factory_reset_app_only:
         # Remove native app config
-        retcode = subprocess.call("rm -rf /tmp/chip* /tmp/repl*", shell=True)
-        if retcode != 0:
-            raise Exception("Failed to remove /tmp/chip* for factory reset.")
+        for path in glob.glob('/tmp/chip*') + glob.glob('/tmp/repl*'):
+            pathlib.Path(path).unlink(missing_ok=True)
 
         # Remove native app KVS if that was used
-        kvs_match = re.search(r"--KVS (?P<kvs_path>[^ ]+)", app_args)
-        if kvs_match:
-            kvs_path_to_remove = kvs_match.group("kvs_path")
-            retcode = subprocess.call("rm -f %s" % kvs_path_to_remove, shell=True)
-            print("Trying to remove KVS path %s" % kvs_path_to_remove)
-            if retcode != 0:
-                raise Exception("Failed to remove %s for factory reset." % kvs_path_to_remove)
+        if match := re.search(r"--KVS (?P<path>[^ ]+)", app_args):
+            logging.info("Removing KVS path: %s" % match.group("path"))
+            pathlib.Path(match.group("path")).unlink(missing_ok=True)
 
-    if factoryreset:
+    if factory_reset:
         # Remove Python test admin storage if provided
-        storage_match = re.search(r"--storage-path (?P<storage_path>[^ ]+)", script_args)
-        if storage_match:
-            storage_path_to_remove = storage_match.group("storage_path")
-            retcode = subprocess.call("rm -f %s" % storage_path_to_remove, shell=True)
-            print("Trying to remove storage path %s" % storage_path_to_remove)
-            if retcode != 0:
-                raise Exception("Failed to remove %s for factory reset." % storage_path_to_remove)
-
-    coloredlogs.install(level='INFO')
-
-    log_queue = queue.Queue()
-    log_cooking_threads = []
+        if match := re.search(r"--storage-path (?P<path>[^ ]+)", script_args):
+            logging.info("Removing storage path: %s" % match.group("path"))
+            pathlib.Path(match.group("path")).unlink(missing_ok=True)
 
     app_process = None
+    app_exit_code = 0
     app_pid = 0
 
     stream_output = sys.stdout.buffer
@@ -171,16 +159,15 @@
         if not os.path.exists(app):
             if app is None:
                 raise FileNotFoundError(f"{app} not found")
-        app_args = [app] + shlex.split(app_args)
-        logging.info(f"Execute: {app_args}")
-        app_process = subprocess.Popen(
-            app_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, bufsize=0)
-        app_process.stdin.close()
-        app_pid = app_process.pid
-        DumpProgramOutputToQueue(
-            log_cooking_threads, Fore.GREEN + "APP " + Style.RESET_ALL, app_process, stream_output, log_queue)
-
-    time.sleep(script_start_delay)
+        if app_ready_pattern:
+            app_ready_pattern = re.compile(app_ready_pattern.encode())
+        app_process = Subprocess(app, *shlex.split(app_args),
+                                 output_cb=process_chip_app_output,
+                                 f_stdout=stream_output,
+                                 f_stderr=stream_output)
+        app_process.start(expected_output=app_ready_pattern, timeout=30)
+        app_process.p.stdin.close()
+        app_pid = app_process.p.pid
 
     script_command = [script, "--paa-trust-store-path", os.path.join(DEFAULT_CHIP_ROOT, MATTER_DEVELOPMENT_PAA_ROOT_CERTS),
                       '--log-format', '%(message)s', "--app-pid", str(app_pid)] + shlex.split(script_args)
@@ -198,31 +185,24 @@
 
     final_script_command = [i.replace('|', ' ') for i in script_command]
 
-    logging.info(f"Execute: {final_script_command}")
-    test_script_process = subprocess.Popen(
-        final_script_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
-    test_script_process.stdin.close()
-    DumpProgramOutputToQueue(log_cooking_threads, Fore.GREEN + "TEST" + Style.RESET_ALL,
-                             test_script_process, stream_output, log_queue)
-
+    test_script_process = Subprocess(final_script_command[0], *final_script_command[1:],
+                                     output_cb=process_test_script_output,
+                                     f_stdout=stream_output,
+                                     f_stderr=stream_output)
+    test_script_process.start()
+    test_script_process.p.stdin.close()
     test_script_exit_code = test_script_process.wait()
 
     if test_script_exit_code != 0:
-        logging.error("Test script exited with error %r" % test_script_exit_code)
+        logging.error("Test script exited with returncode %d" % test_script_exit_code)
 
-    test_app_exit_code = 0
     if app_process:
-        logging.warning("Stopping app with SIGINT")
-        app_process.send_signal(signal.SIGINT.value)
-        test_app_exit_code = app_process.wait()
-
-    # There are some logs not cooked, so we wait until we have processed all logs.
-    # This procedure should be very fast since the related processes are finished.
-    for thread in log_cooking_threads:
-        thread.join()
+        logging.info("Stopping app with SIGTERM")
+        app_process.terminate()
+        app_exit_code = app_process.returncode
 
     # We expect both app and test script should exit with 0
-    exit_code = test_script_exit_code if test_script_exit_code != 0 else test_app_exit_code
+    exit_code = test_script_exit_code or app_exit_code
 
     if quiet:
         if exit_code:
diff --git a/src/controller/python/test/test_scripts/mobile-device-test.py b/src/controller/python/test/test_scripts/mobile-device-test.py
index 79ad383..5515fa1 100755
--- a/src/controller/python/test/test_scripts/mobile-device-test.py
+++ b/src/controller/python/test/test_scripts/mobile-device-test.py
@@ -23,12 +23,18 @@
 # for details about the block below.
 #
 # === BEGIN CI TEST ARGUMENTS ===
-# test-runner-runs: run1
-# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
-# test-runner-run/run1/factoryreset: True
-# test-runner-run/run1/quiet: True
-# test-runner-run/run1/app-args: --trace-to json:${TRACE_APP}.json
-# test-runner-run/run1/script-args: --log-level INFO -t 3600 --disable-test ClusterObjectTests.TestTimedRequestTimeout --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+# test-runner-runs:
+#   run1:
+#     app: ${ALL_CLUSTERS_APP}
+#     app-args: --trace-to json:${TRACE_APP}.json
+#     script-args: >
+#       --log-level INFO
+#       --timeout 3600
+#       --disable-test ClusterObjectTests.TestTimedRequestTimeout
+#       --trace-to json:${TRACE_TEST_JSON}.json
+#       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+#     factoryreset: true
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import asyncio
diff --git a/src/python_testing/TCP_Tests.py b/src/python_testing/TCP_Tests.py
index 6703964..41e5c2c 100644
--- a/src/python_testing/TCP_Tests.py
+++ b/src/python_testing/TCP_Tests.py
@@ -15,12 +15,19 @@
 #    limitations under the License.
 #
 # === BEGIN CI TEST ARGUMENTS ===
-# test-runner-runs: run1
-# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
-# test-runner-run/run1/factoryreset: True
-# test-runner-run/run1/quiet: True
-# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
-# test-runner-run/run1/script-args: --storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+# test-runner-runs:
+#   run1:
+#     app: ${ALL_CLUSTERS_APP}
+#     app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
+#     script-args: >
+#       --storage-path admin_storage.json
+#       --commissioning-method on-network
+#       --discriminator 1234
+#       --passcode 20202021
+#       --trace-to json:${TRACE_TEST_JSON}.json
+#       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+#     factoryreset: true
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 #
 import chip.clusters as Clusters
diff --git a/src/python_testing/TC_ECOINFO_2_1.py b/src/python_testing/TC_ECOINFO_2_1.py
index 52a3938..f3f22bb 100644
--- a/src/python_testing/TC_ECOINFO_2_1.py
+++ b/src/python_testing/TC_ECOINFO_2_1.py
@@ -23,6 +23,7 @@
 #   run1:
 #     app: examples/fabric-admin/scripts/fabric-sync-app.py
 #     app-args: --app-admin=${FABRIC_ADMIN_APP} --app-bridge=${FABRIC_BRIDGE_APP} --stdin-pipe=dut-fsa-stdin --discriminator=1234
+#     app-ready-pattern: "Successfully opened pairing window on the device"
 #     script-args: >
 #       --PICS src/app/tests/suites/certification/ci-pics-values
 #       --storage-path admin_storage.json
@@ -32,9 +33,8 @@
 #       --string-arg th_server_app_path:${ALL_CLUSTERS_APP} dut_fsa_stdin_pipe:dut-fsa-stdin
 #       --trace-to json:${TRACE_TEST_JSON}.json
 #       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
-#     script-start-delay: 5
 #     factoryreset: true
-#     quiet: false
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import asyncio
diff --git a/src/python_testing/TC_ECOINFO_2_2.py b/src/python_testing/TC_ECOINFO_2_2.py
index dd5f138..96fa2cd 100644
--- a/src/python_testing/TC_ECOINFO_2_2.py
+++ b/src/python_testing/TC_ECOINFO_2_2.py
@@ -23,6 +23,7 @@
 #   run1:
 #     app: examples/fabric-admin/scripts/fabric-sync-app.py
 #     app-args: --app-admin=${FABRIC_ADMIN_APP} --app-bridge=${FABRIC_BRIDGE_APP} --stdin-pipe=dut-fsa-stdin --discriminator=1234
+#     app-ready-pattern: "Successfully opened pairing window on the device"
 #     script-args: >
 #       --PICS src/app/tests/suites/certification/ci-pics-values
 #       --storage-path admin_storage.json
@@ -32,9 +33,8 @@
 #       --string-arg th_server_app_path:${ALL_CLUSTERS_APP} dut_fsa_stdin_pipe:dut-fsa-stdin
 #       --trace-to json:${TRACE_TEST_JSON}.json
 #       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
-#     script-start-delay: 5
 #     factoryreset: true
-#     quiet: false
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import asyncio
diff --git a/src/python_testing/TC_MCORE_FS_1_1.py b/src/python_testing/TC_MCORE_FS_1_1.py
index 0723c7a..3bdee81 100755
--- a/src/python_testing/TC_MCORE_FS_1_1.py
+++ b/src/python_testing/TC_MCORE_FS_1_1.py
@@ -25,6 +25,7 @@
 #   run1:
 #     app: examples/fabric-admin/scripts/fabric-sync-app.py
 #     app-args: --app-admin=${FABRIC_ADMIN_APP} --app-bridge=${FABRIC_BRIDGE_APP} --stdin-pipe=dut-fsa-stdin --discriminator=1234
+#     app-ready-pattern: "Successfully opened pairing window on the device"
 #     script-args: >
 #       --PICS src/app/tests/suites/certification/ci-pics-values
 #       --storage-path admin_storage.json
@@ -34,9 +35,8 @@
 #       --string-arg th_server_app_path:${ALL_CLUSTERS_APP}
 #       --trace-to json:${TRACE_TEST_JSON}.json
 #       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
-#     script-start-delay: 5
 #     factoryreset: true
-#     quiet: false
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import logging
diff --git a/src/python_testing/TC_MCORE_FS_1_2.py b/src/python_testing/TC_MCORE_FS_1_2.py
index d7bcbc0..e169841 100644
--- a/src/python_testing/TC_MCORE_FS_1_2.py
+++ b/src/python_testing/TC_MCORE_FS_1_2.py
@@ -23,6 +23,7 @@
 #   run1:
 #     app: examples/fabric-admin/scripts/fabric-sync-app.py
 #     app-args: --app-admin=${FABRIC_ADMIN_APP} --app-bridge=${FABRIC_BRIDGE_APP} --stdin-pipe=dut-fsa-stdin --discriminator=1234
+#     app-ready-pattern: "Successfully opened pairing window on the device"
 #     script-args: >
 #       --PICS src/app/tests/suites/certification/ci-pics-values
 #       --storage-path admin_storage.json
@@ -32,9 +33,8 @@
 #       --string-arg th_server_app_path:${ALL_CLUSTERS_APP} dut_fsa_stdin_pipe:dut-fsa-stdin
 #       --trace-to json:${TRACE_TEST_JSON}.json
 #       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
-#     script-start-delay: 5
 #     factoryreset: true
-#     quiet: false
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import asyncio
diff --git a/src/python_testing/TC_MCORE_FS_1_3.py b/src/python_testing/TC_MCORE_FS_1_3.py
index 005a33e..79988e5 100644
--- a/src/python_testing/TC_MCORE_FS_1_3.py
+++ b/src/python_testing/TC_MCORE_FS_1_3.py
@@ -27,6 +27,7 @@
 #   run1:
 #     app: examples/fabric-admin/scripts/fabric-sync-app.py
 #     app-args: --app-admin=${FABRIC_ADMIN_APP} --app-bridge=${FABRIC_BRIDGE_APP} --stdin-pipe=dut-fsa-stdin --discriminator=1234
+#     app-ready-pattern: "Successfully opened pairing window on the device"
 #     script-args: >
 #       --PICS src/app/tests/suites/certification/ci-pics-values
 #       --storage-path admin_storage.json
@@ -36,7 +37,6 @@
 #       --string-arg th_server_no_uid_app_path:${LIGHTING_APP_NO_UNIQUE_ID}
 #       --trace-to json:${TRACE_TEST_JSON}.json
 #       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
-#     script-start-delay: 5
 #     factoryreset: true
 #     quiet: true
 # === END CI TEST ARGUMENTS ===
diff --git a/src/python_testing/TC_MCORE_FS_1_4.py b/src/python_testing/TC_MCORE_FS_1_4.py
index c9244cf..c365b4e 100644
--- a/src/python_testing/TC_MCORE_FS_1_4.py
+++ b/src/python_testing/TC_MCORE_FS_1_4.py
@@ -27,6 +27,7 @@
 #   run1:
 #     app: examples/fabric-admin/scripts/fabric-sync-app.py
 #     app-args: --app-admin=${FABRIC_ADMIN_APP} --app-bridge=${FABRIC_BRIDGE_APP} --stdin-pipe=dut-fsa-stdin --discriminator=1234
+#     app-ready-pattern: "Successfully opened pairing window on the device"
 #     script-args: >
 #       --PICS src/app/tests/suites/certification/ci-pics-values
 #       --storage-path admin_storage.json
@@ -35,7 +36,6 @@
 #       --string-arg th_fsa_app_path:examples/fabric-admin/scripts/fabric-sync-app.py th_fsa_admin_path:${FABRIC_ADMIN_APP} th_fsa_bridge_path:${FABRIC_BRIDGE_APP} th_server_no_uid_app_path:${LIGHTING_APP_NO_UNIQUE_ID} dut_fsa_stdin_pipe:dut-fsa-stdin
 #       --trace-to json:${TRACE_TEST_JSON}.json
 #       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
-#     script-start-delay: 5
 #     factoryreset: true
 #     quiet: true
 # === END CI TEST ARGUMENTS ===
diff --git a/src/python_testing/TC_MCORE_FS_1_5.py b/src/python_testing/TC_MCORE_FS_1_5.py
index 0317316..d13d81a 100755
--- a/src/python_testing/TC_MCORE_FS_1_5.py
+++ b/src/python_testing/TC_MCORE_FS_1_5.py
@@ -23,6 +23,7 @@
 #   run1:
 #     app: examples/fabric-admin/scripts/fabric-sync-app.py
 #     app-args: --app-admin=${FABRIC_ADMIN_APP} --app-bridge=${FABRIC_BRIDGE_APP} --stdin-pipe=dut-fsa-stdin --discriminator=1234
+#     app-ready-pattern: "Successfully opened pairing window on the device"
 #     script-args: >
 #       --PICS src/app/tests/suites/certification/ci-pics-values
 #       --storage-path admin_storage.json
@@ -32,9 +33,8 @@
 #       --string-arg th_server_app_path:${ALL_CLUSTERS_APP} dut_fsa_stdin_pipe:dut-fsa-stdin
 #       --trace-to json:${TRACE_TEST_JSON}.json
 #       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
-#     script-start-delay: 5
 #     factoryreset: true
-#     quiet: false
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import asyncio
diff --git a/src/python_testing/TestBatchInvoke.py b/src/python_testing/TestBatchInvoke.py
index 230ebc9..cf10fbb 100644
--- a/src/python_testing/TestBatchInvoke.py
+++ b/src/python_testing/TestBatchInvoke.py
@@ -19,12 +19,19 @@
 # for details about the block below.
 #
 # === BEGIN CI TEST ARGUMENTS ===
-# test-runner-runs: run1
-# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
-# test-runner-run/run1/factoryreset: True
-# test-runner-run/run1/quiet: True
-# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
-# test-runner-run/run1/script-args: --storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+# test-runner-runs:
+#   run1:
+#     app: ${ALL_CLUSTERS_APP}
+#     app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
+#     script-args: >
+#       --storage-path admin_storage.json
+#       --commissioning-method on-network
+#       --discriminator 1234
+#       --passcode 20202021
+#       --trace-to json:${TRACE_TEST_JSON}.json
+#       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+#     factoryreset: true
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import logging
diff --git a/src/python_testing/TestGroupTableReports.py b/src/python_testing/TestGroupTableReports.py
index cc9b9a0..738b208 100644
--- a/src/python_testing/TestGroupTableReports.py
+++ b/src/python_testing/TestGroupTableReports.py
@@ -19,12 +19,19 @@
 # for details about the block below.
 #
 # === BEGIN CI TEST ARGUMENTS ===
-# test-runner-runs: run1
-# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
-# test-runner-run/run1/factoryreset: True
-# test-runner-run/run1/quiet: True
-# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
-# test-runner-run/run1/script-args: --storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+# test-runner-runs:
+#   run1:
+#     app: ${ALL_CLUSTERS_APP}
+#     app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
+#     script-args: >
+#       --storage-path admin_storage.json
+#       --commissioning-method on-network
+#       --discriminator 1234
+#       --passcode 20202021
+#       --trace-to json:${TRACE_TEST_JSON}.json
+#       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+#     factoryreset: true
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import logging
diff --git a/src/python_testing/TestUnitTestingErrorPath.py b/src/python_testing/TestUnitTestingErrorPath.py
index c26e6af..b49e8a6 100644
--- a/src/python_testing/TestUnitTestingErrorPath.py
+++ b/src/python_testing/TestUnitTestingErrorPath.py
@@ -19,12 +19,19 @@
 # for details about the block below.
 #
 # === BEGIN CI TEST ARGUMENTS ===
-# test-runner-runs: run1
-# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
-# test-runner-run/run1/factoryreset: True
-# test-runner-run/run1/quiet: True
-# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
-# test-runner-run/run1/script-args: --storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+# test-runner-runs:
+#   run1:
+#     app: ${ALL_CLUSTERS_APP}
+#     app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
+#     script-args: >
+#       --storage-path admin_storage.json
+#       --commissioning-method on-network
+#       --discriminator 1234
+#       --passcode 20202021
+#       --trace-to json:${TRACE_TEST_JSON}.json
+#       --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+#     factoryreset: true
+#     quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import logging
diff --git a/src/python_testing/hello_test.py b/src/python_testing/hello_test.py
index 041f28e..ef6bce8 100644
--- a/src/python_testing/hello_test.py
+++ b/src/python_testing/hello_test.py
@@ -19,12 +19,19 @@
 # for details about the block below.
 #
 # === BEGIN CI TEST ARGUMENTS ===
-# test-runner-runs: run1
-# test-runner-run/run1/app: ${TYPE_OF_APP}
-# test-runner-run/run1/factoryreset: True
-# test-runner-run/run1/quiet: True
-# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
-# test-runner-run/run1/script-args: --storage-path admin_storage.json --commissioning-method on-network --discriminator 1234 --passcode 20202021 --trace-to json:${TRACE_TEST_JSON}.json --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+# test-runner-runs:
+#    run1:
+#      app: ${TYPE_OF_APP}
+#      app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
+#      script-args: >
+#        --storage-path admin_storage.json
+#        --commissioning-method on-network
+#        --discriminator 1234
+#        --passcode 20202021
+#        --trace-to json:${TRACE_TEST_JSON}.json
+#        --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
+#      factoryreset: true
+#      quiet: true
 # === END CI TEST ARGUMENTS ===
 
 import logging
diff --git a/src/python_testing/matter_testing_infrastructure/chip/testing/metadata.py b/src/python_testing/matter_testing_infrastructure/chip/testing/metadata.py
index 803160e..2a912d0 100644
--- a/src/python_testing/matter_testing_infrastructure/chip/testing/metadata.py
+++ b/src/python_testing/matter_testing_infrastructure/chip/testing/metadata.py
@@ -16,7 +16,7 @@
 import re
 from dataclasses import dataclass
 from io import StringIO
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
 
 import yaml
 
@@ -33,55 +33,15 @@
 class Metadata:
     py_script_path: str
     run: str
-    app: str
-    app_args: str
-    script_args: str
-    script_start_delay: int = 0
-    factoryreset: bool = False
-    factoryreset_app_only: bool = False
+    app: str = ""
+    app_args: Optional[str] = None
+    app_ready_pattern: Optional[str] = None
+    script_args: Optional[str] = None
+    factory_reset: bool = False
+    factory_reset_app_only: bool = False
     script_gdb: bool = False
     quiet: bool = True
 
-    def copy_from_dict(self, attr_dict: Dict[str, Any]) -> None:
-        """
-        Sets the value of the attributes from a dictionary.
-
-        Attributes:
-
-        attr_dict:
-         Dictionary that stores attributes value that should
-         be transferred to this class.
-        """
-        if "app" in attr_dict:
-            self.app = attr_dict["app"]
-
-        if "run" in attr_dict:
-            self.run = attr_dict["run"]
-
-        if "app-args" in attr_dict:
-            self.app_args = attr_dict["app-args"]
-
-        if "script-args" in attr_dict:
-            self.script_args = attr_dict["script-args"]
-
-        if "script-start-delay" in attr_dict:
-            self.script_start_delay = int(attr_dict["script-start-delay"])
-
-        if "py_script_path" in attr_dict:
-            self.py_script_path = attr_dict["py_script_path"]
-
-        if "factoryreset" in attr_dict:
-            self.factoryreset = cast_to_bool(attr_dict["factoryreset"])
-
-        if "factoryreset_app_only" in attr_dict:
-            self.factoryreset_app_only = cast_to_bool(attr_dict["factoryreset_app_only"])
-
-        if "script_gdb" in attr_dict:
-            self.script_gdb = cast_to_bool(attr_dict["script_gdb"])
-
-        if "quiet" in attr_dict:
-            self.quiet = cast_to_bool(attr_dict["quiet"])
-
 
 class NamedStringIO(StringIO):
     def __init__(self, content, name):
@@ -89,7 +49,7 @@
         self.name = name
 
 
-def extract_runs_arg_lines(py_script_path: str) -> Dict[str, Dict[str, str]]:
+def extract_runs_args(py_script_path: str) -> Dict[str, Dict[str, str]]:
     """Extract the run arguments from the CI test arguments blocks."""
 
     found_ci_args_section = False
@@ -135,7 +95,6 @@
                 for run in runs_match.group("run_id").strip().split():
                     runs_arg_lines[run] = {}
                     runs_arg_lines[run]['run'] = run
-                    runs_arg_lines[run]['py_script_path'] = py_script_path
 
             elif args_match:
                 runs_arg_lines[args_match.group("run_id")][args_match.group("arg_name")] = args_match.group("arg_val")
@@ -146,7 +105,6 @@
             for run, args in runs.get("test-runner-runs", {}).items():
                 runs_arg_lines[run] = {}
                 runs_arg_lines[run]['run'] = run
-                runs_arg_lines[run]['py_script_path'] = py_script_path
                 runs_arg_lines[run].update(args)
         except yaml.YAMLError as e:
             logging.error(f"Failed to parse CI arguments YAML: {e}")
@@ -213,24 +171,19 @@
          the script file.
         """
         runs_metadata: List[Metadata] = []
-        runs_arg_lines = extract_runs_arg_lines(py_script_path)
+        runs_args = extract_runs_args(py_script_path)
 
-        for run, attr in runs_arg_lines.items():
+        for run, attr in runs_args.items():
             self.__resolve_env_vals__(attr)
-
-            metadata = Metadata(
-                py_script_path=attr.get("py_script_path", ""),
+            runs_metadata.append(Metadata(
+                py_script_path=py_script_path,
                 run=run,
                 app=attr.get("app", ""),
-                app_args=attr.get("app_args", ""),
-                script_args=attr.get("script_args", ""),
-                script_start_delay=int(attr.get("script_start_delay", 0)),
-                factoryreset=bool(attr.get("factoryreset", False)),
-                factoryreset_app_only=bool(attr.get("factoryreset_app_only", False)),
-                script_gdb=bool(attr.get("script_gdb", False)),
-                quiet=bool(attr.get("quiet", True))
-            )
-            metadata.copy_from_dict(attr)
-            runs_metadata.append(metadata)
+                app_args=attr.get("app-args"),
+                app_ready_pattern=attr.get("app-ready-pattern"),
+                script_args=attr.get("script-args"),
+                factory_reset=cast_to_bool(attr.get("factoryreset", False)),
+                quiet=cast_to_bool(attr.get("quiet", True))
+            ))
 
         return runs_metadata
diff --git a/src/python_testing/matter_testing_infrastructure/chip/testing/test_metadata.py b/src/python_testing/matter_testing_infrastructure/chip/testing/test_metadata.py
index ce41969..24ed6c6 100644
--- a/src/python_testing/matter_testing_infrastructure/chip/testing/test_metadata.py
+++ b/src/python_testing/matter_testing_infrastructure/chip/testing/test_metadata.py
@@ -64,7 +64,7 @@
         app_args="--discriminator 1234 --trace-to json:out/trace_data/app-{SCRIPT_BASE_NAME}.json",
         run="run1",
         app="out/linux-x64-all-clusters-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-all-clusters-app",
-        factoryreset=True,
+        factory_reset=True,
         quiet=True
     )