15
15
# limitations under the License.
16
16
17
17
import datetime
18
+ import glob
18
19
import io
19
20
import logging
20
21
import os
21
22
import os .path
22
- import queue
23
+ import pathlib
23
24
import re
24
25
import shlex
25
- import signal
26
- import subprocess
27
26
import sys
28
- import threading
29
27
import time
30
- import typing
31
28
32
29
import click
33
30
import coloredlogs
34
31
from chip .testing .metadata import Metadata , MetadataReader
32
+ from chip .testing .tasks import Subprocess
35
33
from colorama import Fore , Style
36
34
37
35
DEFAULT_CHIP_ROOT = os .path .abspath (
38
36
os .path .join (os .path .dirname (__file__ ), '..' , '..' ))
39
37
40
38
MATTER_DEVELOPMENT_PAA_ROOT_CERTS = "credentials/development/paa-root-certs"
41
39
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 ()
42
44
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 ())
56
47
57
48
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
63
54
64
55
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 )
70
69
71
70
72
71
@click .command ()
@@ -77,7 +76,9 @@ def DumpProgramOutputToQueue(thread_list: typing.List[threading.Thread], tag: st
77
76
@click .option ("--factoryreset-app-only" , is_flag = True ,
78
77
help = 'Remove app config and repl configs (/tmp/chip* and /tmp/repl*) before running the tests, but not the controller config' )
79
78
@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.' )
81
82
@click .option ("--script" , type = click .Path (exists = True ), default = os .path .join (DEFAULT_CHIP_ROOT ,
82
83
'src' ,
83
84
'controller' ,
@@ -87,14 +88,12 @@ def DumpProgramOutputToQueue(thread_list: typing.List[threading.Thread], tag: st
87
88
'mobile-device-test.py' ), help = 'Test script to use.' )
88
89
@click .option ("--script-args" , type = str , default = '' ,
89
90
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.' )
92
91
@click .option ("--script-gdb" , is_flag = True ,
93
92
help = 'Run script through gdb' )
94
93
@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." )
95
94
@click .option ("--load-from-env" , default = None , help = "YAML file that contains values for environment variables." )
96
95
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 ):
98
97
if load_from_env :
99
98
reader = MetadataReader (load_from_env )
100
99
runs = reader .parse_script (script )
@@ -105,10 +104,10 @@ def main(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: st
105
104
run = "cmd-run" ,
106
105
app = app ,
107
106
app_args = app_args ,
107
+ app_ready_pattern = app_ready_pattern ,
108
108
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 ,
112
111
script_gdb = script_gdb ,
113
112
quiet = quiet
114
113
)
@@ -118,49 +117,38 @@ def main(app: str, factoryreset: bool, factoryreset_app_only: bool, app_args: st
118
117
raise Exception (
119
118
"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." )
120
119
120
+ coloredlogs .install (level = 'INFO' )
121
+
121
122
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 )
125
126
126
127
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 ):
129
130
130
131
app_args = app_args .replace ('{SCRIPT_BASE_NAME}' , os .path .splitext (os .path .basename (script ))[0 ])
131
132
script_args = script_args .replace ('{SCRIPT_BASE_NAME}' , os .path .splitext (os .path .basename (script ))[0 ])
132
133
133
- if factoryreset or factoryreset_app_only :
134
+ if factory_reset or factory_reset_app_only :
134
135
# 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 )
138
138
139
139
# 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 )
157
143
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 )
162
149
163
150
app_process = None
151
+ app_exit_code = 0
164
152
app_pid = 0
165
153
166
154
stream_output = sys .stdout .buffer
@@ -171,16 +159,15 @@ def main_impl(app: str, factoryreset: bool, factoryreset_app_only: bool, app_arg
171
159
if not os .path .exists (app ):
172
160
if app is None :
173
161
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
184
171
185
172
script_command = [script , "--paa-trust-store-path" , os .path .join (DEFAULT_CHIP_ROOT , MATTER_DEVELOPMENT_PAA_ROOT_CERTS ),
186
173
'--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
198
185
199
186
final_script_command = [i .replace ('|' , ' ' ) for i in script_command ]
200
187
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 ()
208
194
test_script_exit_code = test_script_process .wait ()
209
195
210
196
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 )
212
198
213
- test_app_exit_code = 0
214
199
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
223
203
224
204
# 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
226
206
227
207
if quiet :
228
208
if exit_code :
0 commit comments