Skip to content

Commit e5bf62f

Browse files
Add --app-ready-pattern option to python test arguments (project-chip#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>
1 parent 0564a26 commit e5bf62f

17 files changed

+203
-223
lines changed

docs/testing/python.md

+22-16
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,19 @@ Python tests located in src/python_testing
5050
# for details about the block below.
5151
#
5252
# === BEGIN CI TEST ARGUMENTS ===
53-
# test-runner-runs: run1
54-
# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
55-
# test-runner-run/run1/factoryreset: True
56-
# test-runner-run/run1/quiet: True
57-
# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
58-
# 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
53+
# test-runner-runs:
54+
# run1:
55+
# app: ${ALL_CLUSTERS_APP}
56+
# app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
57+
# script-args: >
58+
# --storage-path admin_storage.json
59+
# --commissioning-method on-network
60+
# --discriminator 1234
61+
# --passcode 20202021
62+
# --trace-to json:${TRACE_TEST_JSON}.json
63+
# --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
64+
# factoryreset: true
65+
# quiet: true
5966
# === END CI TEST ARGUMENTS ===
6067
6168
class TC_MYTEST_1_1(MatterBaseTest):
@@ -669,10 +676,10 @@ for that run, e.g.:
669676
# test-runner-runs:
670677
# run1:
671678
# app: ${TYPE_OF_APP}
672-
# factoryreset: <true|false>
673-
# quiet: <true|false>
674679
# app-args: <app_arguments>
675680
# script-args: <script_arguments>
681+
# factoryreset: <true|false>
682+
# quiet: <true|false>
676683
# === END CI TEST ARGUMENTS ===
677684
```
678685

@@ -701,19 +708,18 @@ for that run, e.g.:
701708
- Example:
702709
`--discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json`
703710

711+
- `app-ready-pattern`: Regular expression pattern to match against the output
712+
of the application to determine when the application is ready. If this
713+
parameter is specified, the test runner will not run the test script until
714+
the pattern is found.
715+
716+
- Example: `"Manual pairing code: \\[\\d+\\]"`
717+
704718
- `script-args`: Specifies the arguments to be passed to the test script.
705719

706720
- Example:
707721
`--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`
708722

709-
- `script-start-delay`: Specifies the number of seconds to wait before
710-
starting the test script. This parameter can be used to allow the
711-
application to initialize itself properly before the test script will try to
712-
commission it (e.g. in case if the application needs to be commissioned to
713-
some other controller first). By default, the delay is 0 seconds.
714-
715-
- Example: `10`
716-
717723
This structured format ensures that all necessary configurations are clearly
718724
defined and easily understood, allowing for consistent and reliable test
719725
execution.

scripts/tests/run_python_test.py

+73-93
Original file line numberDiff line numberDiff line change
@@ -15,58 +15,57 @@
1515
# limitations under the License.
1616

1717
import datetime
18+
import glob
1819
import io
1920
import logging
2021
import os
2122
import os.path
22-
import queue
23+
import pathlib
2324
import re
2425
import shlex
25-
import signal
26-
import subprocess
2726
import sys
28-
import threading
2927
import time
30-
import typing
3128

3229
import click
3330
import coloredlogs
3431
from chip.testing.metadata import Metadata, MetadataReader
32+
from chip.testing.tasks import Subprocess
3533
from colorama import Fore, Style
3634

3735
DEFAULT_CHIP_ROOT = os.path.abspath(
3836
os.path.join(os.path.dirname(__file__), '..', '..'))
3937

4038
MATTER_DEVELOPMENT_PAA_ROOT_CERTS = "credentials/development/paa-root-certs"
4139

40+
TAG_PROCESS_APP = f"[{Fore.GREEN}APP {Style.RESET_ALL}]".encode()
41+
TAG_PROCESS_TEST = f"[{Fore.GREEN}TEST{Style.RESET_ALL}]".encode()
42+
TAG_STDOUT = f"[{Fore.YELLOW}STDOUT{Style.RESET_ALL}]".encode()
43+
TAG_STDERR = f"[{Fore.RED}STDERR{Style.RESET_ALL}]".encode()
4244

43-
def EnqueueLogOutput(fp, tag, output_stream, q):
44-
for line in iter(fp.readline, b''):
45-
timestamp = time.time()
46-
if len(line) > len('[1646290606.901990]') and line[0:1] == b'[':
47-
try:
48-
timestamp = float(line[1:18].decode())
49-
line = line[19:]
50-
except Exception:
51-
pass
52-
output_stream.write(
53-
(f"[{datetime.datetime.fromtimestamp(timestamp).isoformat(sep=' ')}]").encode() + tag + line)
54-
sys.stdout.flush()
55-
fp.close()
45+
# RegExp which matches the timestamp in the output of CHIP application
46+
OUTPUT_TIMESTAMP_MATCH = re.compile(r'(?P<prefix>.*)\[(?P<ts>\d+\.\d+)\](?P<suffix>\[\d+:\d+\].*)'.encode())
5647

5748

58-
def RedirectQueueThread(fp, tag, stream_output, queue) -> threading.Thread:
59-
log_queue_thread = threading.Thread(target=EnqueueLogOutput, args=(
60-
fp, tag, stream_output, queue))
61-
log_queue_thread.start()
62-
return log_queue_thread
49+
def chip_output_extract_timestamp(line: bytes) -> (float, bytes):
50+
"""Try to extract timestamp from a CHIP application output line."""
51+
if match := OUTPUT_TIMESTAMP_MATCH.match(line):
52+
return float(match.group(2)), match.group(1) + match.group(3) + b'\n'
53+
return time.time(), line
6354

6455

65-
def DumpProgramOutputToQueue(thread_list: typing.List[threading.Thread], tag: str, process: subprocess.Popen, stream_output, queue: queue.Queue):
66-
thread_list.append(RedirectQueueThread(process.stdout,
67-
(f"[{tag}][{Fore.YELLOW}STDOUT{Style.RESET_ALL}]").encode(), stream_output, queue))
68-
thread_list.append(RedirectQueueThread(process.stderr,
69-
(f"[{tag}][{Fore.RED}STDERR{Style.RESET_ALL}]").encode(), stream_output, queue))
56+
def process_chip_output(line: bytes, is_stderr: bool, process_tag: bytes = b"") -> bytes:
57+
"""Rewrite the output line to add the timestamp and the process tag."""
58+
timestamp, line = chip_output_extract_timestamp(line)
59+
timestamp = datetime.datetime.fromtimestamp(timestamp).isoformat(sep=' ')
60+
return f"[{timestamp}]".encode() + process_tag + (TAG_STDERR if is_stderr else TAG_STDOUT) + line
61+
62+
63+
def process_chip_app_output(line, is_stderr):
64+
return process_chip_output(line, is_stderr, TAG_PROCESS_APP)
65+
66+
67+
def process_test_script_output(line, is_stderr):
68+
return process_chip_output(line, is_stderr, TAG_PROCESS_TEST)
7069

7170

7271
@click.command()
@@ -77,7 +76,9 @@ def DumpProgramOutputToQueue(thread_list: typing.List[threading.Thread], tag: st
7776
@click.option("--factoryreset-app-only", is_flag=True,
7877
help='Remove app config and repl configs (/tmp/chip* and /tmp/repl*) before running the tests, but not the controller config')
7978
@click.option("--app-args", type=str, default='',
80-
help='The extra arguments passed to the device. Can use placholders like {SCRIPT_BASE_NAME}')
79+
help='The extra arguments passed to the device. Can use placeholders like {SCRIPT_BASE_NAME}')
80+
@click.option("--app-ready-pattern", type=str, default=None,
81+
help='Delay test script start until given regular expression pattern is found in the application output.')
8182
@click.option("--script", type=click.Path(exists=True), default=os.path.join(DEFAULT_CHIP_ROOT,
8283
'src',
8384
'controller',
@@ -87,14 +88,12 @@ def DumpProgramOutputToQueue(thread_list: typing.List[threading.Thread], tag: st
8788
'mobile-device-test.py'), help='Test script to use.')
8889
@click.option("--script-args", type=str, default='',
8990
help='Script arguments, can use placeholders like {SCRIPT_BASE_NAME}.')
90-
@click.option("--script-start-delay", type=int, default=0,
91-
help='Delay in seconds before starting the script.')
9291
@click.option("--script-gdb", is_flag=True,
9392
help='Run script through gdb')
9493
@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.")
9594
@click.option("--load-from-env", default=None, help="YAML file that contains values for environment variables.")
9695
def main(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: str,
97-
script: str, script_args: str, script_start_delay: int, script_gdb: bool, quiet: bool, load_from_env):
96+
app_ready_pattern: str, script: str, script_args: str, script_gdb: bool, quiet: bool, load_from_env):
9897
if load_from_env:
9998
reader = MetadataReader(load_from_env)
10099
runs = reader.parse_script(script)
@@ -105,10 +104,10 @@ def main(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: st
105104
run="cmd-run",
106105
app=app,
107106
app_args=app_args,
107+
app_ready_pattern=app_ready_pattern,
108108
script_args=script_args,
109-
script_start_delay=script_start_delay,
110-
factoryreset=factoryreset,
111-
factoryreset_app_only=factoryreset_app_only,
109+
factory_reset=factoryreset,
110+
factory_reset_app_only=factoryreset_app_only,
112111
script_gdb=script_gdb,
113112
quiet=quiet
114113
)
@@ -118,49 +117,38 @@ def main(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: st
118117
raise Exception(
119118
"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.")
120119

120+
coloredlogs.install(level='INFO')
121+
121122
for run in runs:
122-
print(f"Executing {run.py_script_path.split('/')[-1]} {run.run}")
123-
main_impl(run.app, run.factoryreset, run.factoryreset_app_only, run.app_args,
124-
run.py_script_path, run.script_args, run.script_start_delay, run.script_gdb, run.quiet)
123+
logging.info("Executing %s %s", run.py_script_path.split('/')[-1], run.run)
124+
main_impl(run.app, run.factory_reset, run.factory_reset_app_only, run.app_args or "",
125+
run.app_ready_pattern, run.py_script_path, run.script_args or "", run.script_gdb, run.quiet)
125126

126127

127-
def main_impl(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: str,
128-
script: str, script_args: str, script_start_delay: int, script_gdb: bool, quiet: bool):
128+
def main_impl(app: str, factory_reset: bool, factory_reset_app_only: bool, app_args: str,
129+
app_ready_pattern: str, script: str, script_args: str, script_gdb: bool, quiet: bool):
129130

130131
app_args = app_args.replace('{SCRIPT_BASE_NAME}', os.path.splitext(os.path.basename(script))[0])
131132
script_args = script_args.replace('{SCRIPT_BASE_NAME}', os.path.splitext(os.path.basename(script))[0])
132133

133-
if factoryreset or factoryreset_app_only:
134+
if factory_reset or factory_reset_app_only:
134135
# Remove native app config
135-
retcode = subprocess.call("rm -rf /tmp/chip* /tmp/repl*", shell=True)
136-
if retcode != 0:
137-
raise Exception("Failed to remove /tmp/chip* for factory reset.")
136+
for path in glob.glob('/tmp/chip*') + glob.glob('/tmp/repl*'):
137+
pathlib.Path(path).unlink(missing_ok=True)
138138

139139
# Remove native app KVS if that was used
140-
kvs_match = re.search(r"--KVS (?P<kvs_path>[^ ]+)", app_args)
141-
if kvs_match:
142-
kvs_path_to_remove = kvs_match.group("kvs_path")
143-
retcode = subprocess.call("rm -f %s" % kvs_path_to_remove, shell=True)
144-
print("Trying to remove KVS path %s" % kvs_path_to_remove)
145-
if retcode != 0:
146-
raise Exception("Failed to remove %s for factory reset." % kvs_path_to_remove)
147-
148-
if factoryreset:
149-
# Remove Python test admin storage if provided
150-
storage_match = re.search(r"--storage-path (?P<storage_path>[^ ]+)", script_args)
151-
if storage_match:
152-
storage_path_to_remove = storage_match.group("storage_path")
153-
retcode = subprocess.call("rm -f %s" % storage_path_to_remove, shell=True)
154-
print("Trying to remove storage path %s" % storage_path_to_remove)
155-
if retcode != 0:
156-
raise Exception("Failed to remove %s for factory reset." % storage_path_to_remove)
140+
if match := re.search(r"--KVS (?P<path>[^ ]+)", app_args):
141+
logging.info("Removing KVS path: %s" % match.group("path"))
142+
pathlib.Path(match.group("path")).unlink(missing_ok=True)
157143

158-
coloredlogs.install(level='INFO')
159-
160-
log_queue = queue.Queue()
161-
log_cooking_threads = []
144+
if factory_reset:
145+
# Remove Python test admin storage if provided
146+
if match := re.search(r"--storage-path (?P<path>[^ ]+)", script_args):
147+
logging.info("Removing storage path: %s" % match.group("path"))
148+
pathlib.Path(match.group("path")).unlink(missing_ok=True)
162149

163150
app_process = None
151+
app_exit_code = 0
164152
app_pid = 0
165153

166154
stream_output = sys.stdout.buffer
@@ -171,16 +159,15 @@ def main_impl(app: str, factoryreset: bool, factoryreset_app_only: bool, app_arg
171159
if not os.path.exists(app):
172160
if app is None:
173161
raise FileNotFoundError(f"{app} not found")
174-
app_args = [app] + shlex.split(app_args)
175-
logging.info(f"Execute: {app_args}")
176-
app_process = subprocess.Popen(
177-
app_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, bufsize=0)
178-
app_process.stdin.close()
179-
app_pid = app_process.pid
180-
DumpProgramOutputToQueue(
181-
log_cooking_threads, Fore.GREEN + "APP " + Style.RESET_ALL, app_process, stream_output, log_queue)
182-
183-
time.sleep(script_start_delay)
162+
if app_ready_pattern:
163+
app_ready_pattern = re.compile(app_ready_pattern.encode())
164+
app_process = Subprocess(app, *shlex.split(app_args),
165+
output_cb=process_chip_app_output,
166+
f_stdout=stream_output,
167+
f_stderr=stream_output)
168+
app_process.start(expected_output=app_ready_pattern, timeout=30)
169+
app_process.p.stdin.close()
170+
app_pid = app_process.p.pid
184171

185172
script_command = [script, "--paa-trust-store-path", os.path.join(DEFAULT_CHIP_ROOT, MATTER_DEVELOPMENT_PAA_ROOT_CERTS),
186173
'--log-format', '%(message)s', "--app-pid", str(app_pid)] + shlex.split(script_args)
@@ -198,31 +185,24 @@ def main_impl(app: str, factoryreset: bool, factoryreset_app_only: bool, app_arg
198185

199186
final_script_command = [i.replace('|', ' ') for i in script_command]
200187

201-
logging.info(f"Execute: {final_script_command}")
202-
test_script_process = subprocess.Popen(
203-
final_script_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
204-
test_script_process.stdin.close()
205-
DumpProgramOutputToQueue(log_cooking_threads, Fore.GREEN + "TEST" + Style.RESET_ALL,
206-
test_script_process, stream_output, log_queue)
207-
188+
test_script_process = Subprocess(final_script_command[0], *final_script_command[1:],
189+
output_cb=process_test_script_output,
190+
f_stdout=stream_output,
191+
f_stderr=stream_output)
192+
test_script_process.start()
193+
test_script_process.p.stdin.close()
208194
test_script_exit_code = test_script_process.wait()
209195

210196
if test_script_exit_code != 0:
211-
logging.error("Test script exited with error %r" % test_script_exit_code)
197+
logging.error("Test script exited with returncode %d" % test_script_exit_code)
212198

213-
test_app_exit_code = 0
214199
if app_process:
215-
logging.warning("Stopping app with SIGINT")
216-
app_process.send_signal(signal.SIGINT.value)
217-
test_app_exit_code = app_process.wait()
218-
219-
# There are some logs not cooked, so we wait until we have processed all logs.
220-
# This procedure should be very fast since the related processes are finished.
221-
for thread in log_cooking_threads:
222-
thread.join()
200+
logging.info("Stopping app with SIGTERM")
201+
app_process.terminate()
202+
app_exit_code = app_process.returncode
223203

224204
# We expect both app and test script should exit with 0
225-
exit_code = test_script_exit_code if test_script_exit_code != 0 else test_app_exit_code
205+
exit_code = test_script_exit_code or app_exit_code
226206

227207
if quiet:
228208
if exit_code:

src/controller/python/test/test_scripts/mobile-device-test.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,18 @@
2323
# for details about the block below.
2424
#
2525
# === BEGIN CI TEST ARGUMENTS ===
26-
# test-runner-runs: run1
27-
# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
28-
# test-runner-run/run1/factoryreset: True
29-
# test-runner-run/run1/quiet: True
30-
# test-runner-run/run1/app-args: --trace-to json:${TRACE_APP}.json
31-
# 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
26+
# test-runner-runs:
27+
# run1:
28+
# app: ${ALL_CLUSTERS_APP}
29+
# app-args: --trace-to json:${TRACE_APP}.json
30+
# script-args: >
31+
# --log-level INFO
32+
# --timeout 3600
33+
# --disable-test ClusterObjectTests.TestTimedRequestTimeout
34+
# --trace-to json:${TRACE_TEST_JSON}.json
35+
# --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
36+
# factoryreset: true
37+
# quiet: true
3238
# === END CI TEST ARGUMENTS ===
3339

3440
import asyncio

src/python_testing/TCP_Tests.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,19 @@
1515
# limitations under the License.
1616
#
1717
# === BEGIN CI TEST ARGUMENTS ===
18-
# test-runner-runs: run1
19-
# test-runner-run/run1/app: ${ALL_CLUSTERS_APP}
20-
# test-runner-run/run1/factoryreset: True
21-
# test-runner-run/run1/quiet: True
22-
# test-runner-run/run1/app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
23-
# 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
18+
# test-runner-runs:
19+
# run1:
20+
# app: ${ALL_CLUSTERS_APP}
21+
# app-args: --discriminator 1234 --KVS kvs1 --trace-to json:${TRACE_APP}.json
22+
# script-args: >
23+
# --storage-path admin_storage.json
24+
# --commissioning-method on-network
25+
# --discriminator 1234
26+
# --passcode 20202021
27+
# --trace-to json:${TRACE_TEST_JSON}.json
28+
# --trace-to perfetto:${TRACE_TEST_PERFETTO}.perfetto
29+
# factoryreset: true
30+
# quiet: true
2431
# === END CI TEST ARGUMENTS ===
2532
#
2633
import chip.clusters as Clusters

0 commit comments

Comments
 (0)