summaryrefslogtreecommitdiffstats
path: root/testing/raptor/raptor/control_server.py
blob: 5a1b2f253630c12e471bc3d0ac8b76d09c1a95ea (plain)
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
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
#!/usr/bin/env python

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

# control server for raptor performance framework
# communicates with the raptor browser webextension
import datetime
import json
import os
import shutil
import socket
import threading
import time

import six

try:
    from http import server  # py3
except ImportError:
    import BaseHTTPServer as server  # py2

from logger.logger import RaptorLogger

LOG = RaptorLogger(component="raptor-control-server")

here = os.path.abspath(os.path.dirname(__file__))


def MakeCustomHandlerClass(
    results_handler,
    error_handler,
    startup_handler,
    shutdown_browser,
    handle_gecko_profile,
    background_app,
    foreground_app,
):
    class MyHandler(server.BaseHTTPRequestHandler, object):
        """
        Control server expects messages of the form
        {'type': 'messagetype', 'data':...}

        Each message is given a key which is calculated as

           If type is 'webext_status', then
              the key is data['type']/data['data']
           otherwise
              the key is data['type'].

        The control server can be forced to wait before performing an
        action requested via POST by sending a special message

        {'type': 'wait-set', 'data': key}

        where key is the key of the message for which the control server should
        perform a wait before processing. The handler will store
        this key in the wait_after_messages dict as a True value.

        wait_after_messages[key] = True

        For subsequent requests, the handler will check the key of
        the incoming message against wait_for_messages; if found
        and its value is True, the handler will assign the key
        to waiting_in_state and will loop until the key is removed
        or until its value is changed to False.

        The control server will stop waiting for a state to be continued
        or cleared after wait_timeout seconds, after which the wait
        will be removed and the control server will finish processing
        the current POST request. wait_timeout defaults to 60 seconds
        but can be set globally for all wait states by sending the
        message

        {'type': 'wait-timeout', 'data': timeout}

        The value of waiting_in_state can be retrieved by sending the
        message

        {'type': 'wait-get', 'data': ''}

        which will return the value of waiting_in_state in the
        content of the response. If the value returned is not
        'None', then the control server has received a message whose
        key is recorded in wait_after_messages and is waiting before
        completing the request.

        The control server can be told to stop waiting and finish
        processing the current request while keeping the wait for
        subsequent requests by sending

        {'type': 'wait-continue', 'data': ''}

        The control server can be told to stop waiting and finish
        processing the current request while removing the wait for
        subsequent requests by sending

        {'type': 'wait-clear', 'data': key}

            if key is the empty string ''
                the key in waiting_in_state is removed from wait_after_messages
                waiting_in_state is set to None
            else if key is 'all'
                 all keys in wait_after_messages are removed
            else key is not in wait_after_messages
                 the message is ignored
            else
                 the key is removed from wait_after messages
                 if the key matches the value in waiting_in_state,
                 then waiting_in_state is set to None
        """

        wait_after_messages = {}
        waiting_in_state = None
        wait_timeout = 60

        def __init__(self, *args, **kwargs):
            self.results_handler = results_handler
            self.error_handler = error_handler
            self.startup_handler = startup_handler
            self.shutdown_browser = shutdown_browser
            self.handle_gecko_profile = handle_gecko_profile
            self.background_app = background_app
            self.foreground_app = foreground_app
            try:
                super(MyHandler, self).__init__(*args, **kwargs)
            except ValueError:
                pass

        def log_request(self, code="-", size="-"):
            if code != 200:
                super(MyHandler, self).log_request(code, size)

        def do_GET(self):
            # get handler, received request for test settings from webext runner
            self.send_response(200)
            head, tail = os.path.split(self.path)

            if tail.startswith("raptor") and tail.endswith(".json"):
                LOG.info("reading test settings from json/" + tail)
                try:
                    with open("json/{}".format(tail)) as json_settings:
                        self.send_header("Access-Control-Allow-Origin", "*")
                        self.send_header("Content-type", "application/json")
                        self.end_headers()
                        self.wfile.write(
                            json.dumps(json.load(json_settings)).encode("utf-8")
                        )
                        self.wfile.close()
                        LOG.info("sent test settings to webext runner")
                except Exception as ex:
                    LOG.info("control server exception")
                    LOG.info(ex)
            else:
                LOG.info("received request for unknown file: " + self.path)

        def do_POST(self):
            # post handler, received something from webext
            self.send_response(200)
            self.send_header("Access-Control-Allow-Origin", "*")
            self.send_header("Content-type", "text/html")
            self.end_headers()

            if six.PY2:
                content_len = int(self.headers.getheader("content-length"))
            elif six.PY3:
                content_len = int(self.headers.get("content-length"))

            post_body = self.rfile.read(content_len)
            # could have received a status update or test results
            if isinstance(post_body, six.binary_type):
                post_body = post_body.decode("utf-8")
            data = json.loads(post_body)

            if data["type"] == "webext_status":
                wait_key = "%s/%s" % (data["type"], data["data"])
            else:
                wait_key = data["type"]

            if MyHandler.wait_after_messages.get(wait_key, None):
                LOG.info("Waiting in %s" % wait_key)
                MyHandler.waiting_in_state = wait_key
                start_time = datetime.datetime.now()

            while MyHandler.wait_after_messages.get(wait_key, None):
                time.sleep(1)
                elapsed_time = datetime.datetime.now() - start_time
                if elapsed_time > datetime.timedelta(seconds=MyHandler.wait_timeout):
                    del MyHandler.wait_after_messages[wait_key]
                    MyHandler.waiting_in_state = None
                    LOG.error(
                        "TEST-UNEXPECTED-ERROR | "
                        "control server wait %s exceeded %s seconds"
                        % (wait_key, MyHandler.wait_timeout)
                    )

            if MyHandler.wait_after_messages.get(wait_key, None) is not None:
                # If the wait is False, it was continued, so we set it back
                # to True for the next time. If removed by clear, we
                # leave it alone so it will not cause further waits.
                MyHandler.wait_after_messages[wait_key] = True

            if data["type"] == "webext_error":
                error, stack = data["data"]
                LOG.info("received " + data["type"] + ": " + str(error))
                self.error_handler(error, stack)

            elif data["type"] == "webext_gecko_profile":
                # received file name of the saved gecko profile
                filename = str(data["data"])
                LOG.info("received gecko profile filename: {}".format(filename))
                self.handle_gecko_profile(filename)

            elif data["type"] == "webext_results":
                LOG.info("received " + data["type"] + ": " + str(data["data"]))
                self.results_handler.add(data["data"])
            elif data["type"] == "webext_raptor-page-timeout":
                LOG.info("received " + data["type"] + ": " + str(data["data"]))

                if len(data["data"]) == 3:
                    data["data"].append("")
                # pageload test has timed out; record it as a failure
                self.results_handler.add_page_timeout(
                    str(data["data"][0]),
                    str(data["data"][1]),
                    str(data["data"][2]),
                    dict(data["data"][3]),
                )
            elif data["type"] == "webext_shutdownBrowser":
                LOG.info("received request to shutdown the browser")
                self.shutdown_browser()
            elif data["type"] == "webext_start_background":
                LOG.info("received request to background app")
                self.background_app()
            elif data["type"] == "webext_end_background":
                LOG.info("received request to foreground app")
                self.foreground_app()
            elif data["type"] == "webext_screenshot":
                LOG.info("received " + data["type"])
                self.results_handler.add_image(
                    str(data["data"][0]), str(data["data"][1]), str(data["data"][2])
                )
            elif data["type"] == "webext_status":
                LOG.info("received " + data["type"] + ": " + str(data["data"]))
            elif data["type"] == "webext_loaded":
                LOG.info("received " + data["type"] + ": raptor runner.js is loaded!")
                self.startup_handler(True)
            elif data["type"] == "wait-set":
                LOG.info("received " + data["type"] + ": " + str(data["data"]))
                MyHandler.wait_after_messages[str(data["data"])] = True
            elif data["type"] == "wait-timeout":
                LOG.info("received " + data["type"] + ": " + str(data["data"]))
                MyHandler.wait_timeout = data["data"]
            elif data["type"] == "wait-get":
                state = MyHandler.waiting_in_state
                if state is None:
                    state = "None"
                if isinstance(state, six.text_type):
                    state = state.encode("utf-8")
                self.wfile.write(state)
            elif data["type"] == "wait-continue":
                LOG.info("received " + data["type"] + ": " + str(data["data"]))
                if MyHandler.waiting_in_state:
                    MyHandler.wait_after_messages[MyHandler.waiting_in_state] = False
                    MyHandler.waiting_in_state = None
            elif data["type"] == "wait-clear":
                LOG.info("received " + data["type"] + ": " + str(data["data"]))
                clear_key = str(data["data"])
                if clear_key == "":
                    if MyHandler.waiting_in_state:
                        del MyHandler.wait_after_messages[MyHandler.waiting_in_state]
                        MyHandler.waiting_in_state = None
                    else:
                        pass
                elif clear_key == "all":
                    MyHandler.wait_after_messages = {}
                    MyHandler.waiting_in_state = None
                elif clear_key not in MyHandler.wait_after_messages:
                    pass
                else:
                    del MyHandler.wait_after_messages[clear_key]
                    if MyHandler.waiting_in_state == clear_key:
                        MyHandler.waiting_in_state = None
            else:
                LOG.info("received " + data["type"] + ": " + str(data["data"]))

        def do_OPTIONS(self):
            self.send_response(200, "ok")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
            self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
            self.send_header("Access-Control-Allow-Headers", "Content-Type")
            self.end_headers()

    return MyHandler


