Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(216)

Side by Side Diff: chrome/test/kasko/kasko_integration_test.py

Issue 1582613002: [win] Create a SyzyAsan/Chrome/Kasko/Crashpad integration test. (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Update file permissions. Created 4 years, 11 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | chrome/test/kasko/py/kasko/__init__.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 #!/usr/bin/env python 1 #!/usr/bin/env python
2 # Copyright 2015 The Chromium Authors. All rights reserved. 2 # Copyright 2015 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be 3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file. 4 # found in the LICENSE file.
5 5
6 """A Windows-only end-to-end integration test for Kasko, Chrome and Crashpad. 6 """A Windows-only end-to-end integration test for Kasko, Chrome and Crashpad.
7 7
8 This test ensures that the interface between Kasko and Chrome and Crashpad works 8 This test ensures that the interface between Kasko and Chrome and Crashpad works
9 as expected. The test causes Kasko to set certain crash keys and invoke a crash 9 as expected. The test causes Kasko to set certain crash keys and invoke a crash
10 report, which is in turn delivered to a locally hosted test crash server. If the 10 report, which is in turn delivered to a locally hosted test crash server. If the
11 crash report is received intact with the expected crash keys then all is well. 11 crash report is received intact with the expected crash keys then all is well.
12 12
13 Note that this test only works against non-component Release and Official builds 13 Note that this test only works against non-component Release and Official builds
14 of Chrome with Chrome branding, and attempting to use it with anything else will 14 of Chrome with Chrome branding, and attempting to use it with anything else will
15 most likely lead to constant failures. 15 most likely lead to constant failures.
16 16
17 Typical usage (assuming in root 'src' directory): 17 Typical usage (assuming in root 'src' directory):
18 18
19 - generate project files with the following GYP variables: 19 - generate project files with the following GYP variables:
20 branding=Chrome syzyasan=1 win_z7=0 chromium_win_pch=0 20 syzyasan=1 win_z7=0 chromium_win_pch=0
21 - build the release Chrome binaries: 21 - build the release Chrome binaries:
22 ninja -C out\Release chrome.exe 22 ninja -C out\Release chrome.exe
23 - run the test: 23 - run the test:
24 python chrome/test/kasko/kasko_integration_test.py --chrome-dir=out/Release 24 python chrome/test/kasko/kasko_integration_test.py
25
26 Many of the components in this test could be reused in other end-to-end crash
27 testing. Feel free to open them up for reuse, but please CC chrisha@chromium.org
28 on any associated reviews or bugs!
29 """ 25 """
30 26
31 import BaseHTTPServer
32 import cgi
33 import logging 27 import logging
34 import os 28 import os
35 import optparse
36 import pywintypes
37 import re
38 import shutil
39 import socket
40 import subprocess
41 import sys 29 import sys
42 import tempfile 30
43 import threading 31 # Bring in the Kasko module.
44 import time 32 KASKO_DIR = os.path.join(os.path.dirname(__file__), 'py')
45 import uuid 33 sys.path.append(KASKO_DIR)
46 import win32api 34 import kasko
47 import win32com.client
48 import win32con
49 import win32event
50 import win32gui
51 import win32process
52 35
53 36
54 _DEFAULT_TIMEOUT = 10 # Seconds.
55 _LOGGER = logging.getLogger(os.path.basename(__file__)) 37 _LOGGER = logging.getLogger(os.path.basename(__file__))
56 38
57 39
58 class _TimeoutException(Exception):
59 """Exception used to indicate a timeout has occurred."""
60 pass
61
62
63 class _StoppableHTTPServer(BaseHTTPServer.HTTPServer):
64 """An extension of BaseHTTPServer that uses timeouts and is interruptable."""
65
66 def server_bind(self):
67 BaseHTTPServer.HTTPServer.server_bind(self)
68 self.socket.settimeout(1)
69 self.run_ = True
70
71 def get_request(self):
72 while self.run_:
73 try:
74 sock, addr = self.socket.accept()
75 sock.settimeout(None)
76 return (sock, addr)
77 except socket.timeout:
78 pass
79
80 def stop(self):
81 self.run_ = False
82
83 def serve(self):
84 while self.run_:
85 self.handle_request()
86
87
88 class _CrashServer(object):
89 """A simple crash server for testing."""
90
91 def __init__(self):
92 self.server_ = None
93 self.lock_ = threading.Lock()
94 self.crashes_ = [] # Under lock_.
95
96 def crash(self, index):
97 """Accessor for the list of crashes."""
98 with self.lock_:
99 if index >= len(self.crashes_):
100 return None
101 return self.crashes_[index]
102
103 @property
104 def port(self):
105 """Returns the port associated with the server."""
106 if not self.server_:
107 return 0
108 return self.server_.server_port
109
110 def start(self):
111 """Starts the server on another thread. Call from main thread only."""
112 page_handler = self.multipart_form_handler()
113 self.server_ = _StoppableHTTPServer(('127.0.0.1', 0), page_handler)
114 self.thread_ = self.server_thread()
115 self.thread_.start()
116
117 def stop(self):
118 """Stops the running server. Call from main thread only."""
119 self.server_.stop()
120 self.thread_.join()
121 self.server_ = None
122 self.thread_ = None
123
124 def wait_for_report(self, timeout):
125 """Waits until the server has received a crash report.
126
127 Returns True if the a report has been received in the given time, or False
128 if a timeout occurred. Since Python condition variables have no notion of
129 timeout this is, sadly, a busy loop on the calling thread.
130 """
131 started = time.time()
132 elapsed = 0
133 while elapsed < timeout:
134 with self.lock_:
135 if len(self.crashes_):
136 return True
137 time.sleep(0.1)
138 elapsed = time.time() - started
139
140 return False
141
142
143 def multipart_form_handler(crash_server):
144 """Returns a multi-part form handler class for use with a BaseHTTPServer."""
145
146 class MultipartFormHandler(BaseHTTPServer.BaseHTTPRequestHandler):
147 """A multi-part form handler that processes crash reports.
148
149 This class only handles multipart form POST messages, with all other
150 requests by default returning a '501 not implemented' error.
151 """
152
153 def __init__(self, request, client_address, socket_server):
154 BaseHTTPServer.BaseHTTPRequestHandler.__init__(
155 self, request, client_address, socket_server)
156
157 def log_message(self, format, *args):
158 _LOGGER.debug(format, *args)
159
160 def do_POST(self):
161 """Handles POST messages contained multipart form data."""
162 content_type, parameters = cgi.parse_header(
163 self.headers.getheader('content-type'))
164 if content_type != 'multipart/form-data':
165 raise Exception('Unsupported Content-Type: ' + content_type)
166 post_multipart = cgi.parse_multipart(self.rfile, parameters)
167
168 # Save the crash report.
169 report = dict(post_multipart.items())
170 report_id = str(uuid.uuid4())
171 report['report-id'] = [report_id]
172 with crash_server.lock_:
173 crash_server.crashes_.append(report)
174
175 # Send the response.
176 self.send_response(200)
177 self.send_header("Content-Type", "text/plain")
178 self.end_headers()
179 self.wfile.write(report_id)
180
181 return MultipartFormHandler
182
183 def server_thread(crash_server):
184 """Returns a thread that hosts the webserver."""
185
186 class ServerThread(threading.Thread):
187 def run(self):
188 crash_server.server_.serve()
189
190 return ServerThread()
191
192
193 class _ScopedTempDir(object):
194 """A class that creates a scoped temporary directory."""
195
196 def __init__(self):
197 self.path_ = None
198
199 def __enter__(self):
200 """Creates the temporary directory and initializes |path|."""
201 self.path_ = tempfile.mkdtemp(prefix='kasko_integration_')
202 return self
203
204 def __exit__(self, *args, **kwargs):
205 """Destroys the temporary directory."""
206 if self.path_ is None:
207 return
208 shutil.rmtree(self.path_)
209
210 @property
211 def path(self):
212 return self.path_
213
214 def release(self):
215 path = self.path_
216 self.path_ = None
217 return path
218
219
220 class _ScopedStartStop(object):
221 """Utility class for calling 'start' and 'stop' within a scope."""
222
223 def __init__(self, service, start=None, stop=None):
224 self.service_ = service
225
226 if start is None:
227 self.start_ = lambda x: x.start()
228 else:
229 self.start_ = start
230
231 if stop is None:
232 self.stop_ = lambda x: x.stop()
233 else:
234 self.stop_ = stop
235
236 def __enter__(self):
237 self.start_(self.service_)
238 return self
239
240 def __exit__(self, *args, **kwargs):
241 if self.service_:
242 self.stop_(self.service_)
243
244 @property
245 def service(self):
246 """Returns the encapsulated service, retaining ownership."""
247 return self.service_
248
249 def release(self):
250 """Relinquishes ownership of the encapsulated service and returns it."""
251 service = self.service_
252 self.service_ = None
253 return service
254
255
256 def _FindChromeProcessId(user_data_dir, timeout=_DEFAULT_TIMEOUT):
257 """Finds the process ID of a given Chrome instance."""
258 udd = os.path.abspath(user_data_dir)
259
260 # Find the message window.
261 started = time.time()
262 elapsed = 0
263 msg_win = None
264 while msg_win is None:
265 try:
266 win = win32gui.FindWindowEx(None, None, 'Chrome_MessageWindow', udd)
267 if win != 0:
268 msg_win = win
269 break
270 except pywintypes.error:
271 continue
272
273 time.sleep(0.1)
274 elapsed = time.time() - started
275 if elapsed >= timeout:
276 raise _TimeoutException()
277
278 # Get the process ID associated with the message window.
279 tid, pid = win32process.GetWindowThreadProcessId(msg_win)
280
281 return pid
282
283
284 def _ShutdownProcess(process_id, timeout, force=False):
285 """Attempts to nicely close the specified process.
286
287 Returns the exit code on success. Raises an error on failure.
288 """
289
290 # Open the process in question, so we can wait for it to exit.
291 permissions = win32con.SYNCHRONIZE | win32con.PROCESS_QUERY_INFORMATION
292 process_handle = win32api.OpenProcess(permissions, False, process_id)
293
294 # Loop around to periodically retry to close Chrome.
295 started = time.time()
296 elapsed = 0
297 while True:
298 _LOGGER.debug('Shutting down process with PID=%d.', process_id)
299
300 with open(os.devnull, 'w') as f:
301 cmd = ['taskkill.exe', '/PID', str(process_id)]
302 if force:
303 cmd.append('/F')
304 subprocess.call(cmd, shell=True, stdout=f, stderr=f)
305
306 # Wait at most 2 seconds after each call to taskkill.
307 curr_timeout_ms = int(max(2, timeout - elapsed) * 1000)
308
309 _LOGGER.debug('Waiting for process with PID=%d to exit.', process_id)
310 result = win32event.WaitForSingleObject(process_handle, curr_timeout_ms)
311 # Exit the loop on successful wait.
312 if result == win32event.WAIT_OBJECT_0:
313 break
314
315 elapsed = time.time() - started
316 if elapsed > timeout:
317 _LOGGER.debug('Timeout waiting for process to exit.')
318 raise _TimeoutException()
319
320 exit_status = win32process.GetExitCodeProcess(process_handle)
321 process_handle.Close()
322 _LOGGER.debug('Process exited with status %d.', exit_status)
323
324 return exit_status
325
326
327 def _WmiTimeToLocalEpoch(wmitime):
328 """Converts a WMI time string to a Unix epoch time."""
329 # The format of WMI times is: yyyymmddHHMMSS.xxxxxx[+-]UUU, where
330 # UUU is the number of minutes between local time and UTC.
331 m = re.match('^(?P<year>\d{4})(?P<month>\d{2})(?P<day>\d{2})'
332 '(?P<hour>\d{2})(?P<minutes>\d{2})(?P<seconds>\d{2}\.\d+)'
333 '(?P<offset>[+-]\d{3})$', wmitime)
334 if not m:
335 raise Exception('Invalid WMI time string.')
336
337 # This parses the time as a local time.
338 t = time.mktime(time.strptime(wmitime[0:14], '%Y%m%d%H%M%S'))
339
340 # Add the fractional part of the seconds that wasn't parsed by strptime.
341 s = float(m.group('seconds'))
342 t += s - int(s)
343
344 return t
345
346
347 def _GetProcessCreationDate(pid):
348 """Returns the process creation date as local unix epoch time."""
349 wmi = win32com.client.GetObject('winmgmts:')
350 procs = wmi.ExecQuery(
351 'select CreationDate from Win32_Process where ProcessId = %s' % pid)
352 for proc in procs:
353 return _WmiTimeToLocalEpoch(proc.Properties_('CreationDate').Value)
354 raise Exception('Unable to find process with PID %d.' % pid)
355
356
357 def _ShutdownChildren(parent_pid, child_exe, started_after, started_before,
358 timeout=_DEFAULT_TIMEOUT, force=False):
359 """Shuts down any lingering child processes of a given parent.
360
361 This is an inherently racy thing to do as process IDs are aggressively reused
362 on Windows. Filtering by a valid known |started_after| and |started_before|
363 timestamp, as well as by the executable of the child process resolves this
364 issue. Ugh.
365 """
366 started = time.time()
367 wmi = win32com.client.GetObject('winmgmts:')
368 _LOGGER.debug('Shutting down lingering children processes.')
369 for proc in wmi.InstancesOf('Win32_Process'):
370 if proc.Properties_('ParentProcessId').Value != parent_pid:
371 continue
372 if proc.Properties_('ExecutablePath').Value != child_exe:
373 continue
374 t = _WmiTimeToLocalEpoch(proc.Properties_('CreationDate').Value)
375 if t <= started_after or t >= started_before:
376 continue
377 pid = proc.Properties_('ProcessId').Value
378 remaining = max(0, started + timeout - time.time())
379 _ShutdownProcess(pid, remaining, force=force)
380
381
382 class _ChromeInstance(object):
383 """A class encapsulating a running instance of Chrome for testing."""
384
385 def __init__(self, chromedriver, chrome, user_data_dir):
386 self.chromedriver_ = os.path.abspath(chromedriver)
387 self.chrome_ = os.path.abspath(chrome)
388 self.user_data_dir_ = user_data_dir
389
390 def start(self, timeout=_DEFAULT_TIMEOUT):
391 capabilities = {
392 'chromeOptions': {
393 'args': [
394 # This allows automated navigation to chrome:// URLs.
395 '--enable-gpu-benchmarking',
396 '--user-data-dir=%s' % self.user_data_dir_,
397 ],
398 'binary': self.chrome_,
399 }
400 }
401
402 # Use a _ScopedStartStop helper so the service and driver clean themselves
403 # up in case of any exceptions.
404 _LOGGER.info('Starting chromedriver')
405 with _ScopedStartStop(service.Service(self.chromedriver_)) as \
406 scoped_service:
407 _LOGGER.info('Starting chrome')
408 with _ScopedStartStop(webdriver.Remote(scoped_service.service.service_url,
409 capabilities),
410 start=lambda x: None, stop=lambda x: x.quit()) as \
411 scoped_driver:
412 self.pid_ = _FindChromeProcessId(self.user_data_dir_, timeout)
413 self.started_at_ = _GetProcessCreationDate(self.pid_)
414 _LOGGER.debug('Chrome launched.')
415 self.driver_ = scoped_driver.release()
416 self.service_ = scoped_service.release()
417
418
419 def stop(self, timeout=_DEFAULT_TIMEOUT):
420 started = time.time()
421 self.driver_.quit()
422 self.stopped_at_ = time.time()
423 self.service_.stop()
424 self.driver_ = None
425 self.service = None
426
427 # Ensure that any lingering children processes are torn down as well. This
428 # is generally racy on Windows, but is gated based on parent process ID,
429 # child executable, and start time of the child process. These criteria
430 # ensure we don't go indiscriminately killing processes.
431 remaining = max(0, started + timeout - time.time())
432 _ShutdownChildren(self.pid_, self.chrome_, self.started_at_,
433 self.stopped_at_, remaining, force=True)
434
435 def navigate_to(self, url):
436 """Navigates the running Chrome instance to the provided URL."""
437 self.driver_.get(url)
438
439
440 def _ParseCommandLine():
441 """Parses the command-line and returns an options structure."""
442 self_dir = os.path.dirname(__file__)
443 src_dir = os.path.abspath(os.path.join(self_dir, '..', '..', '..'))
444
445 option_parser = optparse.OptionParser()
446 option_parser.add_option('--chrome', dest='chrome', type='string',
447 default=os.path.join(src_dir, 'out', 'Release', 'chrome.exe'),
448 help='Path to chrome.exe. Defaults to $SRC/out/Release/chrome.exe')
449 option_parser.add_option('--chromedriver', dest='chromedriver',
450 type='string', help='Path to the chromedriver.exe. By default will look '
451 'alongside chrome.exe.')
452 option_parser.add_option('--keep-temp-dirs', action='store_true',
453 default=False, help='Prevents temporary directories from being deleted.')
454 option_parser.add_option('--quiet', dest='log_level', action='store_const',
455 default=logging.INFO, const=logging.ERROR,
456 help='Disables all output except for errors.')
457 option_parser.add_option('--user-data-dir', dest='user_data_dir',
458 type='string', help='User data directory to use. Defaults to using a '
459 'temporary one.')
460 option_parser.add_option('--verbose', dest='log_level', action='store_const',
461 default=logging.INFO, const=logging.DEBUG,
462 help='Enables verbose logging.')
463 option_parser.add_option('--webdriver', type='string',
464 default=os.path.join(src_dir, 'third_party', 'webdriver', 'pylib'),
465 help='Specifies the directory where the python installation of webdriver '
466 '(selenium) can be found. Specify an empty string to use the system '
467 'installation. Defaults to $SRC/third_party/webdriver/pylib')
468 options, args = option_parser.parse_args()
469 if args:
470 option_parser.error('Unexpected arguments: %s' % args)
471
472 # Validate chrome.exe exists.
473 if not os.path.isfile(options.chrome):
474 option_parser.error('chrome.exe not found')
475
476 # Use default chromedriver.exe if necessary, and validate it exists.
477 if not options.chromedriver:
478 options.chromedriver = os.path.join(os.path.dirname(options.chrome),
479 'chromedriver.exe')
480 if not os.path.isfile(options.chromedriver):
481 option_parser.error('chromedriver.exe not found')
482
483 # If specified, ensure the webdriver parameters is a directory.
484 if options.webdriver and not os.path.isdir(options.webdriver):
485 option_parser.error('Invalid webdriver directory.')
486
487 # Configure logging.
488 logging.basicConfig(level=options.log_level)
489
490 _LOGGER.debug('Using chrome path: %s', options.chrome)
491 _LOGGER.debug('Using chromedriver path: %s', options.chromedriver)
492 _LOGGER.debug('Using webdriver path: %s', options.webdriver)
493
494 # Import webdriver and selenium.
495 global webdriver
496 global service
497 if options.webdriver:
498 sys.path.append(options.webdriver)
499 from selenium import webdriver
500 import selenium.webdriver.chrome.service as service
501
502 return options
503
504
505 def Main(): 40 def Main():
506 options = _ParseCommandLine() 41 options = kasko.config.ParseCommandLine()
507 42
508 # Generate a temporary directory for use in the tests. 43 # Generate a temporary directory for use in the tests.
509 with _ScopedTempDir() as temp_dir: 44 with kasko.util.ScopedTempDir() as temp_dir:
510 # Prevent the temporary directory from self cleaning if requested. 45 # Prevent the temporary directory from self cleaning if requested.
511 if options.keep_temp_dirs: 46 if options.keep_temp_dirs:
512 temp_dir_path = temp_dir.release() 47 temp_dir_path = temp_dir.release()
513 else: 48 else:
514 temp_dir_path = temp_dir.path 49 temp_dir_path = temp_dir.path
515 50
516 # Use the specified user data directory if requested. 51 # Use the specified user data directory if requested.
517 if options.user_data_dir: 52 if options.user_data_dir:
518 user_data_dir = options.user_data_dir 53 user_data_dir = options.user_data_dir
519 else: 54 else:
520 user_data_dir = os.path.join(temp_dir_path, 'user-data-dir') 55 user_data_dir = os.path.join(temp_dir_path, 'user-data-dir')
521 56
522 kasko_dir = os.path.join(temp_dir_path, 'kasko') 57 kasko_dir = os.path.join(temp_dir_path, 'kasko')
523 os.makedirs(kasko_dir) 58 os.makedirs(kasko_dir)
524 59
525 # Launch the test server. 60 # Launch the test server.
526 server = _CrashServer() 61 server = kasko.crash_server.CrashServer()
527 with _ScopedStartStop(server): 62 with kasko.util.ScopedStartStop(server):
528 _LOGGER.info('Started server on port %d', server.port) 63 _LOGGER.info('Started server on port %d', server.port)
529 64
530 # Configure the environment so Chrome can find the test crash server. 65 # Configure the environment so Chrome can find the test crash server.
531 os.environ['KASKO_CRASH_SERVER_URL'] = ( 66 os.environ['KASKO_CRASH_SERVER_URL'] = (
532 'http://127.0.0.1:%d/crash' % server.port) 67 'http://127.0.0.1:%d/crash' % server.port)
533 68
534 # Launch Chrome and navigate it to the test URL. 69 # Launch Chrome and navigate it to the test URL.
535 chrome = _ChromeInstance(options.chromedriver, options.chrome, 70 chrome = kasko.process.ChromeInstance(options.chromedriver,
536 user_data_dir) 71 options.chrome, user_data_dir)
537 with _ScopedStartStop(chrome): 72 with kasko.util.ScopedStartStop(chrome):
538 _LOGGER.info('Navigating to Kasko debug URL') 73 _LOGGER.info('Navigating to Kasko debug URL')
539 chrome.navigate_to('chrome://kasko/send-report') 74 chrome.navigate_to('chrome://kasko/send-report')
540 75
541 _LOGGER.info('Waiting for Kasko report') 76 _LOGGER.info('Waiting for Kasko report')
542 if not server.wait_for_report(10): 77 if not server.wait_for_report(10):
543 raise Exception('No Kasko report received.') 78 raise Exception('No Kasko report received.')
544 79
545 report = server.crash(0) 80 report = server.crash(0)
546 for key in sorted(report.keys()): 81 kasko.report.LogCrashKeys(report)
547 val = report[key][0] 82 kasko.report.ValidateCrashReport(report,
548 if (len(val) < 64): 83 {'kasko-set-crash-key-value-impl': 'SetCrashKeyValueImpl'})
549 _LOGGER.debug('Got crashkey "%s": "%s"', key, val)
550 else:
551 _LOGGER.debug('Got crashkey "%s": ...%d bytes...', key, len(val))
552
553 expected_keys = {
554 'kasko-set-crash-key-value-impl': 'SetCrashKeyValueImpl',
555 'guid': 'GetCrashKeysForKasko'}
556 for expected_key, error in expected_keys.iteritems():
557 if expected_key not in report:
558 _LOGGER.error('Missing expected "%s" crash key.', expected_key)
559 raise Exception('"%s" integration appears broken.' % error)
560 84
561 return 0 85 return 0
562 86
563 87
564 if __name__ == '__main__': 88 if __name__ == '__main__':
565 sys.exit(Main()) 89 sys.exit(Main())
OLDNEW
« no previous file with comments | « no previous file | chrome/test/kasko/py/kasko/__init__.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698