blob: e0ca7ab08c38a7cd82fd055e37ff19b813b468b8 [file] [log] [blame]
Hung-Te Lin6bb48552012-02-09 14:37:43 +08001#!/usr/bin/python -u
Hung-Te Linf2f78f72012-02-08 19:27:11 +08002# -*- coding: utf-8 -*-
3#
4# Copyright (c) 2010 The Chromium OS Authors. All rights reserved.
5# Use of this source code is governed by a BSD-style license that can be
6# found in the LICENSE file.
7
8
9# DESCRIPTION :
10#
11# This library provides convenience routines to launch factory tests.
12# This includes support for drawing the test widget in a window at the
13# proper location, grabbing control of the mouse, and making the mouse
14# cursor disappear.
Hung-Te Lin6bb48552012-02-09 14:37:43 +080015#
16# This UI is intended to be used by the factory autotest suite to
17# provide factory operators feedback on test status and control over
18# execution order.
19#
20# In short, the UI is composed of a 'console' panel on the bottom of
21# the screen which displays the autotest log, and there is also a
22# 'test list' panel on the right hand side of the screen. The
23# majority of the screen is dedicated to tests, which are executed in
24# seperate processes, but instructed to display their own UIs in this
25# dedicated area whenever possible. Tests in the test list are
26# executed in order by default, but can be activated on demand via
27# associated keyboard shortcuts. As tests are run, their status is
28# color-indicated to the operator -- greyed out means untested, yellow
29# means active, green passed and red failed.
Hung-Te Linf2f78f72012-02-08 19:27:11 +080030
Hung-Te Lin6bb48552012-02-09 14:37:43 +080031import logging
32import os
33import re
Tammo Spalinkdb8d7112012-03-13 18:54:37 +080034import string
Hung-Te Lin6bb48552012-02-09 14:37:43 +080035import subprocess
36import sys
Hung-Te Linf2f78f72012-02-08 19:27:11 +080037from itertools import izip, product
Jon Salz14bcbb02012-03-17 15:11:50 +080038from optparse import OptionParser
Hung-Te Linf2f78f72012-02-08 19:27:11 +080039
Hung-Te Lin6bb48552012-02-09 14:37:43 +080040# GTK and X modules
41import gobject
42import gtk
43import pango
44
Tammo Spalinkdb8d7112012-03-13 18:54:37 +080045# Guard loading Xlib because it is currently not available in the
46# image build process host-depends list. Failure to load in
47# production should always manifest during regular use.
48try:
49 from Xlib import X
50 from Xlib.display import Display
51except:
52 pass
53
Hung-Te Lin6bb48552012-02-09 14:37:43 +080054# Factory and autotest modules
Hung-Te Linf2f78f72012-02-08 19:27:11 +080055import factory_common
Hung-Te Linde45e9c2012-03-19 13:02:06 +080056from autotest_lib.client.common_lib import error
Hung-Te Linf2f78f72012-02-08 19:27:11 +080057from autotest_lib.client.cros import factory
58from autotest_lib.client.cros.factory import TestState
59from autotest_lib.client.cros.factory.event import Event, EventClient
60
Hung-Te Lin6bb48552012-02-09 14:37:43 +080061
Hung-Te Linf2f78f72012-02-08 19:27:11 +080062# For compatibility with tests before TestState existed
63ACTIVE = TestState.ACTIVE
64PASSED = TestState.PASSED
65FAILED = TestState.FAILED
66UNTESTED = TestState.UNTESTED
67
Hung-Te Lin6bb48552012-02-09 14:37:43 +080068# Color definition
Hung-Te Linf2f78f72012-02-08 19:27:11 +080069BLACK = gtk.gdk.Color()
70RED = gtk.gdk.Color(0xFFFF, 0, 0)
71GREEN = gtk.gdk.Color(0, 0xFFFF, 0)
72BLUE = gtk.gdk.Color(0, 0, 0xFFFF)
73WHITE = gtk.gdk.Color(0xFFFF, 0xFFFF, 0xFFFF)
Hung-Te Linf2f78f72012-02-08 19:27:11 +080074LIGHT_GREEN = gtk.gdk.color_parse('light green')
Hung-Te Linf2f78f72012-02-08 19:27:11 +080075SEP_COLOR = gtk.gdk.color_parse('grey50')
76
77RGBA_GREEN_OVERLAY = (0, 0.5, 0, 0.6)
78RGBA_YELLOW_OVERLAY = (0.6, 0.6, 0, 0.6)
79
80LABEL_COLORS = {
81 TestState.ACTIVE: gtk.gdk.color_parse('light goldenrod'),
82 TestState.PASSED: gtk.gdk.color_parse('pale green'),
83 TestState.FAILED: gtk.gdk.color_parse('tomato'),
84 TestState.UNTESTED: gtk.gdk.color_parse('dark slate grey')}
85
86LABEL_FONT = pango.FontDescription('courier new condensed 16')
Hung-Te Linbf545582012-02-15 17:08:07 +080087LABEL_LARGE_FONT = pango.FontDescription('courier new condensed 24')
Hung-Te Linf2f78f72012-02-08 19:27:11 +080088
89FAIL_TIMEOUT = 30
90
91USER_PASS_FAIL_SELECT_STR = (
92 'hit TAB to fail and ENTER to pass\n' +
93 '錯誤請按 TAB,成功請按 ENTER')
Chun-Ta Linf33efa72012-03-15 18:56:38 +080094# Resolution where original UI is designed for.
95_UI_SCREEN_WIDTH = 1280
96_UI_SCREEN_HEIGHT = 800
Hung-Te Linf2f78f72012-02-08 19:27:11 +080097
Tai-Hsu Lin606685c2012-03-14 19:10:11 +080098_LABEL_STATUS_ROW_SIZE = (300, 30)
Hung-Te Lin6bb48552012-02-09 14:37:43 +080099_LABEL_EN_SIZE = (170, 35)
100_LABEL_ZH_SIZE = (70, 35)
101_LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
102_LABEL_ZH_FONT = pango.FontDescription('normal 12')
103_LABEL_T_SIZE = (40, 35)
104_LABEL_T_FONT = pango.FontDescription('arial ultra-condensed 10')
105_LABEL_UNTESTED_FG = gtk.gdk.color_parse('grey40')
106_LABEL_TROUGH_COLOR = gtk.gdk.color_parse('grey20')
107_LABEL_STATUS_SIZE = (140, 30)
108_LABEL_STATUS_FONT = pango.FontDescription(
109 'courier new bold extra-condensed 16')
110_OTHER_LABEL_FONT = pango.FontDescription('courier new condensed 20')
111
112_ST_LABEL_EN_SIZE = (250, 35)
113_ST_LABEL_ZH_SIZE = (150, 35)
114
115_NO_ACTIVE_TEST_DELAY_MS = 500
116
Jon Salz0405ab52012-03-16 15:26:52 +0800117GLOBAL_HOT_KEY_EVENTS = {
118 'r': Event.Type.RESTART_TESTS,
119 'a': Event.Type.AUTO_RUN,
120 'f': Event.Type.RE_RUN_FAILED,
121 }
122try:
123 # Works only if X is available.
124 GLOBAL_HOT_KEY_MASK = X.ControlMask | X.Mod1Mask
125except:
126 pass
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800127
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800128# ---------------------------------------------------------------------------
129# Client Library
130
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800131
Hung-Te Lin4b0b61f2012-03-05 14:20:06 +0800132# TODO(hungte) Replace gtk_lock by gtk.gdk.lock when it's availble (need pygtk
133# 2.2x, and we're now pinned by 2.1x)
134class _GtkLock(object):
135 __enter__ = gtk.gdk.threads_enter
136 def __exit__(*ignored):
137 gtk.gdk.threads_leave()
138
139
140gtk_lock = _GtkLock()
141
142
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800143def make_label(message, font=LABEL_FONT, fg=LIGHT_GREEN,
144 size=None, alignment=None):
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800145 """Returns a label widget.
146
147 A wrapper for gtk.Label. The unit of size is pixels under resolution
148 _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
149
150 @param message: A string to be displayed.
151 @param font: Font descriptor for the label.
152 @param fg: Foreground color.
153 @param size: Minimum size for this label.
154 @param alignment: Alignment setting.
155 @return: A label widget.
156 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800157 l = gtk.Label(message)
158 l.modify_font(font)
159 l.modify_fg(gtk.STATE_NORMAL, fg)
160 if size:
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800161 # Convert size according to the current resolution.
162 l.set_size_request(*convert_pixels(size))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800163 if alignment:
164 l.set_alignment(*alignment)
165 return l
166
167
Tai-Hsu Lin606685c2012-03-14 19:10:11 +0800168def make_status_row(init_prompt,
169 init_status,
170 label_size=_LABEL_STATUS_ROW_SIZE):
171 """Returns a widget that live updates prompt and status in a row.
172
173 Args:
174 init_prompt: The prompt label text.
175 init_status: The status label text.
176 label_size: The desired size of the prompt label and the status label.
177
178 Returns:
179 1) A dict whose content is linked by the widget.
180 2) A widget to render dict content in "prompt: status" format.
181 """
182 display_dict = {}
183 display_dict['prompt'] = init_prompt
184 display_dict['status'] = init_status
185
186 def prompt_label_expose(widget, event):
187 prompt = display_dict['prompt']
188 widget.set_text(prompt)
189
190 def status_label_expose(widget, event):
191 status = display_dict['status']
192 widget.set_text(status)
193 widget.modify_fg(gtk.STATE_NORMAL, LABEL_COLORS[status])
194
195 prompt_label = make_label(
196 init_prompt, size=label_size,
197 alignment=(0, 0.5))
198 delimiter_label = make_label(':', alignment=(0, 0.5))
199 status_label = make_label(
200 init_status, size=label_size,
201 alignment=(0, 0.5), fg=LABEL_COLORS[init_status])
202
203 widget = gtk.HBox()
204 widget.pack_end(status_label, False, False)
205 widget.pack_end(delimiter_label, False, False)
206 widget.pack_end(prompt_label, False, False)
207
208 status_label.connect('expose_event', status_label_expose)
209 prompt_label.connect('expose_event', prompt_label_expose)
210 return display_dict, widget
211
212
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800213def convert_pixels(size):
214 """Converts a pair in pixel that is suitable for current resolution.
215
216 GTK takes pixels as its unit in many function calls. To maintain the
217 consistency of the UI in different resolution, a conversion is required.
218 Take current resolution and (_UI_SCREEN_WIDTH, _UI_SCREEN_HEIGHT) as
219 the original resolution, this function returns a pair of width and height
220 that is converted for current resolution.
221
222 Because pixels in negative usually indicates unspecified, no conversion
223 will be done for negative pixels.
224
225 In addition, the aspect ratio is not maintained in this function.
226
227 Usage Example:
228 width,_ = convert_pixels((20,-1))
229
230 @param size: A pair of pixels that designed under original resolution.
231 @return: A pair of pixels of (width, height) format.
232 Pixels returned are always integer.
233 """
234 return (int(float(size[0]) / _UI_SCREEN_WIDTH * gtk.gdk.screen_width()
235 if (size[0] > 0) else size[0]),
236 int(float(size[1]) / _UI_SCREEN_HEIGHT * gtk.gdk.screen_height()
237 if (size[1] > 0) else size[1]))
238
239
240def make_hsep(height=1):
241 """Returns a widget acts as a horizontal separation line.
242
243 The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
244 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800245 frame = gtk.EventBox()
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800246 # Convert height according to the current resolution.
247 frame.set_size_request(*convert_pixels((-1, height)))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800248 frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
249 return frame
250
251
252def make_vsep(width=1):
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800253 """Returns a widget acts as a vertical separation line.
254
255 The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
256 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800257 frame = gtk.EventBox()
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800258 # Convert width according to the current resolution.
259 frame.set_size_request(*convert_pixels((width, -1)))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800260 frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
261 return frame
262
263
Hung-Te Linaf8ec542012-03-14 20:01:40 +0800264def make_countdown_widget(prompt=None, value=None, fg=LIGHT_GREEN):
265 if prompt is None:
266 prompt = 'time remaining / 剩餘時間: '
267 if value is None:
268 value = '%s' % FAIL_TIMEOUT
269 title = make_label(prompt, fg=fg, alignment=(1, 0.5))
270 countdown = make_label(value, fg=fg, alignment=(0, 0.5))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800271 hbox = gtk.HBox()
272 hbox.pack_start(title)
273 hbox.pack_start(countdown)
274 eb = gtk.EventBox()
275 eb.modify_bg(gtk.STATE_NORMAL, BLACK)
276 eb.add(hbox)
277 return eb, countdown
278
279
280def hide_cursor(gdk_window):
281 pixmap = gtk.gdk.Pixmap(None, 1, 1, 1)
282 color = gtk.gdk.Color()
283 cursor = gtk.gdk.Cursor(pixmap, pixmap, color, color, 0, 0)
284 gdk_window.set_cursor(cursor)
285
286
287def calc_scale(wanted_x, wanted_y):
288 (widget_size_x, widget_size_y) = factory.get_shared_data('test_widget_size')
289 scale_x = (0.9 * widget_size_x) / wanted_x
290 scale_y = (0.9 * widget_size_y) / wanted_y
291 scale = scale_y if scale_y < scale_x else scale_x
292 scale = 1 if scale > 1 else scale
293 factory.log('scale: %s' % scale)
294 return scale
295
296
297def trim(text, length):
298 if len(text) > length:
299 text = text[:length-3] + '...'
300 return text
301
302
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800303class InputError(ValueError):
304 """Execption for input window callbacks to change status text message."""
305 pass
306
307
Hung-Te Linbf545582012-02-15 17:08:07 +0800308def make_input_window(prompt=None,
309 init_value=None,
310 msg_invalid=None,
311 font=None,
312 on_validate=None,
313 on_keypress=None,
314 on_complete=None):
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800315 """Creates a widget to prompt user for a valid string.
Hung-Te Linbf545582012-02-15 17:08:07 +0800316
317 @param prompt: A string to be displayed. None for default message.
318 @param init_value: Initial value to be set.
319 @param msg_invalid: Status string to display when input is invalid. None for
320 default message.
321 @param font: Font specification (string or pango.FontDescription) for label
322 and entry. None for default large font.
323 @param on_validate: A callback function to validate if the input from user
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800324 is valid. None for allowing any non-empty input. Any ValueError or
325 ui.InputError raised during execution in on_validate will be displayed
326 in bottom status.
Hung-Te Linbf545582012-02-15 17:08:07 +0800327 @param on_keypress: A callback function when each keystroke is hit.
328 @param on_complete: A callback function when a valid string is passed.
329 None to stop (gtk.main_quit).
330 @return: A widget with prompt, input entry, and status label.
Chun-ta Lin2e7f44c2012-03-03 07:29:36 +0800331 In addition, a method called get_entry() is added to the widget to
332 provide controls on the entry.
Hung-Te Linbf545582012-02-15 17:08:07 +0800333 """
334 DEFAULT_MSG_INVALID = "Invalid input / 輸入不正確"
335 DEFAULT_PROMPT = "Enter Data / 輸入資料:"
336
337 def enter_callback(entry):
338 text = entry.get_text()
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800339 try:
340 if (on_validate and (not on_validate(text))) or (not text.strip()):
341 raise ValueError(msg_invalid)
Hung-Te Linbf545582012-02-15 17:08:07 +0800342 on_complete(text) if on_complete else gtk.main_quit()
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800343 except ValueError as e:
344 gtk.gdk.beep()
345 status_label.set_text('ERROR: %s' % e.message)
Hung-Te Linbf545582012-02-15 17:08:07 +0800346 return True
347
348 def key_press_callback(entry, key):
349 status_label.set_text('')
350 if on_keypress:
351 return on_keypress(entry, key)
352 return False
353
354 # Populate default parameters
355 if msg_invalid is None:
356 msg_invalid = DEFAULT_MSG_INVALID
357
358 if prompt is None:
359 prompt = DEFAULT_PROMPT
360
361 if font is None:
362 font = LABEL_LARGE_FONT
363 elif not isinstance(font, pango.FontDescription):
364 font = pango.FontDescription(font)
365
366 widget = gtk.VBox()
367 label = make_label(prompt, font=font)
368 status_label = make_label('', font=font)
369 entry = gtk.Entry()
370 entry.modify_font(font)
371 entry.connect("activate", enter_callback)
372 entry.connect("key_press_event", key_press_callback)
373 if init_value:
374 entry.set_text(init_value)
375 widget.modify_bg(gtk.STATE_NORMAL, BLACK)
376 status_label.modify_fg(gtk.STATE_NORMAL, RED)
377 widget.add(label)
378 widget.pack_start(entry)
379 widget.pack_start(status_label)
Chun-ta Lin2e7f44c2012-03-03 07:29:36 +0800380
381 # Method for getting the entry.
382 widget.get_entry = lambda : entry
Hung-Te Linbf545582012-02-15 17:08:07 +0800383 return widget
384
385
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800386def make_summary_box(tests, state_map, rows=15):
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800387 '''Creates a widget display status of a set of test.
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800388
389 @param tests: A list of FactoryTest nodes whose status (and children's
390 status) should be displayed.
391 @param state_map: The state map as provide by the state instance.
392 @param rows: The number of rows to display.
393 @return: A tuple (widget, label_map), where widget is the widget, and
394 label_map is a map from each test to the corresponding label.
395 '''
396 LABEL_EN_SIZE = (170, 35)
397 LABEL_EN_SIZE_2 = (450, 25)
398 LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
399
400 all_tests = sum([list(t.walk(in_order=True)) for t in tests], [])
401 columns = len(all_tests) / rows + (len(all_tests) % rows != 0)
402
403 info_box = gtk.HBox()
404 info_box.set_spacing(20)
405 for status in (TestState.ACTIVE, TestState.PASSED,
406 TestState.FAILED, TestState.UNTESTED):
407 label = make_label(status,
408 size=LABEL_EN_SIZE,
409 font=LABEL_EN_FONT,
410 alignment=(0.5, 0.5),
411 fg=LABEL_COLORS[status])
412 info_box.pack_start(label, False, False)
413
414 vbox = gtk.VBox()
415 vbox.set_spacing(20)
416 vbox.pack_start(info_box, False, False)
417
418 label_map = {}
419
420 if all_tests:
421 status_table = gtk.Table(rows, columns, True)
422 for (j, i), t in izip(product(xrange(columns), xrange(rows)),
423 all_tests):
424 msg_en = ' ' * (t.depth() - 1) + t.label_en
425 msg_en = trim(msg_en, 12)
426 if t.label_zh:
427 msg = '{0:<12} ({1})'.format(msg_en, t.label_zh)
428 else:
429 msg = msg_en
430 status = state_map[t].status
431 status_label = make_label(msg,
432 size=LABEL_EN_SIZE_2,
433 font=LABEL_EN_FONT,
434 alignment=(0.0, 0.5),
435 fg=LABEL_COLORS[status])
436 label_map[t] = status_label
437 status_table.attach(status_label, j, j+1, i, i+1)
438 vbox.pack_start(status_table, False, False)
439
440 return vbox, label_map
441
442
443def run_test_widget(dummy_job, test_widget,
444 invisible_cursor=True,
445 window_registration_callback=None,
446 cleanup_callback=None):
447 test_widget_size = factory.get_shared_data('test_widget_size')
448
449 window = gtk.Window(gtk.WINDOW_TOPLEVEL)
450 window.modify_bg(gtk.STATE_NORMAL, BLACK)
451 window.set_size_request(*test_widget_size)
452
453 def show_window():
454 window.show()
455 window.window.raise_() # pylint: disable=E1101
456 gtk.gdk.pointer_grab(window.window, confine_to=window.window)
457 if invisible_cursor:
458 hide_cursor(window.window)
459
460 test_path = factory.get_current_test_path()
461
462 def handle_event(event):
463 if (event.type == Event.Type.STATE_CHANGE and
464 test_path and event.path == test_path and
465 event.state.visible):
466 show_window()
467
468 event_client = EventClient(
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800469 callback=handle_event, event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800470
471 align = gtk.Alignment(xalign=0.5, yalign=0.5)
472 align.add(test_widget)
473
474 window.add(align)
475 for c in window.get_children():
476 # Show all children, but not the window itself yet.
477 c.show_all()
478
479 if window_registration_callback is not None:
480 window_registration_callback(window)
481
482 # Show the window if it is the visible test, or if the test_path is not
483 # available (e.g., run directly from the command line).
484 if (not test_path) or (
485 TestState.from_dict_or_object(
486 factory.get_state_instance().get_test_state(test_path)).visible):
487 show_window()
488 else:
489 window.hide()
490
Hung-Te Linde45e9c2012-03-19 13:02:06 +0800491 # When gtk.main() is running, it ignores all uncaught exceptions, which is
492 # not preferred by most of our factory tests. To prevent writing special
493 # function raising errors, we hook top level exception handler to always
494 # leave GTK main and raise exception again.
495
496 def exception_hook(exc_type, value, traceback):
497 # Prevent re-entrant.
498 sys.excepthook = old_excepthook
499 session['exception'] = (exc_type, value, traceback)
500 gobject.idle_add(gtk.main_quit)
501 return old_excepthook(exc_type, value, traceback)
502
503 session = {}
504 old_excepthook = sys.excepthook
505 sys.excepthook = exception_hook
506
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800507 gtk.main()
508
509 gtk.gdk.pointer_ungrab()
510
511 if cleanup_callback is not None:
512 cleanup_callback()
513
514 del event_client
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800515
Hung-Te Linde45e9c2012-03-19 13:02:06 +0800516 sys.excepthook = old_excepthook
517 exc_info = session.get('exception')
518 if exc_info is not None:
519 logging.error(exc_info[0], exc_info=exc_info)
520 raise error.TestError(exc_info[1])
521
522
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800523
524# ---------------------------------------------------------------------------
525# Server Implementation
526
527
528class Console(object):
529 '''Display a progress log. Implemented by launching an borderless
530 xterm at a strategic location, and running tail against the log.'''
531
532 def __init__(self, allocation):
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800533 # Specify how many lines and characters per line are displayed.
534 XTERM_DISPLAY_LINES = 13
535 XTERM_DISPLAY_CHARS = 120
536 # Extra space reserved for pixels between lines.
537 XTERM_RESERVED_LINES = 3
538
539 xterm_coords = '%dx%d+%d+%d' % (XTERM_DISPLAY_CHARS,
540 XTERM_DISPLAY_LINES,
541 allocation.x,
542 allocation.y)
543 xterm_reserved_height = gtk.gdk.screen_height() - allocation.y
544 font_size = int(float(xterm_reserved_height) / (XTERM_DISPLAY_LINES +
545 XTERM_RESERVED_LINES))
546 logging.info('xterm_reserved_height = %d' % xterm_reserved_height)
547 logging.info('font_size = %d' % font_size)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800548 logging.info('xterm_coords = %s', xterm_coords)
549 xterm_opts = ('-bg black -fg lightgray -bw 0 -g %s' % xterm_coords)
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800550 xterm_cmd = (
551 ['urxvt'] + xterm_opts.split() +
552 ['-fn', 'xft:DejaVu Sans Mono:pixelsize=%s' % font_size] +
553 ['-e', 'bash'] +
554 ['-c', 'tail -f "%s"' % factory.CONSOLE_LOG_PATH])
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800555 logging.info('xterm_cmd = %s', xterm_cmd)
556 self._proc = subprocess.Popen(xterm_cmd)
557
558 def __del__(self):
559 logging.info('console_proc __del__')
560 self._proc.kill()
561
562
563class TestLabelBox(gtk.EventBox): # pylint: disable=R0904
564
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800565 def __init__(self, test):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800566 gtk.EventBox.__init__(self)
567 self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[TestState.UNTESTED])
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800568 depth = len(test.get_ancestor_groups())
569 label_en_text = ' ' + ('..' * depth) + test.label_en
570 self._label_en = make_label(
571 label_en_text, size=_LABEL_EN_SIZE,
572 font=_LABEL_EN_FONT, alignment=(0, 0.5),
573 fg=_LABEL_UNTESTED_FG)
574 self._label_zh = make_label(
575 test.label_zh, size=_LABEL_ZH_SIZE,
576 font=_LABEL_ZH_FONT, alignment=(0.5, 0.5),
577 fg=_LABEL_UNTESTED_FG)
578 self._label_t = make_label(
579 '', size=_LABEL_T_SIZE, font=_LABEL_T_FONT,
580 alignment=(0.5, 0.5), fg=BLACK)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800581 hbox = gtk.HBox()
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800582 hbox.pack_start(self._label_en, False, False)
583 hbox.pack_start(self._label_zh, False, False)
584 hbox.pack_start(self._label_t, False, False)
585 vbox = gtk.VBox()
586 vbox.pack_start(hbox, False, False)
587 vbox.pack_start(make_hsep(), False, False)
588 self.add(vbox)
589 self._status = None
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800590
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800591 def set_shortcut(self, shortcut):
592 if shortcut is None:
593 return
594 self._label_t.set_text('C-%s' % shortcut)
595 attrs = self._label_en.get_attributes() or pango.AttrList()
596 attrs.filter(lambda attr: attr.type == pango.ATTR_UNDERLINE)
597 index_hotkey = self._label_en.get_text().upper().find(shortcut.upper())
598 if index_hotkey != -1:
599 attrs.insert(pango.AttrUnderline(
600 pango.UNDERLINE_LOW, index_hotkey, index_hotkey + 1))
601 attrs.insert(pango.AttrWeight(
602 pango.WEIGHT_BOLD, index_hotkey, index_hotkey + 1))
603 self._label_en.set_attributes(attrs)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800604 self.queue_draw()
605
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800606 def update(self, status):
607 if self._status == status:
608 return
609 self._status = status
610 label_fg = (_LABEL_UNTESTED_FG if status == TestState.UNTESTED
611 else BLACK)
612 for label in [self._label_en, self._label_zh, self._label_t]:
613 label.modify_fg(gtk.STATE_NORMAL, label_fg)
614 self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[status])
615 self.queue_draw()
616
617
618class TestDirectory(gtk.VBox):
619 '''Widget containing a list of tests, colored by test status.
620
621 This is the widget corresponding to the RHS test panel.
622
623 Attributes:
624 _label_map: Dict of test path to TestLabelBox objects. Should
625 contain an entry for each test that has been visible at some
626 time.
627 _visible_status: List of (test, status) pairs reflecting the
628 last refresh of the set of visible tests. This is used to
629 rememeber what tests were active, to allow implementation of
630 visual refresh only when new active tests appear.
631 _shortcut_map: Dict of keyboard shortcut key to test path.
632 Tracks the current set of keyboard shortcut mappings for the
633 visible set of tests. This will change when the visible
634 test set changes.
635 '''
636
637 def __init__(self):
638 gtk.VBox.__init__(self)
639 self.set_spacing(0)
640 self._label_map = {}
641 self._visible_status = []
642 self._shortcut_map = {}
643
644 def _get_test_label(self, test):
645 if test.path in self._label_map:
646 return self._label_map[test.path]
647 label_box = TestLabelBox(test)
648 self._label_map[test.path] = label_box
649 return label_box
650
651 def _remove_shortcut(self, path):
652 reverse_map = dict((v, k) for k, v in self._shortcut_map.items())
653 if path not in reverse_map:
654 logging.error('Removal of non-present shortcut for %s' % path)
655 return
656 shortcut = reverse_map[path]
657 del self._shortcut_map[shortcut]
658
659 def _add_shortcut(self, test):
660 shortcut = test.kbd_shortcut
661 if shortcut in self._shortcut_map:
662 logging.error('Shortcut %s already in use by %s; cannot apply to %s'
663 % (shortcut, self._shortcut_map[shortcut], test.path))
664 shortcut = None
665 if shortcut is None:
666 # Find a suitable shortcut. For groups, use numbers. For
667 # regular tests, use alpha (letters).
Jon Salz0405ab52012-03-16 15:26:52 +0800668 if test.is_group():
669 gen = (x for x in string.digits if x not in self._shortcut_map)
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800670 else:
Jon Salz0405ab52012-03-16 15:26:52 +0800671 gen = (x for x in test.label_en.lower() + string.lowercase
672 if x.isalnum() and x not in self._shortcut_map)
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800673 shortcut = next(gen, None)
674 if shortcut is None:
675 logging.error('Unable to find shortcut for %s' % test.path)
676 return
677 self._shortcut_map[shortcut] = test.path
678 return shortcut
679
680 def handle_xevent(self, dummy_src, dummy_cond,
681 xhandle, keycode_map, event_client):
682 for dummy_i in range(0, xhandle.pending_events()):
683 xevent = xhandle.next_event()
684 if xevent.type != X.KeyPress:
685 continue
686 keycode = xevent.detail
687 if keycode not in keycode_map:
688 logging.warning('Ignoring unknown keycode %r' % keycode)
689 continue
690 shortcut = keycode_map[keycode]
Jon Salz0405ab52012-03-16 15:26:52 +0800691
692 if (xevent.state & GLOBAL_HOT_KEY_MASK ==
693 GLOBAL_HOT_KEY_MASK):
694 event_type = GLOBAL_HOT_KEY_EVENTS.get(shortcut)
695 if event_type:
696 event_client.post_event(Event(event_type))
697 else:
698 logging.warning('Unbound global hot key %s' % key)
699 else:
700 if shortcut not in self._shortcut_map:
701 logging.warning('Ignoring unbound shortcut %r' % shortcut)
702 continue
703 test_path = self._shortcut_map[shortcut]
704 event_client.post_event(Event(Event.Type.SWITCH_TEST,
705 path=test_path))
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800706 return True
707
708 def update(self, new_test_status):
709 '''Refresh the RHS test list to show current status and active groups.
710
711 Refresh the set of visible tests only when new active tests
712 arise. This avoids visual volatility when switching between
713 tests (intervals where no test is active). Also refresh at
714 initial startup.
715
716 Args:
717 new_test_status: A list of (test, status) tuples. The tests
718 order should match how they should be displayed in the
719 directory (rhs panel).
720 '''
721 old_active = set(t for t, s in self._visible_status
722 if s == TestState.ACTIVE)
723 new_active = set(t for t, s in new_test_status
724 if s == TestState.ACTIVE)
725 new_visible = set(t for t, s in new_test_status)
726 old_visible = set(t for t, s in self._visible_status)
727
728 if old_active and not new_active - old_active:
729 # No new active tests, so do not change the displayed test
730 # set, only update the displayed status for currently
731 # visible tests. Not updating _visible_status allows us
732 # to remember the last set of active tests.
733 for test, _ in self._visible_status:
734 status = test.get_state().status
735 self._label_map[test.path].update(status)
736 return
737
738 self._visible_status = new_test_status
739
740 new_test_map = dict((t.path, t) for t, s in new_test_status)
741
742 for test in old_visible - new_visible:
743 label_box = self._label_map[test.path]
744 logging.debug('removing %s test label' % test.path)
745 self.remove(label_box)
746 self._remove_shortcut(test.path)
747
748 new_tests = new_visible - old_visible
749
750 for position, (test, status) in enumerate(new_test_status):
751 label_box = self._get_test_label(test)
752 if test in new_tests:
753 shortcut = self._add_shortcut(test)
754 label_box = self._get_test_label(test)
755 label_box.set_shortcut(shortcut)
756 logging.debug('adding %s test label (sortcut %r, pos %d)' %
757 (test.path, shortcut, position))
758 self.pack_start(label_box, False, False)
759 self.reorder_child(label_box, position)
760 label_box.update(status)
761
762 self.show_all()
763
764
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800765
766class UiState(object):
767
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800768 def __init__(self, test_widget_box, test_directory_widget, test_list):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800769 self._test_widget_box = test_widget_box
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800770 self._test_directory_widget = test_directory_widget
771 self._test_list = test_list
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800772 self._transition_count = 0
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800773 self._active_test_label_map = None
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800774 self.update_test_state()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800775
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800776 def update_test_state(self):
777 state_map = self._test_list.get_state_map()
778 active_tests = set(
779 t for t in self._test_list.walk()
780 if t.is_leaf() and state_map[t].status == TestState.ACTIVE)
781 active_groups = set(g for t in active_tests
782 for g in t.get_ancestor_groups())
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800783
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800784 def filter_visible_test_state(tests):
785 '''List currently visible tests and their status.
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800786
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800787 Visible means currently displayed in the RHS panel.
788 Visiblity is implied by being a top level test or having
789 membership in a group with at least one active test.
790
791 Returns:
792 A list of (test, status) tuples for all visible tests,
793 in the order they should be displayed.
794 '''
795 results = []
796 for test in tests:
Jon Salz0405ab52012-03-16 15:26:52 +0800797 if test.is_group():
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800798 results.append((test, TestState.UNTESTED))
799 if test not in active_groups:
800 continue
801 results += filter_visible_test_state(test.subtests)
802 else:
803 results.append((test, state_map[test].status))
804 return results
805
806 visible_test_state = filter_visible_test_state(self._test_list.subtests)
807 self._test_directory_widget.update(visible_test_state)
808
809 def remove_state_widget():
810 for child in self._test_widget_box.get_children():
811 self._test_widget_box.remove(child)
812 self._active_test_label_map = None
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800813
814 if not active_tests:
815 # Display the "no active tests" widget if there are still no
816 # active tests after _NO_ACTIVE_TEST_DELAY_MS.
817 def run(transition_count):
818 if transition_count != self._transition_count:
819 # Something has happened
820 return False
821
822 self._transition_count += 1
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800823 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800824
825 self._test_widget_box.set(0.5, 0.5, 0.0, 0.0)
826 self._test_widget_box.set_padding(0, 0, 0, 0)
827 label_box = gtk.EventBox()
828 label_box.modify_bg(gtk.STATE_NORMAL, BLACK)
829 label = make_label('no active test', font=_OTHER_LABEL_FONT,
830 alignment=(0.5, 0.5))
831 label_box.add(label)
832 self._test_widget_box.add(label_box)
833 self._test_widget_box.show_all()
834
835 gobject.timeout_add(_NO_ACTIVE_TEST_DELAY_MS, run,
836 self._transition_count)
837 return
838
839 self._transition_count += 1
840
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800841 if any(t.has_ui for t in active_tests):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800842 # Remove the widget (if any) since there is an active test
843 # with a UI.
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800844 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800845 return
846
847 if (self._active_test_label_map is not None and
848 all(t in self._active_test_label_map for t in active_tests)):
849 # All active tests are already present in the summary, so just
850 # update their states.
851 for test, label in self._active_test_label_map.iteritems():
852 label.modify_fg(
853 gtk.STATE_NORMAL,
854 LABEL_COLORS[state_map[test].status])
855 return
856
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800857 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800858 # No active UI; draw summary of current test states
859 self._test_widget_box.set(0.5, 0.0, 0.0, 0.0)
860 self._test_widget_box.set_padding(40, 0, 0, 0)
861 vbox, self._active_test_label_map = make_summary_box(
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800862 [t for t in self._test_list.subtests
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800863 if state_map[t].status == TestState.ACTIVE],
864 state_map)
865 self._test_widget_box.add(vbox)
866 self._test_widget_box.show_all()
867
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800868
869def grab_shortcut_keys(disp, event_handler, event_client):
870 # We want to receive KeyPress events
871 root = disp.screen().root
872 root.change_attributes(event_mask = X.KeyPressMask)
873 shortcut_set = set(string.lowercase + string.digits)
874 keycode_map = {}
875 for mod, shortcut in ([(X.ControlMask, k) for k in shortcut_set] +
Jon Salz0405ab52012-03-16 15:26:52 +0800876 [(GLOBAL_HOT_KEY_MASK, k)
877 for k in GLOBAL_HOT_KEY_EVENTS] +
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800878 [(X.Mod1Mask, 'Tab')]): # Mod1 = Alt
879 keysym = gtk.gdk.keyval_from_name(shortcut)
880 keycode = disp.keysym_to_keycode(keysym)
881 keycode_map[keycode] = shortcut
882 root.grab_key(keycode, mod, 1, X.GrabModeAsync, X.GrabModeAsync)
883 # This flushes the XGrabKey calls to the server.
884 for dummy_x in range(0, root.display.pending_events()):
885 root.display.next_event()
886 gobject.io_add_watch(root.display, gobject.IO_IN, event_handler,
887 root.display, keycode_map, event_client)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800888
889
890def main(test_list_path):
891 '''Starts the main UI.
892
893 This is launched by the autotest/cros/factory/client.
894 When operators press keyboard shortcuts, the shortcut
895 value is sent as an event to the control program.'''
896
897 test_list = None
898 ui_state = None
899 event_client = None
900
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800901 def handle_key_release_event(_, event):
902 logging.info('base ui key event (%s)', event.keyval)
903 return True
904
905 def handle_event(event):
906 if event.type == Event.Type.STATE_CHANGE:
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800907 ui_state.update_test_state()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800908
909 test_list = factory.read_test_list(test_list_path)
910
911 window = gtk.Window(gtk.WINDOW_TOPLEVEL)
912 window.connect('destroy', lambda _: gtk.main_quit())
913 window.modify_bg(gtk.STATE_NORMAL, BLACK)
914
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800915 disp = Display()
916
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800917 event_client = EventClient(
918 callback=handle_event,
919 event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
920
921 screen = window.get_screen()
922 if (screen is None):
923 logging.info('ERROR: communication with the X server is not working, ' +
924 'could not find a working screen. UI exiting.')
925 sys.exit(1)
926
927 screen_size_str = os.environ.get('CROS_SCREEN_SIZE')
928 if screen_size_str:
929 match = re.match(r'^(\d+)x(\d+)$', screen_size_str)
930 assert match, 'CROS_SCREEN_SIZE should be {width}x{height}'
931 screen_size = (int(match.group(1)), int(match.group(2)))
932 else:
933 screen_size = (screen.get_width(), screen.get_height())
934 window.set_size_request(*screen_size)
935
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800936 test_directory = TestDirectory()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800937
938 rhs_box = gtk.EventBox()
939 rhs_box.modify_bg(gtk.STATE_NORMAL, _LABEL_TROUGH_COLOR)
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800940 rhs_box.add(test_directory)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800941
942 console_box = gtk.EventBox()
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800943 console_box.set_size_request(*convert_pixels((-1, 180)))
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800944 console_box.modify_bg(gtk.STATE_NORMAL, BLACK)
945
946 test_widget_box = gtk.Alignment()
947 test_widget_box.set_size_request(-1, -1)
948
949 lhs_box = gtk.VBox()
950 lhs_box.pack_end(console_box, False, False)
951 lhs_box.pack_start(test_widget_box)
952 lhs_box.pack_start(make_hsep(3), False, False)
953
954 base_box = gtk.HBox()
955 base_box.pack_end(rhs_box, False, False)
956 base_box.pack_end(make_vsep(3), False, False)
957 base_box.pack_start(lhs_box)
958
959 window.connect('key-release-event', handle_key_release_event)
960 window.add_events(gtk.gdk.KEY_RELEASE_MASK)
961
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800962 ui_state = UiState(test_widget_box, test_directory, test_list)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800963
964 window.add(base_box)
965 window.show_all()
966
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800967 grab_shortcut_keys(disp, test_directory.handle_xevent, event_client)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800968
969 hide_cursor(window.window)
970
971 test_widget_allocation = test_widget_box.get_allocation()
972 test_widget_size = (test_widget_allocation.width,
973 test_widget_allocation.height)
974 factory.set_shared_data('test_widget_size', test_widget_size)
975
976 dummy_console = Console(console_box.get_allocation())
977
978 event_client.post_event(Event(Event.Type.UI_READY))
979
980 logging.info('cros/factory/ui setup done, starting gtk.main()...')
981 gtk.main()
982 logging.info('cros/factory/ui gtk.main() finished, exiting.')
983
984
985if __name__ == '__main__':
Jon Salz14bcbb02012-03-17 15:11:50 +0800986 parser = OptionParser(usage='usage: %prog [options] TEST-LIST-PATH')
987 parser.add_option('-v', '--verbose', dest='verbose',
988 action='store_true',
989 help='Enable debug logging')
990 (options, args) = parser.parse_args()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800991
Jon Salz14bcbb02012-03-17 15:11:50 +0800992 if len(args) != 1:
993 parser.error('Incorrect number of arguments')
994
995 factory.init_logging('ui', verbose=options.verbose)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800996 main(sys.argv[1])