blob: a22bd8b4dfb6d59125918369f561a4f768fbddfc [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
56from autotest_lib.client.cros import factory
57from autotest_lib.client.cros.factory import TestState
58from autotest_lib.client.cros.factory.event import Event, EventClient
59
Hung-Te Lin6bb48552012-02-09 14:37:43 +080060
Hung-Te Linf2f78f72012-02-08 19:27:11 +080061# For compatibility with tests before TestState existed
62ACTIVE = TestState.ACTIVE
63PASSED = TestState.PASSED
64FAILED = TestState.FAILED
65UNTESTED = TestState.UNTESTED
66
Hung-Te Lin6bb48552012-02-09 14:37:43 +080067# Color definition
Hung-Te Linf2f78f72012-02-08 19:27:11 +080068BLACK = gtk.gdk.Color()
69RED = gtk.gdk.Color(0xFFFF, 0, 0)
70GREEN = gtk.gdk.Color(0, 0xFFFF, 0)
71BLUE = gtk.gdk.Color(0, 0, 0xFFFF)
72WHITE = gtk.gdk.Color(0xFFFF, 0xFFFF, 0xFFFF)
Hung-Te Linf2f78f72012-02-08 19:27:11 +080073LIGHT_GREEN = gtk.gdk.color_parse('light green')
Hung-Te Linf2f78f72012-02-08 19:27:11 +080074SEP_COLOR = gtk.gdk.color_parse('grey50')
75
76RGBA_GREEN_OVERLAY = (0, 0.5, 0, 0.6)
77RGBA_YELLOW_OVERLAY = (0.6, 0.6, 0, 0.6)
78
79LABEL_COLORS = {
80 TestState.ACTIVE: gtk.gdk.color_parse('light goldenrod'),
81 TestState.PASSED: gtk.gdk.color_parse('pale green'),
82 TestState.FAILED: gtk.gdk.color_parse('tomato'),
83 TestState.UNTESTED: gtk.gdk.color_parse('dark slate grey')}
84
85LABEL_FONT = pango.FontDescription('courier new condensed 16')
Hung-Te Linbf545582012-02-15 17:08:07 +080086LABEL_LARGE_FONT = pango.FontDescription('courier new condensed 24')
Hung-Te Linf2f78f72012-02-08 19:27:11 +080087
88FAIL_TIMEOUT = 30
89
90USER_PASS_FAIL_SELECT_STR = (
91 'hit TAB to fail and ENTER to pass\n' +
92 '錯誤請按 TAB,成功請按 ENTER')
Chun-Ta Linf33efa72012-03-15 18:56:38 +080093# Resolution where original UI is designed for.
94_UI_SCREEN_WIDTH = 1280
95_UI_SCREEN_HEIGHT = 800
Hung-Te Linf2f78f72012-02-08 19:27:11 +080096
Tai-Hsu Lin606685c2012-03-14 19:10:11 +080097_LABEL_STATUS_ROW_SIZE = (300, 30)
Hung-Te Lin6bb48552012-02-09 14:37:43 +080098_LABEL_EN_SIZE = (170, 35)
99_LABEL_ZH_SIZE = (70, 35)
100_LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
101_LABEL_ZH_FONT = pango.FontDescription('normal 12')
102_LABEL_T_SIZE = (40, 35)
103_LABEL_T_FONT = pango.FontDescription('arial ultra-condensed 10')
104_LABEL_UNTESTED_FG = gtk.gdk.color_parse('grey40')
105_LABEL_TROUGH_COLOR = gtk.gdk.color_parse('grey20')
106_LABEL_STATUS_SIZE = (140, 30)
107_LABEL_STATUS_FONT = pango.FontDescription(
108 'courier new bold extra-condensed 16')
109_OTHER_LABEL_FONT = pango.FontDescription('courier new condensed 20')
110
111_ST_LABEL_EN_SIZE = (250, 35)
112_ST_LABEL_ZH_SIZE = (150, 35)
113
114_NO_ACTIVE_TEST_DELAY_MS = 500
115
Jon Salz0405ab52012-03-16 15:26:52 +0800116GLOBAL_HOT_KEY_EVENTS = {
117 'r': Event.Type.RESTART_TESTS,
118 'a': Event.Type.AUTO_RUN,
119 'f': Event.Type.RE_RUN_FAILED,
120 }
121try:
122 # Works only if X is available.
123 GLOBAL_HOT_KEY_MASK = X.ControlMask | X.Mod1Mask
124except:
125 pass
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800126
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800127# ---------------------------------------------------------------------------
128# Client Library
129
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800130
Hung-Te Lin4b0b61f2012-03-05 14:20:06 +0800131# TODO(hungte) Replace gtk_lock by gtk.gdk.lock when it's availble (need pygtk
132# 2.2x, and we're now pinned by 2.1x)
133class _GtkLock(object):
134 __enter__ = gtk.gdk.threads_enter
135 def __exit__(*ignored):
136 gtk.gdk.threads_leave()
137
138
139gtk_lock = _GtkLock()
140
141
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800142def make_label(message, font=LABEL_FONT, fg=LIGHT_GREEN,
143 size=None, alignment=None):
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800144 """Returns a label widget.
145
146 A wrapper for gtk.Label. The unit of size is pixels under resolution
147 _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
148
149 @param message: A string to be displayed.
150 @param font: Font descriptor for the label.
151 @param fg: Foreground color.
152 @param size: Minimum size for this label.
153 @param alignment: Alignment setting.
154 @return: A label widget.
155 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800156 l = gtk.Label(message)
157 l.modify_font(font)
158 l.modify_fg(gtk.STATE_NORMAL, fg)
159 if size:
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800160 # Convert size according to the current resolution.
161 l.set_size_request(*convert_pixels(size))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800162 if alignment:
163 l.set_alignment(*alignment)
164 return l
165
166
Tai-Hsu Lin606685c2012-03-14 19:10:11 +0800167def make_status_row(init_prompt,
168 init_status,
169 label_size=_LABEL_STATUS_ROW_SIZE):
170 """Returns a widget that live updates prompt and status in a row.
171
172 Args:
173 init_prompt: The prompt label text.
174 init_status: The status label text.
175 label_size: The desired size of the prompt label and the status label.
176
177 Returns:
178 1) A dict whose content is linked by the widget.
179 2) A widget to render dict content in "prompt: status" format.
180 """
181 display_dict = {}
182 display_dict['prompt'] = init_prompt
183 display_dict['status'] = init_status
184
185 def prompt_label_expose(widget, event):
186 prompt = display_dict['prompt']
187 widget.set_text(prompt)
188
189 def status_label_expose(widget, event):
190 status = display_dict['status']
191 widget.set_text(status)
192 widget.modify_fg(gtk.STATE_NORMAL, LABEL_COLORS[status])
193
194 prompt_label = make_label(
195 init_prompt, size=label_size,
196 alignment=(0, 0.5))
197 delimiter_label = make_label(':', alignment=(0, 0.5))
198 status_label = make_label(
199 init_status, size=label_size,
200 alignment=(0, 0.5), fg=LABEL_COLORS[init_status])
201
202 widget = gtk.HBox()
203 widget.pack_end(status_label, False, False)
204 widget.pack_end(delimiter_label, False, False)
205 widget.pack_end(prompt_label, False, False)
206
207 status_label.connect('expose_event', status_label_expose)
208 prompt_label.connect('expose_event', prompt_label_expose)
209 return display_dict, widget
210
211
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800212def convert_pixels(size):
213 """Converts a pair in pixel that is suitable for current resolution.
214
215 GTK takes pixels as its unit in many function calls. To maintain the
216 consistency of the UI in different resolution, a conversion is required.
217 Take current resolution and (_UI_SCREEN_WIDTH, _UI_SCREEN_HEIGHT) as
218 the original resolution, this function returns a pair of width and height
219 that is converted for current resolution.
220
221 Because pixels in negative usually indicates unspecified, no conversion
222 will be done for negative pixels.
223
224 In addition, the aspect ratio is not maintained in this function.
225
226 Usage Example:
227 width,_ = convert_pixels((20,-1))
228
229 @param size: A pair of pixels that designed under original resolution.
230 @return: A pair of pixels of (width, height) format.
231 Pixels returned are always integer.
232 """
233 return (int(float(size[0]) / _UI_SCREEN_WIDTH * gtk.gdk.screen_width()
234 if (size[0] > 0) else size[0]),
235 int(float(size[1]) / _UI_SCREEN_HEIGHT * gtk.gdk.screen_height()
236 if (size[1] > 0) else size[1]))
237
238
239def make_hsep(height=1):
240 """Returns a widget acts as a horizontal separation line.
241
242 The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
243 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800244 frame = gtk.EventBox()
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800245 # Convert height according to the current resolution.
246 frame.set_size_request(*convert_pixels((-1, height)))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800247 frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
248 return frame
249
250
251def make_vsep(width=1):
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800252 """Returns a widget acts as a vertical separation line.
253
254 The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
255 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800256 frame = gtk.EventBox()
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800257 # Convert width according to the current resolution.
258 frame.set_size_request(*convert_pixels((width, -1)))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800259 frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
260 return frame
261
262
Hung-Te Linaf8ec542012-03-14 20:01:40 +0800263def make_countdown_widget(prompt=None, value=None, fg=LIGHT_GREEN):
264 if prompt is None:
265 prompt = 'time remaining / 剩餘時間: '
266 if value is None:
267 value = '%s' % FAIL_TIMEOUT
268 title = make_label(prompt, fg=fg, alignment=(1, 0.5))
269 countdown = make_label(value, fg=fg, alignment=(0, 0.5))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800270 hbox = gtk.HBox()
271 hbox.pack_start(title)
272 hbox.pack_start(countdown)
273 eb = gtk.EventBox()
274 eb.modify_bg(gtk.STATE_NORMAL, BLACK)
275 eb.add(hbox)
276 return eb, countdown
277
278
279def hide_cursor(gdk_window):
280 pixmap = gtk.gdk.Pixmap(None, 1, 1, 1)
281 color = gtk.gdk.Color()
282 cursor = gtk.gdk.Cursor(pixmap, pixmap, color, color, 0, 0)
283 gdk_window.set_cursor(cursor)
284
285
286def calc_scale(wanted_x, wanted_y):
287 (widget_size_x, widget_size_y) = factory.get_shared_data('test_widget_size')
288 scale_x = (0.9 * widget_size_x) / wanted_x
289 scale_y = (0.9 * widget_size_y) / wanted_y
290 scale = scale_y if scale_y < scale_x else scale_x
291 scale = 1 if scale > 1 else scale
292 factory.log('scale: %s' % scale)
293 return scale
294
295
296def trim(text, length):
297 if len(text) > length:
298 text = text[:length-3] + '...'
299 return text
300
301
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800302class InputError(ValueError):
303 """Execption for input window callbacks to change status text message."""
304 pass
305
306
Hung-Te Linbf545582012-02-15 17:08:07 +0800307def make_input_window(prompt=None,
308 init_value=None,
309 msg_invalid=None,
310 font=None,
311 on_validate=None,
312 on_keypress=None,
313 on_complete=None):
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800314 """Creates a widget to prompt user for a valid string.
Hung-Te Linbf545582012-02-15 17:08:07 +0800315
316 @param prompt: A string to be displayed. None for default message.
317 @param init_value: Initial value to be set.
318 @param msg_invalid: Status string to display when input is invalid. None for
319 default message.
320 @param font: Font specification (string or pango.FontDescription) for label
321 and entry. None for default large font.
322 @param on_validate: A callback function to validate if the input from user
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800323 is valid. None for allowing any non-empty input. Any ValueError or
324 ui.InputError raised during execution in on_validate will be displayed
325 in bottom status.
Hung-Te Linbf545582012-02-15 17:08:07 +0800326 @param on_keypress: A callback function when each keystroke is hit.
327 @param on_complete: A callback function when a valid string is passed.
328 None to stop (gtk.main_quit).
329 @return: A widget with prompt, input entry, and status label.
Chun-ta Lin2e7f44c2012-03-03 07:29:36 +0800330 In addition, a method called get_entry() is added to the widget to
331 provide controls on the entry.
Hung-Te Linbf545582012-02-15 17:08:07 +0800332 """
333 DEFAULT_MSG_INVALID = "Invalid input / 輸入不正確"
334 DEFAULT_PROMPT = "Enter Data / 輸入資料:"
335
336 def enter_callback(entry):
337 text = entry.get_text()
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800338 try:
339 if (on_validate and (not on_validate(text))) or (not text.strip()):
340 raise ValueError(msg_invalid)
Hung-Te Linbf545582012-02-15 17:08:07 +0800341 on_complete(text) if on_complete else gtk.main_quit()
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800342 except ValueError as e:
343 gtk.gdk.beep()
344 status_label.set_text('ERROR: %s' % e.message)
Hung-Te Linbf545582012-02-15 17:08:07 +0800345 return True
346
347 def key_press_callback(entry, key):
348 status_label.set_text('')
349 if on_keypress:
350 return on_keypress(entry, key)
351 return False
352
353 # Populate default parameters
354 if msg_invalid is None:
355 msg_invalid = DEFAULT_MSG_INVALID
356
357 if prompt is None:
358 prompt = DEFAULT_PROMPT
359
360 if font is None:
361 font = LABEL_LARGE_FONT
362 elif not isinstance(font, pango.FontDescription):
363 font = pango.FontDescription(font)
364
365 widget = gtk.VBox()
366 label = make_label(prompt, font=font)
367 status_label = make_label('', font=font)
368 entry = gtk.Entry()
369 entry.modify_font(font)
370 entry.connect("activate", enter_callback)
371 entry.connect("key_press_event", key_press_callback)
372 if init_value:
373 entry.set_text(init_value)
374 widget.modify_bg(gtk.STATE_NORMAL, BLACK)
375 status_label.modify_fg(gtk.STATE_NORMAL, RED)
376 widget.add(label)
377 widget.pack_start(entry)
378 widget.pack_start(status_label)
Chun-ta Lin2e7f44c2012-03-03 07:29:36 +0800379
380 # Method for getting the entry.
381 widget.get_entry = lambda : entry
Hung-Te Linbf545582012-02-15 17:08:07 +0800382 return widget
383
384
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800385def make_summary_box(tests, state_map, rows=15):
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800386 '''Creates a widget display status of a set of test.
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800387
388 @param tests: A list of FactoryTest nodes whose status (and children's
389 status) should be displayed.
390 @param state_map: The state map as provide by the state instance.
391 @param rows: The number of rows to display.
392 @return: A tuple (widget, label_map), where widget is the widget, and
393 label_map is a map from each test to the corresponding label.
394 '''
395 LABEL_EN_SIZE = (170, 35)
396 LABEL_EN_SIZE_2 = (450, 25)
397 LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
398
399 all_tests = sum([list(t.walk(in_order=True)) for t in tests], [])
400 columns = len(all_tests) / rows + (len(all_tests) % rows != 0)
401
402 info_box = gtk.HBox()
403 info_box.set_spacing(20)
404 for status in (TestState.ACTIVE, TestState.PASSED,
405 TestState.FAILED, TestState.UNTESTED):
406 label = make_label(status,
407 size=LABEL_EN_SIZE,
408 font=LABEL_EN_FONT,
409 alignment=(0.5, 0.5),
410 fg=LABEL_COLORS[status])
411 info_box.pack_start(label, False, False)
412
413 vbox = gtk.VBox()
414 vbox.set_spacing(20)
415 vbox.pack_start(info_box, False, False)
416
417 label_map = {}
418
419 if all_tests:
420 status_table = gtk.Table(rows, columns, True)
421 for (j, i), t in izip(product(xrange(columns), xrange(rows)),
422 all_tests):
423 msg_en = ' ' * (t.depth() - 1) + t.label_en
424 msg_en = trim(msg_en, 12)
425 if t.label_zh:
426 msg = '{0:<12} ({1})'.format(msg_en, t.label_zh)
427 else:
428 msg = msg_en
429 status = state_map[t].status
430 status_label = make_label(msg,
431 size=LABEL_EN_SIZE_2,
432 font=LABEL_EN_FONT,
433 alignment=(0.0, 0.5),
434 fg=LABEL_COLORS[status])
435 label_map[t] = status_label
436 status_table.attach(status_label, j, j+1, i, i+1)
437 vbox.pack_start(status_table, False, False)
438
439 return vbox, label_map
440
441
442def run_test_widget(dummy_job, test_widget,
443 invisible_cursor=True,
444 window_registration_callback=None,
445 cleanup_callback=None):
446 test_widget_size = factory.get_shared_data('test_widget_size')
447
448 window = gtk.Window(gtk.WINDOW_TOPLEVEL)
449 window.modify_bg(gtk.STATE_NORMAL, BLACK)
450 window.set_size_request(*test_widget_size)
451
452 def show_window():
453 window.show()
454 window.window.raise_() # pylint: disable=E1101
455 gtk.gdk.pointer_grab(window.window, confine_to=window.window)
456 if invisible_cursor:
457 hide_cursor(window.window)
458
459 test_path = factory.get_current_test_path()
460
461 def handle_event(event):
462 if (event.type == Event.Type.STATE_CHANGE and
463 test_path and event.path == test_path and
464 event.state.visible):
465 show_window()
466
467 event_client = EventClient(
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800468 callback=handle_event, event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800469
470 align = gtk.Alignment(xalign=0.5, yalign=0.5)
471 align.add(test_widget)
472
473 window.add(align)
474 for c in window.get_children():
475 # Show all children, but not the window itself yet.
476 c.show_all()
477
478 if window_registration_callback is not None:
479 window_registration_callback(window)
480
481 # Show the window if it is the visible test, or if the test_path is not
482 # available (e.g., run directly from the command line).
483 if (not test_path) or (
484 TestState.from_dict_or_object(
485 factory.get_state_instance().get_test_state(test_path)).visible):
486 show_window()
487 else:
488 window.hide()
489
490 gtk.main()
491
492 gtk.gdk.pointer_ungrab()
493
494 if cleanup_callback is not None:
495 cleanup_callback()
496
497 del event_client
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800498
499
500# ---------------------------------------------------------------------------
501# Server Implementation
502
503
504class Console(object):
505 '''Display a progress log. Implemented by launching an borderless
506 xterm at a strategic location, and running tail against the log.'''
507
508 def __init__(self, allocation):
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800509 # Specify how many lines and characters per line are displayed.
510 XTERM_DISPLAY_LINES = 13
511 XTERM_DISPLAY_CHARS = 120
512 # Extra space reserved for pixels between lines.
513 XTERM_RESERVED_LINES = 3
514
515 xterm_coords = '%dx%d+%d+%d' % (XTERM_DISPLAY_CHARS,
516 XTERM_DISPLAY_LINES,
517 allocation.x,
518 allocation.y)
519 xterm_reserved_height = gtk.gdk.screen_height() - allocation.y
520 font_size = int(float(xterm_reserved_height) / (XTERM_DISPLAY_LINES +
521 XTERM_RESERVED_LINES))
522 logging.info('xterm_reserved_height = %d' % xterm_reserved_height)
523 logging.info('font_size = %d' % font_size)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800524 logging.info('xterm_coords = %s', xterm_coords)
525 xterm_opts = ('-bg black -fg lightgray -bw 0 -g %s' % xterm_coords)
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800526 xterm_cmd = (
527 ['urxvt'] + xterm_opts.split() +
528 ['-fn', 'xft:DejaVu Sans Mono:pixelsize=%s' % font_size] +
529 ['-e', 'bash'] +
530 ['-c', 'tail -f "%s"' % factory.CONSOLE_LOG_PATH])
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800531 logging.info('xterm_cmd = %s', xterm_cmd)
532 self._proc = subprocess.Popen(xterm_cmd)
533
534 def __del__(self):
535 logging.info('console_proc __del__')
536 self._proc.kill()
537
538
539class TestLabelBox(gtk.EventBox): # pylint: disable=R0904
540
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800541 def __init__(self, test):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800542 gtk.EventBox.__init__(self)
543 self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[TestState.UNTESTED])
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800544 depth = len(test.get_ancestor_groups())
545 label_en_text = ' ' + ('..' * depth) + test.label_en
546 self._label_en = make_label(
547 label_en_text, size=_LABEL_EN_SIZE,
548 font=_LABEL_EN_FONT, alignment=(0, 0.5),
549 fg=_LABEL_UNTESTED_FG)
550 self._label_zh = make_label(
551 test.label_zh, size=_LABEL_ZH_SIZE,
552 font=_LABEL_ZH_FONT, alignment=(0.5, 0.5),
553 fg=_LABEL_UNTESTED_FG)
554 self._label_t = make_label(
555 '', size=_LABEL_T_SIZE, font=_LABEL_T_FONT,
556 alignment=(0.5, 0.5), fg=BLACK)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800557 hbox = gtk.HBox()
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800558 hbox.pack_start(self._label_en, False, False)
559 hbox.pack_start(self._label_zh, False, False)
560 hbox.pack_start(self._label_t, False, False)
561 vbox = gtk.VBox()
562 vbox.pack_start(hbox, False, False)
563 vbox.pack_start(make_hsep(), False, False)
564 self.add(vbox)
565 self._status = None
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800566
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800567 def set_shortcut(self, shortcut):
568 if shortcut is None:
569 return
570 self._label_t.set_text('C-%s' % shortcut)
571 attrs = self._label_en.get_attributes() or pango.AttrList()
572 attrs.filter(lambda attr: attr.type == pango.ATTR_UNDERLINE)
573 index_hotkey = self._label_en.get_text().upper().find(shortcut.upper())
574 if index_hotkey != -1:
575 attrs.insert(pango.AttrUnderline(
576 pango.UNDERLINE_LOW, index_hotkey, index_hotkey + 1))
577 attrs.insert(pango.AttrWeight(
578 pango.WEIGHT_BOLD, index_hotkey, index_hotkey + 1))
579 self._label_en.set_attributes(attrs)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800580 self.queue_draw()
581
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800582 def update(self, status):
583 if self._status == status:
584 return
585 self._status = status
586 label_fg = (_LABEL_UNTESTED_FG if status == TestState.UNTESTED
587 else BLACK)
588 for label in [self._label_en, self._label_zh, self._label_t]:
589 label.modify_fg(gtk.STATE_NORMAL, label_fg)
590 self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[status])
591 self.queue_draw()
592
593
594class TestDirectory(gtk.VBox):
595 '''Widget containing a list of tests, colored by test status.
596
597 This is the widget corresponding to the RHS test panel.
598
599 Attributes:
600 _label_map: Dict of test path to TestLabelBox objects. Should
601 contain an entry for each test that has been visible at some
602 time.
603 _visible_status: List of (test, status) pairs reflecting the
604 last refresh of the set of visible tests. This is used to
605 rememeber what tests were active, to allow implementation of
606 visual refresh only when new active tests appear.
607 _shortcut_map: Dict of keyboard shortcut key to test path.
608 Tracks the current set of keyboard shortcut mappings for the
609 visible set of tests. This will change when the visible
610 test set changes.
611 '''
612
613 def __init__(self):
614 gtk.VBox.__init__(self)
615 self.set_spacing(0)
616 self._label_map = {}
617 self._visible_status = []
618 self._shortcut_map = {}
619
620 def _get_test_label(self, test):
621 if test.path in self._label_map:
622 return self._label_map[test.path]
623 label_box = TestLabelBox(test)
624 self._label_map[test.path] = label_box
625 return label_box
626
627 def _remove_shortcut(self, path):
628 reverse_map = dict((v, k) for k, v in self._shortcut_map.items())
629 if path not in reverse_map:
630 logging.error('Removal of non-present shortcut for %s' % path)
631 return
632 shortcut = reverse_map[path]
633 del self._shortcut_map[shortcut]
634
635 def _add_shortcut(self, test):
636 shortcut = test.kbd_shortcut
637 if shortcut in self._shortcut_map:
638 logging.error('Shortcut %s already in use by %s; cannot apply to %s'
639 % (shortcut, self._shortcut_map[shortcut], test.path))
640 shortcut = None
641 if shortcut is None:
642 # Find a suitable shortcut. For groups, use numbers. For
643 # regular tests, use alpha (letters).
Jon Salz0405ab52012-03-16 15:26:52 +0800644 if test.is_group():
645 gen = (x for x in string.digits if x not in self._shortcut_map)
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800646 else:
Jon Salz0405ab52012-03-16 15:26:52 +0800647 gen = (x for x in test.label_en.lower() + string.lowercase
648 if x.isalnum() and x not in self._shortcut_map)
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800649 shortcut = next(gen, None)
650 if shortcut is None:
651 logging.error('Unable to find shortcut for %s' % test.path)
652 return
653 self._shortcut_map[shortcut] = test.path
654 return shortcut
655
656 def handle_xevent(self, dummy_src, dummy_cond,
657 xhandle, keycode_map, event_client):
658 for dummy_i in range(0, xhandle.pending_events()):
659 xevent = xhandle.next_event()
660 if xevent.type != X.KeyPress:
661 continue
662 keycode = xevent.detail
663 if keycode not in keycode_map:
664 logging.warning('Ignoring unknown keycode %r' % keycode)
665 continue
666 shortcut = keycode_map[keycode]
Jon Salz0405ab52012-03-16 15:26:52 +0800667
668 if (xevent.state & GLOBAL_HOT_KEY_MASK ==
669 GLOBAL_HOT_KEY_MASK):
670 event_type = GLOBAL_HOT_KEY_EVENTS.get(shortcut)
671 if event_type:
672 event_client.post_event(Event(event_type))
673 else:
674 logging.warning('Unbound global hot key %s' % key)
675 else:
676 if shortcut not in self._shortcut_map:
677 logging.warning('Ignoring unbound shortcut %r' % shortcut)
678 continue
679 test_path = self._shortcut_map[shortcut]
680 event_client.post_event(Event(Event.Type.SWITCH_TEST,
681 path=test_path))
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800682 return True
683
684 def update(self, new_test_status):
685 '''Refresh the RHS test list to show current status and active groups.
686
687 Refresh the set of visible tests only when new active tests
688 arise. This avoids visual volatility when switching between
689 tests (intervals where no test is active). Also refresh at
690 initial startup.
691
692 Args:
693 new_test_status: A list of (test, status) tuples. The tests
694 order should match how they should be displayed in the
695 directory (rhs panel).
696 '''
697 old_active = set(t for t, s in self._visible_status
698 if s == TestState.ACTIVE)
699 new_active = set(t for t, s in new_test_status
700 if s == TestState.ACTIVE)
701 new_visible = set(t for t, s in new_test_status)
702 old_visible = set(t for t, s in self._visible_status)
703
704 if old_active and not new_active - old_active:
705 # No new active tests, so do not change the displayed test
706 # set, only update the displayed status for currently
707 # visible tests. Not updating _visible_status allows us
708 # to remember the last set of active tests.
709 for test, _ in self._visible_status:
710 status = test.get_state().status
711 self._label_map[test.path].update(status)
712 return
713
714 self._visible_status = new_test_status
715
716 new_test_map = dict((t.path, t) for t, s in new_test_status)
717
718 for test in old_visible - new_visible:
719 label_box = self._label_map[test.path]
720 logging.debug('removing %s test label' % test.path)
721 self.remove(label_box)
722 self._remove_shortcut(test.path)
723
724 new_tests = new_visible - old_visible
725
726 for position, (test, status) in enumerate(new_test_status):
727 label_box = self._get_test_label(test)
728 if test in new_tests:
729 shortcut = self._add_shortcut(test)
730 label_box = self._get_test_label(test)
731 label_box.set_shortcut(shortcut)
732 logging.debug('adding %s test label (sortcut %r, pos %d)' %
733 (test.path, shortcut, position))
734 self.pack_start(label_box, False, False)
735 self.reorder_child(label_box, position)
736 label_box.update(status)
737
738 self.show_all()
739
740
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800741
742class UiState(object):
743
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800744 def __init__(self, test_widget_box, test_directory_widget, test_list):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800745 self._test_widget_box = test_widget_box
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800746 self._test_directory_widget = test_directory_widget
747 self._test_list = test_list
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800748 self._transition_count = 0
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800749 self._active_test_label_map = None
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800750 self.update_test_state()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800751
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800752 def update_test_state(self):
753 state_map = self._test_list.get_state_map()
754 active_tests = set(
755 t for t in self._test_list.walk()
756 if t.is_leaf() and state_map[t].status == TestState.ACTIVE)
757 active_groups = set(g for t in active_tests
758 for g in t.get_ancestor_groups())
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800759
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800760 def filter_visible_test_state(tests):
761 '''List currently visible tests and their status.
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800762
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800763 Visible means currently displayed in the RHS panel.
764 Visiblity is implied by being a top level test or having
765 membership in a group with at least one active test.
766
767 Returns:
768 A list of (test, status) tuples for all visible tests,
769 in the order they should be displayed.
770 '''
771 results = []
772 for test in tests:
Jon Salz0405ab52012-03-16 15:26:52 +0800773 if test.is_group():
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800774 results.append((test, TestState.UNTESTED))
775 if test not in active_groups:
776 continue
777 results += filter_visible_test_state(test.subtests)
778 else:
779 results.append((test, state_map[test].status))
780 return results
781
782 visible_test_state = filter_visible_test_state(self._test_list.subtests)
783 self._test_directory_widget.update(visible_test_state)
784
785 def remove_state_widget():
786 for child in self._test_widget_box.get_children():
787 self._test_widget_box.remove(child)
788 self._active_test_label_map = None
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800789
790 if not active_tests:
791 # Display the "no active tests" widget if there are still no
792 # active tests after _NO_ACTIVE_TEST_DELAY_MS.
793 def run(transition_count):
794 if transition_count != self._transition_count:
795 # Something has happened
796 return False
797
798 self._transition_count += 1
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800799 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800800
801 self._test_widget_box.set(0.5, 0.5, 0.0, 0.0)
802 self._test_widget_box.set_padding(0, 0, 0, 0)
803 label_box = gtk.EventBox()
804 label_box.modify_bg(gtk.STATE_NORMAL, BLACK)
805 label = make_label('no active test', font=_OTHER_LABEL_FONT,
806 alignment=(0.5, 0.5))
807 label_box.add(label)
808 self._test_widget_box.add(label_box)
809 self._test_widget_box.show_all()
810
811 gobject.timeout_add(_NO_ACTIVE_TEST_DELAY_MS, run,
812 self._transition_count)
813 return
814
815 self._transition_count += 1
816
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800817 if any(t.has_ui for t in active_tests):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800818 # Remove the widget (if any) since there is an active test
819 # with a UI.
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800820 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800821 return
822
823 if (self._active_test_label_map is not None and
824 all(t in self._active_test_label_map for t in active_tests)):
825 # All active tests are already present in the summary, so just
826 # update their states.
827 for test, label in self._active_test_label_map.iteritems():
828 label.modify_fg(
829 gtk.STATE_NORMAL,
830 LABEL_COLORS[state_map[test].status])
831 return
832
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800833 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800834 # No active UI; draw summary of current test states
835 self._test_widget_box.set(0.5, 0.0, 0.0, 0.0)
836 self._test_widget_box.set_padding(40, 0, 0, 0)
837 vbox, self._active_test_label_map = make_summary_box(
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800838 [t for t in self._test_list.subtests
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800839 if state_map[t].status == TestState.ACTIVE],
840 state_map)
841 self._test_widget_box.add(vbox)
842 self._test_widget_box.show_all()
843
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800844
845def grab_shortcut_keys(disp, event_handler, event_client):
846 # We want to receive KeyPress events
847 root = disp.screen().root
848 root.change_attributes(event_mask = X.KeyPressMask)
849 shortcut_set = set(string.lowercase + string.digits)
850 keycode_map = {}
851 for mod, shortcut in ([(X.ControlMask, k) for k in shortcut_set] +
Jon Salz0405ab52012-03-16 15:26:52 +0800852 [(GLOBAL_HOT_KEY_MASK, k)
853 for k in GLOBAL_HOT_KEY_EVENTS] +
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800854 [(X.Mod1Mask, 'Tab')]): # Mod1 = Alt
855 keysym = gtk.gdk.keyval_from_name(shortcut)
856 keycode = disp.keysym_to_keycode(keysym)
857 keycode_map[keycode] = shortcut
858 root.grab_key(keycode, mod, 1, X.GrabModeAsync, X.GrabModeAsync)
859 # This flushes the XGrabKey calls to the server.
860 for dummy_x in range(0, root.display.pending_events()):
861 root.display.next_event()
862 gobject.io_add_watch(root.display, gobject.IO_IN, event_handler,
863 root.display, keycode_map, event_client)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800864
865
866def main(test_list_path):
867 '''Starts the main UI.
868
869 This is launched by the autotest/cros/factory/client.
870 When operators press keyboard shortcuts, the shortcut
871 value is sent as an event to the control program.'''
872
873 test_list = None
874 ui_state = None
875 event_client = None
876
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800877 def handle_key_release_event(_, event):
878 logging.info('base ui key event (%s)', event.keyval)
879 return True
880
881 def handle_event(event):
882 if event.type == Event.Type.STATE_CHANGE:
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800883 ui_state.update_test_state()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800884
885 test_list = factory.read_test_list(test_list_path)
886
887 window = gtk.Window(gtk.WINDOW_TOPLEVEL)
888 window.connect('destroy', lambda _: gtk.main_quit())
889 window.modify_bg(gtk.STATE_NORMAL, BLACK)
890
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800891 disp = Display()
892
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800893 event_client = EventClient(
894 callback=handle_event,
895 event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
896
897 screen = window.get_screen()
898 if (screen is None):
899 logging.info('ERROR: communication with the X server is not working, ' +
900 'could not find a working screen. UI exiting.')
901 sys.exit(1)
902
903 screen_size_str = os.environ.get('CROS_SCREEN_SIZE')
904 if screen_size_str:
905 match = re.match(r'^(\d+)x(\d+)$', screen_size_str)
906 assert match, 'CROS_SCREEN_SIZE should be {width}x{height}'
907 screen_size = (int(match.group(1)), int(match.group(2)))
908 else:
909 screen_size = (screen.get_width(), screen.get_height())
910 window.set_size_request(*screen_size)
911
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800912 test_directory = TestDirectory()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800913
914 rhs_box = gtk.EventBox()
915 rhs_box.modify_bg(gtk.STATE_NORMAL, _LABEL_TROUGH_COLOR)
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800916 rhs_box.add(test_directory)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800917
918 console_box = gtk.EventBox()
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800919 console_box.set_size_request(*convert_pixels((-1, 180)))
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800920 console_box.modify_bg(gtk.STATE_NORMAL, BLACK)
921
922 test_widget_box = gtk.Alignment()
923 test_widget_box.set_size_request(-1, -1)
924
925 lhs_box = gtk.VBox()
926 lhs_box.pack_end(console_box, False, False)
927 lhs_box.pack_start(test_widget_box)
928 lhs_box.pack_start(make_hsep(3), False, False)
929
930 base_box = gtk.HBox()
931 base_box.pack_end(rhs_box, False, False)
932 base_box.pack_end(make_vsep(3), False, False)
933 base_box.pack_start(lhs_box)
934
935 window.connect('key-release-event', handle_key_release_event)
936 window.add_events(gtk.gdk.KEY_RELEASE_MASK)
937
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800938 ui_state = UiState(test_widget_box, test_directory, test_list)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800939
940 window.add(base_box)
941 window.show_all()
942
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800943 grab_shortcut_keys(disp, test_directory.handle_xevent, event_client)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800944
945 hide_cursor(window.window)
946
947 test_widget_allocation = test_widget_box.get_allocation()
948 test_widget_size = (test_widget_allocation.width,
949 test_widget_allocation.height)
950 factory.set_shared_data('test_widget_size', test_widget_size)
951
952 dummy_console = Console(console_box.get_allocation())
953
954 event_client.post_event(Event(Event.Type.UI_READY))
955
956 logging.info('cros/factory/ui setup done, starting gtk.main()...')
957 gtk.main()
958 logging.info('cros/factory/ui gtk.main() finished, exiting.')
959
960
961if __name__ == '__main__':
Jon Salz14bcbb02012-03-17 15:11:50 +0800962 parser = OptionParser(usage='usage: %prog [options] TEST-LIST-PATH')
963 parser.add_option('-v', '--verbose', dest='verbose',
964 action='store_true',
965 help='Enable debug logging')
966 (options, args) = parser.parse_args()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800967
Jon Salz14bcbb02012-03-17 15:11:50 +0800968 if len(args) != 1:
969 parser.error('Incorrect number of arguments')
970
971 factory.init_logging('ui', verbose=options.verbose)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800972 main(sys.argv[1])