-
Notifications
You must be signed in to change notification settings - Fork 127
/
run_tests.py
executable file
·345 lines (263 loc) · 11.7 KB
/
run_tests.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
#!/usr/bin/env python3
'''
Runs the Sketch JavaScript API integration tests plugin against a given Sketch application bundle
or the default installation /Applications/Sketch.app if not specified. The application instance
is terminated after a running this script, but only if the Python module `psutil` is available.
The script expects a path to the plugin containing the JavaScript bundle with all unit tests. This
is a build product of the SketchAPI project and created by calling `npm run test:build` command
from the project root, see the README for more detail.
Sketch instances share state in ~/Library/Application/Support/com.bohemiancoding.sketch3, which
means plugins must be named uniquely to avoid issues with concurrent test runs. Subsequently, the
integration test plugin is named SketchIntegrationTests-IDENTIFIER.sketchplugin with `IDENTIFIER`
being a user provided value at build time of the plugin, which could be a Jenkins job number or
GitHub issue number.
This script uses the macOS `open` command to launch a Sketch instance with the `sketch://`
application URL scheme:
sketch://plugin/PLUGIN_IDENTIFIER/COMMAND_IDENTIFIER
Both PLUGIN_IDENTIFIER and COMMAND_IDENTIFIER are inferred from the plugin bundle.
During a test plugin run, the test results are continuously written to the specified test output
file and the extended Finder file attribute `com.apple.progress.fractionCompleted` gets updated
accordingly, while this script monitors this file attribute for changes. On completion, this
attribute reaches a value of 1 at which point the file contents are parsed and logged to the
console.
If the parsed results include a failed test, this script returns with an exit code of 1.
Should this completion not occur, due to a hanging test or testing taking too long, this script
will time out and also return with an exit code of 1.
If all tests have passed successfully, this script exits with code zero.
Usage:
run_tests.py -s SKETCH_PATH -p PLUGIN -o OUTPUT_FILE_PATH
Options:
-s SKETCH_PATH The path to the Sketch.app bundle
-p PLUGIN The path to the plugin that was the result of running `npm run test:build --identifier=X
Will live somewhere like Modules/SketchAPI/build/SketchIntegrationTests-X.sketchplugin
-o OUTPUT Writes the log to here
-t TIMEOUT The duration in seconds for a test to complete, default at 30 seconds. If one test exceeds this time, the entire test run will be aborted.
'''
from pathlib import Path, PurePath
import subprocess
import sys
import getopt
import os
import json
import time
import re
terminate_sketch_on_completion = False
try:
import psutil
terminate_sketch_on_completion = True
except ImportError:
print('Sketch will remain open after running the tests and must be terminated manually.', file=sys.stderr)
def group_results_by_parent(results):
grouped_results = {}
for result in results:
parent_title = result['ancestorTitles'][0] # test suite name
grouped_results[parent_title] = grouped_results.get(parent_title, {
'relativePath': result['relativePath'],
'results': [],
})
# drop the suite name from the ancestors
result['ancestorTitles'] = result['ancestorTitles'][1:]
grouped_results[parent_title]['results'].append(result)
return grouped_results
def has_failed_tests(results):
for result in results:
if result['status'] == "failed":
return True
return False
# Read 'com.apple.progress.fractionCompleted' extended file attribute from
# ouput file to display the test runner progress.
def watch_test_runner_progress(file_path, timeout=30):
progress = 0
last_progress_time = time.time()
print("Waiting for file to be created…")
# Wait for output file to be available on disk. This file is written
# at the beginning of the integration tests plugin run.
while not os.path.exists(file_path):
time.sleep(2)
if (time.time() - last_progress_time > timeout):
raise TimeoutError
if not os.path.isfile(file_path):
raise Exception("Path to test output is not a file")
print("Waiting for tests to complete…")
while progress < 1:
stream = subprocess.Popen(
['xattr', '-l', file_path],
stdout=subprocess.PIPE)
output = str(stream.communicate())
output = output.split("com.apple.progress.fractionCompleted:")[1]
match = re.search('\d+(.\d+)*', output)
latest_progress = float(match.group(0))
if latest_progress == progress:
# no progress since last run
if (time.time() - last_progress_time > timeout):
raise TimeoutError
time.sleep(1)
continue
progress = latest_progress
print(f"Running tests: {round(progress * 100, 2):.2f}% complete", end='\r')
time.sleep(1)
last_progress_time = time.time()
print("\n")
def print_results(results):
for suite_name, suite_results in results.items():
suite_status = "failed" if has_failed_tests(suite_results['results']) else "passed"
print(f":: {suite_status.upper()}\t{suite_name} {suite_results['relativePath']}")
for res in suite_results['results']:
ancestors = " › ".join(res.get('ancestorTitles', []))
print(f" • {res['status'].upper()}\t{ancestors} {res['title']}")
if 'failureReason' not in res:
continue
failure_reason = re.sub('{{{((\w*)|(\/\w*))}}}', '', res['failureReason']['message'])
print(f"\t {failure_reason}")
print("") # for legibility
# returns tuple of parsed test results and whether any of the tests has failed
def parse_test_results(file_path):
if not os.path.isfile(file_path):
raise Exception('File not found')
# Open the output file and parse the results
with open(file_path, 'r') as f:
json_data = json.load(f)
return (group_results_by_parent(json_data), has_failed_tests(json_data))
def terminate_process(path):
for proc in psutil.process_iter(['cmdline', 'name', 'pid', 'status']):
pid = proc.info.get('pid')
if not pid:
continue
if not psutil.pid_exists(pid):
continue
if not proc.is_running():
continue
if proc.status() == psutil.STATUS_ZOMBIE:
continue
try:
exe = proc.exe()
except psutil.AccessDenied:
continue
except Exception as e:
print(f"Could not get executable for process {pid}: {e}", file=sys.stderr)
continue
name = proc.info['name']
# Use partial string comparison because binary is placed within the
# application bundle in /Contents/MacOS/Sketch and the bundle also
# includes binaries for XPC services such as Mirror and Assistants.
# Furthermore, pre-release builds of Sketch have their binaries named
# differently, e.g. Sketch Beta.
if exe and exe.startswith(f"{path}/Contents/MacOS"):
print(f"Terminating process: {proc.info}", file=sys.stderr)
proc.kill()
break
def main(argv):
sketch = '/Applications/Sketch.app' # default Sketch installation path
plugin = ''
output_file_path = ''
timeout = 30
usage = 'run_tests.py -s <sketch> -p <plugin> -o <outputFilePath> [-t <timeout>]'
try:
opts, args = getopt.getopt(
argv, "hs:p:o:t:", [
"sketch=", "plugin=", "outputFilePath=", "timeout="])
except getopt.GetoptError:
print(usage)
sys.exit(2)
for opt, arg in opts:
if opt == '-h':
print(usage)
sys.exit()
elif opt in ("-s", "--sketch"):
sketch = Path(arg).expanduser().resolve()
elif opt in ("-p", "--plugin"):
plugin = Path(arg).expanduser().resolve()
elif opt in ("-o", "--outputFilePath"):
output_file_path = Path(arg).expanduser().resolve()
elif opt in ("-t", "--timeout"):
timeout = float(arg)
if not plugin or not output_file_path:
print(usage)
sys.exit(2)
if not os.path.exists(plugin):
print(f"Plugin not found at: {plugin}", file=sys.stderr)
sys.exit(2)
if not os.path.exists(sketch):
print(f"Sketch bundle not found at: {sketch}", file=sys.stderr)
sys.exit(2)
# create a symbolic link to the plugin because Sketch expects it to
# be inside the Application Support Plugins folder.
app_plugins_path = PurePath(
str(Path.home()),
"Library/Application Support/com.bohemiancoding.sketch3/Plugins")
# ensure Plugins folder exists which may not be the case on new machines or user directories
os.makedirs(app_plugins_path, exist_ok=True)
plugin_path = app_plugins_path.joinpath(os.path.basename(plugin))
# in cases when the execution is stopped, make sure to remove the symbolic
# link before adding the new one
# We need the two tests because a broken symlink returns false, but the symlink
# is still actually there. If it's still actually there, the create symlink command barfs.
if os.path.exists(plugin_path) or os.path.islink(plugin_path):
os.remove(plugin_path)
os.symlink(os.path.abspath(plugin), plugin_path, target_is_directory=True)
# remove any previous test results
if os.path.exists(output_file_path):
os.remove(output_file_path)
# start execution time
start_time = time.time()
# get the plugin identifier from the manifest, Sketch uses the
# identifier to look up the corresponding plugin.
with open(PurePath(plugin_path, 'Contents/Sketch/manifest.json'), 'r') as f:
manifest = json.load(f)
# disable automatic safe mode after a crash #38815
subprocess.Popen([
"defaults",
"write",
"-app",
sketch,
"disableAutomaticSafeMode",
"YES",
])
# use macOS `open` command to spawn new, fresh instance without restoring
# windows, wait for Sketch to quit and use specific path to Sketch app
subprocess.Popen([
"open", "-n", "-F", "-j", "-a", sketch,
f"sketch://plugin/{manifest['identifier']}/test?output={output_file_path}",
"--args",
"-ApplePersistenceIgnoreState",
"YES",
])
try:
# wait until test runner finishes running all tests
watch_test_runner_progress(output_file_path, timeout)
# calc and display execution time
end_time = time.time()
print(f"\nDone in {round(end_time - start_time, 2)} seconds")
except TimeoutError:
print('Test run timed out')
sys.exit(1)
except Exception as e:
print(f"{e}\n\nFailed with exit code 1")
sys.exit(1)
finally:
# cleanup and delete the symbolic link
os.remove(plugin_path)
if terminate_sketch_on_completion:
terminate_process(sketch)
# restore default automatic safe mode behaviour
subprocess.Popen([
"defaults",
"delete",
"-app",
sketch,
"disableAutomaticSafeMode",
])
try:
# read test output file, parse and log the results, even if tests timed out the file
# contains all results up to the point where it timed out
results, failed = parse_test_results(output_file_path)
print_results(results)
if (failed):
raise Exception('Some tests failed')
print('All test suites passed')
sys.exit(0)
except Exception as e:
print(f"{e}\n\nFailed with exit code 1")
sys.exit(1)
if __name__ == "__main__":
main(sys.argv[1:])