1# Copyright (c) 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import argparse
6import json
7import os
8import sys
9import urlparse
10
11from hooks import install
12
13from paste import fileapp
14from paste import httpserver
15
16import webapp2
17from webapp2 import Route, RedirectHandler
18
19from dashboard_build import dashboard_dev_server_config
20from perf_insights_build import perf_insights_dev_server_config
21from tracing_build import tracing_dev_server_config
22
23_MAIN_HTML = """<html><body>
24<h1>Run Unit Tests</h1>
25<ul>
26%s
27</ul>
28<h1>Quick links</h1>
29<ul>
30%s
31</ul>
32</body></html>
33"""
34
35_QUICK_LINKS = [
36    ('Trace File Viewer',
37     '/tracing_examples/trace_viewer.html'),
38    ('Perf Insights Viewer',
39     '/perf_insights_examples/perf_insights_viewer.html')
40]
41
42_LINK_ITEM = '<li><a href="%s">%s</a></li>'
43
44def _GetFilesIn(basedir):
45  data_files = []
46  for dirpath, dirnames, filenames in os.walk(basedir, followlinks=True):
47    new_dirnames = [d for d in dirnames if not d.startswith('.')]
48    del dirnames[:]
49    dirnames += new_dirnames
50
51    for f in filenames:
52      if f.startswith('.'):
53        continue
54      if f == 'README.md':
55        continue
56      full_f = os.path.join(dirpath, f)
57      rel_f = os.path.relpath(full_f, basedir)
58      data_files.append(rel_f)
59
60  data_files.sort()
61  return data_files
62
63
64def _RelPathToUnixPath(p):
65  return p.replace(os.sep, '/')
66
67
68class TestResultHandler(webapp2.RequestHandler):
69  def post(self, *args, **kwargs):  # pylint: disable=unused-argument
70    msg = self.request.body
71    ostream = sys.stdout if 'PASSED' in msg else sys.stderr
72    ostream.write(msg + '\n')
73    return self.response.write('')
74
75
76class TestsCompletedHandler(webapp2.RequestHandler):
77  def post(self, *args, **kwargs):  # pylint: disable=unused-argument
78    msg = self.request.body
79    sys.stdout.write(msg + '\n')
80    exit_code = 0 if 'ALL_PASSED' in msg else 1
81    if hasattr(self.app.server, 'please_exit'):
82      self.app.server.please_exit(exit_code)
83    return self.response.write('')
84
85
86class DirectoryListingHandler(webapp2.RequestHandler):
87  def get(self, *args, **kwargs):  # pylint: disable=unused-argument
88    source_path = kwargs.pop('_source_path', None)
89    mapped_path = kwargs.pop('_mapped_path', None)
90    assert mapped_path.endswith('/')
91
92    data_files_relative_to_top = _GetFilesIn(source_path)
93    data_files = [mapped_path + x
94                  for x in data_files_relative_to_top]
95
96    files_as_json = json.dumps(data_files)
97    self.response.content_type = 'application/json'
98    return self.response.write(files_as_json)
99
100
101class FileAppWithGZipHandling(fileapp.FileApp):
102  def guess_type(self):
103    content_type, content_encoding = \
104        super(FileAppWithGZipHandling, self).guess_type()
105    if not self.filename.endswith('.gz'):
106      return content_type, content_encoding
107    # By default, FileApp serves gzip files as their underlying type with
108    # Content-Encoding of gzip. That causes them to show up on the client
109    # decompressed. That ends up being surprising to our xhr.html system.
110    return None, None
111
112class SourcePathsHandler(webapp2.RequestHandler):
113  def get(self, *args, **kwargs):  # pylint: disable=unused-argument
114    source_paths = kwargs.pop('_source_paths', [])
115
116    path = self.request.path
117    # This is how we do it. Its... strange, but its what we've done since
118    # the dawn of time. Aka 4 years ago, lol.
119    for mapped_path in source_paths:
120      rel = os.path.relpath(path, '/')
121      candidate = os.path.join(mapped_path, rel)
122      if os.path.exists(candidate):
123        app = FileAppWithGZipHandling(candidate)
124        app.cache_control(no_cache=True)
125        return app
126    self.abort(404)
127
128  @staticmethod
129  def GetServingPathForAbsFilename(source_paths, filename):
130    if not os.path.isabs(filename):
131      raise Exception('filename must be an absolute path')
132
133    for mapped_path in source_paths:
134      if not filename.startswith(mapped_path):
135        continue
136      rel = os.path.relpath(filename, mapped_path)
137      unix_rel = _RelPathToUnixPath(rel)
138      return unix_rel
139    return None
140
141
142class SimpleDirectoryHandler(webapp2.RequestHandler):
143  def get(self, *args, **kwargs):  # pylint: disable=unused-argument
144    top_path = os.path.abspath(kwargs.pop('_top_path', None))
145    if not top_path.endswith(os.path.sep):
146      top_path += os.path.sep
147
148    joined_path = os.path.abspath(
149        os.path.join(top_path, kwargs.pop('rest_of_path')))
150    if not joined_path.startswith(top_path):
151      self.response.set_status(403)
152      return
153    app = FileAppWithGZipHandling(joined_path)
154    app.cache_control(no_cache=True)
155    return app
156
157
158class TestOverviewHandler(webapp2.RequestHandler):
159  def get(self, *args, **kwargs):  # pylint: disable=unused-argument
160    test_links = []
161    for name, path in kwargs.pop('pds').iteritems():
162      test_links.append(_LINK_ITEM % (path, name))
163    quick_links = []
164    for name, path in _QUICK_LINKS:
165      quick_links.append(_LINK_ITEM % (path, name))
166    self.response.out.write(_MAIN_HTML % ('\n'.join(test_links),
167                                          '\n'.join(quick_links)))
168
169class DevServerApp(webapp2.WSGIApplication):
170  def __init__(self, pds, args):
171    super(DevServerApp, self).__init__(debug=True)
172    self.pds = pds
173    self._server = None
174    self._all_source_paths = []
175    self._all_mapped_test_data_paths = []
176    self._InitFromArgs(args)
177
178  @property
179  def server(self):
180    return self._server
181
182  @server.setter
183  def server(self, server):
184    self._server = server
185
186  def _InitFromArgs(self, args):
187    default_tests = dict((pd.GetName(), pd.GetRunUnitTestsUrl())
188                         for pd in self.pds)
189    routes = [
190        Route('/tests.html', TestOverviewHandler,
191              defaults={'pds': default_tests}),
192        Route('', RedirectHandler, defaults={'_uri': '/tests.html'}),
193        Route('/', RedirectHandler, defaults={'_uri': '/tests.html'}),
194    ]
195    for pd in self.pds:
196      routes += pd.GetRoutes(args)
197      routes += [
198          Route('/%s/notify_test_result' % pd.GetName(),
199                TestResultHandler),
200          Route('/%s/notify_tests_completed' % pd.GetName(),
201                TestsCompletedHandler)
202      ]
203
204    for pd in self.pds:
205      # Test data system.
206      for mapped_path, source_path in pd.GetTestDataPaths(args):
207        self._all_mapped_test_data_paths.append((mapped_path, source_path))
208        routes.append(Route('%s__file_list__' % mapped_path,
209                            DirectoryListingHandler,
210                            defaults={
211                                '_source_path': source_path,
212                                '_mapped_path': mapped_path
213                            }))
214        routes.append(Route('%s<rest_of_path:.+>' % mapped_path,
215                            SimpleDirectoryHandler,
216                            defaults={'_top_path': source_path}))
217
218    # This must go last, because its catch-all.
219    #
220    # Its funky that we have to add in the root path. The long term fix is to
221    # stop with the crazy multi-source-pathing thing.
222    for pd in self.pds:
223      self._all_source_paths += pd.GetSourcePaths(args)
224    routes.append(
225        Route('/<:.+>', SourcePathsHandler,
226              defaults={'_source_paths': self._all_source_paths}))
227
228    for route in routes:
229      self.router.add(route)
230
231  def GetAbsFilenameForHref(self, href):
232    for source_path in self._all_source_paths:
233      full_source_path = os.path.abspath(source_path)
234      expanded_href_path = os.path.abspath(os.path.join(full_source_path,
235                                                        href.lstrip('/')))
236      if (os.path.exists(expanded_href_path) and
237          os.path.commonprefix([full_source_path,
238                                expanded_href_path]) == full_source_path):
239        return expanded_href_path
240    return None
241
242  def GetURLForAbsFilename(self, filename):
243    assert self.server is not None
244    for mapped_path, source_path in self._all_mapped_test_data_paths:
245      if not filename.startswith(source_path):
246        continue
247      rel = os.path.relpath(filename, source_path)
248      unix_rel = _RelPathToUnixPath(rel)
249      url = urlparse.urljoin(mapped_path, unix_rel)
250      return url
251
252    path = SourcePathsHandler.GetServingPathForAbsFilename(
253        self._all_source_paths, filename)
254    if path is None:
255      return None
256    return urlparse.urljoin('/', path)
257
258
259def _AddPleaseExitMixinToServer(server):
260  # Shutting down httpserver gracefully and yielding a return code requires
261  # a bit of mixin code.
262
263  exit_code_attempt = []
264  def PleaseExit(exit_code):
265    if len(exit_code_attempt) > 0:
266      return
267    exit_code_attempt.append(exit_code)
268    server.running = False
269
270  real_serve_forever = server.serve_forever
271
272  def ServeForever():
273    try:
274      real_serve_forever()
275    except KeyboardInterrupt:
276      # allow CTRL+C to shutdown
277      return 255
278
279    if len(exit_code_attempt) == 1:
280      return exit_code_attempt[0]
281    # The serve_forever returned for some reason separate from
282    # exit_please.
283    return 0
284
285  server.please_exit = PleaseExit
286  server.serve_forever = ServeForever
287
288
289def _AddCommandLineArguments(pds, argv):
290  parser = argparse.ArgumentParser(description='Run development server')
291  parser.add_argument(
292      '--no-install-hooks', dest='install_hooks', action='store_false')
293  parser.add_argument('-p', '--port', default=8003, type=int)
294  for pd in pds:
295    g = parser.add_argument_group(pd.GetName())
296    pd.AddOptionstToArgParseGroup(g)
297  args = parser.parse_args(args=argv[1:])
298  return args
299
300
301def Main(argv):
302  pds = [
303      dashboard_dev_server_config.DashboardDevServerConfig(),
304      perf_insights_dev_server_config.PerfInsightsDevServerConfig(),
305      tracing_dev_server_config.TracingDevServerConfig(),
306  ]
307
308  args = _AddCommandLineArguments(pds, argv)
309
310  if args.install_hooks:
311    install.InstallHooks()
312
313  app = DevServerApp(pds, args=args)
314
315  server = httpserver.serve(app, host='127.0.0.1', port=args.port,
316                            start_loop=False)
317  _AddPleaseExitMixinToServer(server)
318  # pylint: disable=no-member
319  server.urlbase = 'http://127.0.0.1:%i' % server.server_port
320  app.server = server
321
322  sys.stderr.write('Now running on %s\n' % server.urlbase)
323
324  return server.serve_forever()
325