class ThreadedHTTPServer(server.HTTPServer):
    # See
    # https://stackoverflow.com/questions/19537132/threaded-basehttpserver-one-thread-per-request#30312766
    def process_request(self, request, client_address):
        thread = threading.Thread(
            target=self.__new_request,
            args=(self.RequestHandlerClass, request, client_address, self),
        )
        thread.start()

    def __new_request(self, handlerClass, request, address, server):
        handlerClass(request, address, server)
        self.shutdown_request(request)


class RaptorControlServer:
    """Container class for Raptor Control Server"""

    def __init__(self, results_handler, debug_mode=False):
        self.raptor_venv = os.path.join(os.getcwd(), "raptor-venv")
        self.server = None
        self._server_thread = None
        self.port = None
        self.results_handler = results_handler
        self.browser_proc = None
        self._finished = False
        self._is_shutting_down = False
        self._runtime_error = None
        self.device = None
        self.app_name = None
        self.gecko_profile_dir = None
        self.debug_mode = debug_mode
        self.user_profile = None
        self.is_webextension_loaded = False

    def start(self):
        config_dir = os.path.join(here, "tests")
        os.chdir(config_dir)

        # pick a free port
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.bind(("", 0))
        self.port = sock.getsockname()[1]
        sock.close()
        server_address = ("", self.port)

        server_class = ThreadedHTTPServer
        handler_class = MakeCustomHandlerClass(
            self.results_handler,
            self.error_handler,
            self.startup_handler,
            self.shutdown_browser,
            self.handle_gecko_profile,
            self.background_app,
            self.foreground_app,
        )

        httpd = server_class(server_address, handler_class)

        self._server_thread = threading.Thread(target=httpd.serve_forever)
        self._server_thread.setDaemon(True)  # don't hang on exit
        self._server_thread.start()
        LOG.info("raptor control server running on port %d..." % self.port)
        self.server = httpd

    def error_handler(self, error, stack):
        self._runtime_error = {"error": error, "stack": stack}

    def startup_handler(self, value):
        self.is_webextension_loaded = value

    def shutdown_browser(self):
        # if debug-mode enabled, leave the browser running - require manual shutdown
        # this way the browser console remains open, so we can review the logs etc.
        if self.debug_mode:
            LOG.info("debug-mode enabled, so NOT shutting down the browser")
            self._finished = True
            return

        if self.device is not None:
            LOG.info("shutting down android app %s" % self.app_name)
        else:
            LOG.info("shutting down browser (pid: %d)" % self.browser_proc.pid)
        self.kill_thread = threading.Thread(
            target=self.wait_for_quit, kwargs={"timeout": 0}
        )
        self.kill_thread.daemon = True
        self.kill_thread.start()

    def handle_gecko_profile(self, filename):
        # Move the stored profile to a location outside the Firefox profile
        source_path = os.path.join(self.user_profile.profile, "profiler", filename)
        target_path = os.path.join(self.gecko_profile_dir, filename)
        shutil.move(source_path, target_path)
        LOG.info("moved gecko profile to {}".format(target_path))

    def is_app_in_background(self):
        # Get the app view state: foreground->False, background->True
        current_focus = self.device.shell_output(
            "dumpsys window windows | grep mCurrentFocus"
        ).strip()
        return self.app_name not in current_focus

    def background_app(self):
        # Disable Doze, background the app, then disable App Standby
        self.device.shell_output("dumpsys deviceidle whitelist +%s" % self.app_name)
        self.device.shell_output("input keyevent 3")
        if not self.is_app_in_background():
            LOG.critical(
                "%s is still in foreground after background request" % self.app_name
            )
        else:
            LOG.info("%s was successfully backgrounded" % self.app_name)

    def foreground_app(self):
        self.device.shell_output("am start --activity-single-top %s" % self.app_name)
        self.device.shell_output("dumpsys deviceidle enable")
        if self.is_app_in_background():
            LOG.critical(
                "%s is still in background after foreground request" % self.app_name
            )
        else:
            LOG.info("%s was successfully foregrounded" % self.app_name)

    def wait_for_quit(self, timeout=15):
        """Wait timeout seconds for the process to exit. If it hasn't
        exited by then, kill it.

        The sleep calls are required to give those new values enough time
        to sync-up between threads. It would be better to maybe use signals
        for synchronization (bug 1633975)
        """
        self._is_shutting_down = True
        time.sleep(0.25)

        if self.device is not None:
            self.device.stop_application(self.app_name)
        else:
            try:
                self.browser_proc.wait(timeout)
            except OSError:
                LOG.warning("OSError while shutting down browser", exc_info=True)
            finally:
                if self.browser_proc.poll() is None:
                    self.browser_proc.kill()

        self._finished = True
        time.sleep(0.25)
        self._is_shutting_down = False

    def submit_supporting_data(self, supporting_data):
        """
        Allow the submission of supporting data i.e. power data.
        This type of data is measured outside of the webext; so
        we can submit directly to the control server instead of
        doing an http post.
        """
        self.results_handler.add_supporting_data(supporting_data)

    def stop(self):
        LOG.info("shutting down control server")
        self.server.shutdown()
        self._server_thread.join()