| // Copyright 2014 The Chromium OS Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| goog.provide('cros.factory.Goofy'); |
| |
| goog.require('_'); |
| goog.require('cros.factory.DeviceManager'); |
| goog.require('cros.factory.DiagnosisTool'); |
| goog.require('cros.factory.Plugin'); |
| goog.require('cros.factory.i18n'); |
| goog.require('cros.factory.testUI.Manager'); |
| goog.require('cros.factory.testUI.TabManager'); |
| goog.require('cros.factory.testUI.TileManager'); |
| goog.require('cros.factory.utils'); |
| goog.require('goog.crypt'); |
| goog.require('goog.crypt.Sha1'); |
| goog.require('goog.date.DateTime'); |
| goog.require('goog.debug.FancyWindow'); |
| goog.require('goog.debug.Logger'); |
| goog.require('goog.dom'); |
| goog.require('goog.dom.iframe'); |
| goog.require('goog.dom.safe'); |
| goog.require('goog.events'); |
| goog.require('goog.events.EventType'); |
| goog.require('goog.events.KeyCodes'); |
| goog.require('goog.html.SafeHtml'); |
| goog.require('goog.html.SafeStyle'); |
| goog.require('goog.i18n.DateTimeFormat'); |
| goog.require('goog.i18n.NumberFormat'); // Used by status_monitor.js |
| goog.require('goog.math'); |
| goog.require('goog.net.WebSocket'); |
| goog.require('goog.net.XhrIo'); |
| goog.require('goog.positioning'); |
| goog.require('goog.positioning.AnchoredViewportPosition'); |
| goog.require('goog.positioning.Corner'); |
| goog.require('goog.positioning.CornerBit'); |
| goog.require('goog.positioning.Overflow'); |
| goog.require('goog.string'); |
| goog.require('goog.style'); |
| goog.require('goog.ui.AdvancedTooltip'); |
| goog.require('goog.ui.Component.EventType'); |
| goog.require('goog.ui.Container'); |
| goog.require('goog.ui.Dialog'); |
| goog.require('goog.ui.Dialog.ButtonSet'); |
| goog.require('goog.ui.MenuSeparator'); |
| goog.require('goog.ui.PopupMenu'); |
| goog.require('goog.ui.ProgressBar'); |
| goog.require('goog.ui.Prompt'); |
| goog.require('goog.ui.SplitPane'); |
| goog.require('goog.ui.SubMenu'); |
| goog.require('goog.ui.ToggleButton'); // Indirectly used by default_test_ui.js |
| goog.require('goog.ui.decorate'); // Used by default_test_ui.js |
| goog.require('goog.ui.tree.TreeControl'); |
| |
| /** |
| * @type {?goog.debug.Logger} |
| * @const |
| */ |
| cros.factory.logger = goog.log.getLogger('cros.factory'); |
| |
| /** |
| * Keep-alive interval for the WebSocket. (Chrome times out WebSockets every |
| * ~1 min, so 30 s seems like a good interval.) |
| * @const |
| * @type {number} |
| */ |
| cros.factory.KEEP_ALIVE_INTERVAL_MSEC = 30000; |
| |
| /** |
| * Interval at which to try mounting the USB drive. |
| * @const |
| * @type {number} |
| */ |
| cros.factory.MOUNT_USB_DELAY_MSEC = 1000; |
| |
| /** |
| * Width of the control panel, as a fraction of the viewport size. |
| * @type {number} |
| */ |
| cros.factory.CONTROL_PANEL_WIDTH_FRACTION = 0.21; |
| |
| /** |
| * Minimum width of the control panel, in pixels. |
| * @const |
| * @type {number} |
| */ |
| cros.factory.CONTROL_PANEL_MIN_WIDTH = 320; |
| |
| /** |
| * Height of the log pane, as a fraction of the viewport size. |
| * @const |
| * @type {number} |
| */ |
| cros.factory.LOG_PANE_HEIGHT_FRACTION = 0.2; |
| |
| /** |
| * Minimum height of the log pane, in pixels. |
| * @const |
| * @type {number} |
| */ |
| cros.factory.LOG_PANE_MIN_HEIGHT = 170; |
| |
| /** |
| * Hover delay for a non-failing test. |
| * @const |
| * @type {number} |
| */ |
| cros.factory.NON_FAILING_TEST_HOVER_DELAY_MSEC = 250; |
| |
| /** |
| * Factory Test Extension ID to support calling chrome API via RPC. |
| * @const |
| * @type {string} |
| */ |
| cros.factory.EXTENSION_ID = 'pngocaclmlmihmhokaeejfiklacihcmb'; |
| |
| /** |
| * @define {boolean} Whether to enable diagnosis tool or not. |
| * The tool is still under development and is not ready for use yet. |
| * TODO(bowgotsai): enable this when the tool is ready. |
| */ |
| cros.factory.ENABLE_DIAGNOSIS_TOOL = false; |
| |
| /** |
| * Maximum lines of console log to be shown in the UI. |
| * @type {number} |
| * @const |
| */ |
| cros.factory.MAX_LINE_CONSOLE_LOG = 1024; |
| |
| /** |
| * An item in the test list. |
| * closure-compiler typedef can't handle recursive typedef, so the subtests |
| * type is unchecked. |
| * TODO(pihsun): Rewrite this type so the subtests can have correct type. |
| * @typedef {{path: string, label: !cros.factory.i18n.TranslationDict, |
| * disable_abort: boolean, subtests: !Array<!cros.factory.TestListEntry>, |
| * state: !cros.factory.TestState, args: Object, pytest_name: ?string}} |
| */ |
| cros.factory.TestListEntry; |
| |
| /** |
| * A pending shutdown event. |
| * @typedef {{delay_secs: number, time: number, operation: string, |
| * iteration: number, iterations: number, wait_shutdown_secs: number}} |
| */ |
| cros.factory.PendingShutdownEvent; |
| |
| /** |
| * Entry in test history returned by GetTestHistory. |
| * @typedef {{init_time: number, start_time: number, end_time: number, |
| * status: string, path: string, invocation: string}} |
| */ |
| cros.factory.HistoryMetadata; |
| |
| /** |
| * Entry in test history. |
| * @typedef {{metadata: !cros.factory.HistoryMetadata, log: string}} |
| */ |
| cros.factory.HistoryEntry; |
| |
| /** |
| * TestState object in an event or RPC response. |
| * @typedef {{status: string, skip: boolean, count: number, |
| * error_msg: string, invocation: ?string, iterations_left: number, |
| * retries_left: number, shutdown_count: number}} |
| */ |
| cros.factory.TestState; |
| |
| /** |
| * Information about a test list. |
| * @typedef {{id: string, name: !cros.factory.i18n.TranslationDict, |
| * enabled: boolean}} |
| */ |
| cros.factory.TestListInfo; |
| |
| /** |
| * @typedef {{text: !cros.factory.i18n.TranslationDict, id: string, |
| * eng_mode_only: boolean}} |
| */ |
| cros.factory.PluginMenuItem; |
| |
| /** |
| * @typedef {{action: string, data: string}} |
| */ |
| cros.factory.PluginMenuReturnData; |
| |
| /** |
| * Config for goofy plugin frontend UI. |
| * @typedef {{url: string, location: string}} |
| */ |
| cros.factory.PluginFrontendConfig; |
| |
| /** |
| * The i18n name of special keys. |
| * @type {!Map<string, !cros.factory.i18n.TranslationDict>} |
| */ |
| const KEY_NAME_MAP = new Map([ |
| ['ENTER', _('Enter')], |
| ['ESCAPE', _('ESC')], |
| [' ', _('Space')] |
| ]); |
| |
| /** |
| * Public API for tests. |
| */ |
| cros.factory.Test = class { |
| /** |
| * @param {!cros.factory.Invocation} invocation |
| */ |
| constructor(invocation) { |
| /** |
| * The invocation of this test. |
| * @type {!cros.factory.Invocation} |
| */ |
| this.invocation = invocation; |
| |
| /** |
| * Map of key values to handlers. |
| * @type {!Map<string, {callback: function(?goog.events.KeyEvent), |
| * button: ?Element}>} |
| * @private |
| */ |
| this.keyHandlers_ = new Map(); |
| |
| /** |
| * Whether the keydown listener is set on the contentWindow. |
| * @type {boolean} |
| */ |
| this.keyListenerSet_ = false; |
| } |
| |
| /** |
| * Passes the test. |
| * @export |
| */ |
| pass() { |
| this.sendTestEvent('goofy_ui_task_end', { |
| 'status': 'PASSED' |
| }); |
| } |
| |
| /** |
| * Fails the test with the given error message. |
| * @param {string} errorMsg |
| * @export |
| */ |
| fail(errorMsg) { |
| this.sendTestEvent('goofy_ui_task_end', { |
| 'status': 'FAILED', |
| 'error_msg': errorMsg |
| }); |
| } |
| |
| /** |
| * Try to abort the test from UI by operator. If test parameter |
| * 'disable_abort' is set and not in engineering mode, alert and |
| * return. |
| * @param {string=} errorMsg |
| * @export |
| */ |
| userAbort(errorMsg = 'Marked failed by operator') { |
| const goofy = this.invocation.goofy; |
| if (goofy.engineeringMode || |
| !goofy.pathTestMap[this.invocation.path].disable_abort) { |
| this.fail(errorMsg); |
| } else { |
| goofy.alert('You can only abort this test in engineering mode.'); |
| } |
| } |
| |
| /** |
| * Sends an event to the test backend. |
| * @param {string} subtype the event type |
| * @param {?Object} data the event data |
| * @export |
| */ |
| sendTestEvent(subtype, data) { |
| this.invocation.goofy.sendEvent('goofy:test_ui_event', { |
| 'test': this.invocation.path, |
| 'invocation': this.invocation.uuid, |
| 'subtype': subtype, |
| 'data': data |
| }); |
| } |
| |
| /** |
| * Binds a key to a handler. |
| * @param {string} key the key to bind. |
| * @param {function(?goog.events.KeyEvent)} callback the function to call when |
| * the key is pressed. |
| * @param {boolean=} once whether the callback should only be triggered once. |
| * @param {boolean=} virtual whether a virtual key button should be added. |
| * @export |
| */ |
| bindKey(key, callback, once = false, virtual = true) { |
| key = key.toUpperCase(); |
| if (!this.keyListenerSet_) { |
| // Set up the listener. We listen on KEYDOWN instead of KEYUP, so it won't |
| // be accidentally triggered after a dialog is dismissed. |
| goog.events.listen( |
| this.invocation.iframe.contentWindow, goog.events.EventType.KEYDOWN, |
| (/** !goog.events.KeyEvent */ event) => { |
| const handler = this.keyHandlers_.get(event.key.toUpperCase()); |
| if (handler) { |
| handler.callback(event); |
| } |
| }); |
| this.keyListenerSet_ = true; |
| } |
| const handler = {}; |
| if (once) { |
| handler.callback = (event) => { |
| callback(event); |
| this.unbindKey(key); |
| }; |
| } else { |
| handler.callback = callback; |
| } |
| if (virtual) { |
| const button = this.addVirtualKey_(key); |
| if (button) { |
| handler.button = button; |
| } |
| } |
| this.keyHandlers_.set(key, handler); |
| } |
| |
| /** |
| * Unbinds a key and removes its handler. |
| * @param {string} key the key to unbind. |
| * @export |
| */ |
| unbindKey(key) { |
| key = key.toUpperCase(); |
| const handler = this.keyHandlers_.get(key); |
| if (handler && handler.button) { |
| handler.button.remove(); |
| } |
| this.keyHandlers_.delete(key); |
| } |
| |
| /** |
| * Unbinds all keys. |
| * @export |
| */ |
| unbindAllKeys() { |
| for (const {button} of this.keyHandlers_.values()) { |
| if (button) { |
| button.remove(); |
| } |
| } |
| // We don't actually remove the handler, just let it does nothing should be |
| // good enough. |
| this.keyHandlers_.clear(); |
| } |
| |
| /** |
| * Binds standard pass keys (enter, space, 'P'). |
| * @export |
| */ |
| bindStandardPassKeys() { |
| this.bindKey('ENTER', () => { this.pass(); }); |
| for (const key of [' ', 'P']) { |
| this.bindKey(key, () => { this.pass(); }, false, false); |
| } |
| } |
| |
| /** |
| * Binds standard fail keys (ESC, 'F'). |
| * @export |
| */ |
| bindStandardFailKeys() { |
| this.bindKey('ESCAPE', () => { this.userAbort(); }); |
| this.bindKey('F', () => { this.userAbort(); }, false, false); |
| } |
| |
| /** |
| * Binds standard pass and fail keys. |
| * @export |
| */ |
| bindStandardKeys() { |
| this.bindStandardPassKeys(); |
| this.bindStandardFailKeys(); |
| } |
| |
| /** |
| * Triggers an update check. |
| * @export |
| */ |
| updateFactory() { |
| this.invocation.goofy.updateFactory(); |
| } |
| |
| /** |
| * Displays an alert. |
| * @param {string|!cros.factory.i18n.TranslationDict|!goog.html.SafeHtml} |
| * message |
| * @export |
| */ |
| alert(message) { |
| this.invocation.goofy.alert(message); |
| } |
| |
| /** |
| * Sets iframe to fullscreen size. |
| * Also iframe gets higher z-index than test panel so it will cover all other |
| * stuffs in goofy. |
| * @param {boolean} enable fullscreen iframe or not. |
| * @export |
| */ |
| setFullScreen(enable) { |
| this.invocation.iframe.classList.toggle('goofy-test-fullscreen', enable); |
| if (enable) { |
| this.invocation.goofy.hideTooltips(); |
| } |
| } |
| |
| /** |
| * Get the i18n name to be displayed for keyCode. |
| * @param {string} key |
| * @return {!cros.factory.i18n.TranslationDict} |
| * @private |
| */ |
| getKeyName_(key) { |
| return KEY_NAME_MAP.get(key) || cros.factory.i18n.noTranslation(key); |
| } |
| |
| /** |
| * Add a virtualkey button, and return the button. |
| * @param {string} key the key name which handler should be triggered when |
| * clicking the button. |
| * @return {?Element} |
| * @private |
| */ |
| addVirtualKey_(key) { |
| const template = this.invocation.iframe.contentWindow['template']; |
| if (!template) { |
| return null; |
| } |
| const label = this.getKeyName_(key); |
| const button = template.addButton(label); |
| goog.events.listen(button, goog.events.EventType.CLICK, () => { |
| const handler = this.keyHandlers_.get(key); |
| if (handler) { |
| // Not a key event, passing null to callback. |
| handler.callback(null); |
| } |
| }); |
| return button; |
| } |
| }; |
| |
| /** |
| * UI for a single test invocation. |
| */ |
| cros.factory.Invocation = class { |
| /** |
| * @param {!cros.factory.Goofy} goofy |
| * @param {string} path |
| * @param {string} uuid |
| */ |
| constructor(goofy, path, uuid) { |
| /** |
| * Reference to the Goofy object. |
| * @type {!cros.factory.Goofy} |
| */ |
| this.goofy = goofy; |
| |
| /** |
| * Full path of the test. |
| * @type {string} |
| */ |
| this.path = path; |
| |
| /** |
| * UUID of the invocation. |
| * @type {string} |
| */ |
| this.uuid = uuid; |
| |
| /** |
| * The iframe containing the test. |
| * @type {!HTMLIFrameElement} |
| */ |
| this.iframe = goog.asserts.assertInstanceof( |
| document.createElement('iframe'), HTMLIFrameElement); |
| this.iframe.src = '/default_test_ui.html'; |
| this.iframe.classList.add('goofy-test-iframe'); |
| |
| this.goofy.addInvocationUI(this); |
| |
| /** |
| * A promise that would be resolved after the test iframe is loaded. |
| * @type {!Promise} |
| */ |
| this.loaded = new Promise((resolve) => { |
| this.iframe.onload = resolve; |
| }); |
| |
| /** |
| * Test API for the invocation. |
| * @type {!cros.factory.Test} |
| */ |
| this.test = new cros.factory.Test(this); |
| |
| // TODO(pihsun): Remove this and use getElementById directly, since $ is |
| // typically used as jQuery, not getElementById. |
| this.iframe.contentWindow.$ = (/** string */ id) => |
| this.iframe.contentDocument.getElementById(id); |
| |
| // Export the libraries to test iframe. |
| this.iframe.contentWindow.cros = cros; |
| this.iframe.contentWindow.goog = goog; |
| this.iframe.contentWindow.test = this.test; |
| this.iframe.contentWindow._ = _; |
| } |
| |
| /** |
| * Returns test list entry for this invocation. |
| * @return {!cros.factory.TestListEntry} |
| */ |
| getTestListEntry() { |
| return this.goofy.pathTestMap[this.path]; |
| } |
| |
| /** |
| * Dispose the invocation (and destroys the iframe). |
| */ |
| dispose() { |
| goog.log.info(cros.factory.logger, `Cleaning up invocation ${this.uuid}`); |
| |
| this.goofy.removeInvocationUI(this); |
| this.iframe.remove(); |
| this.goofy.invocations.delete(this.uuid); |
| |
| goog.log.info( |
| cros.factory.logger, `Top-level invocation ${this.uuid} disposed`); |
| } |
| }; |
| |
| /** |
| * Types of notes. |
| * @type {!Array<{name: string, message: string}>} |
| */ |
| cros.factory.NOTE_LEVEL = [ |
| {name: 'INFO', message: 'Informative message only'}, |
| {name: 'WARNING', message: 'Displays a warning icon'}, |
| {name: 'CRITICAL', message: 'Testing is stopped indefinitely'} |
| ]; |
| |
| /** |
| * A factory note. |
| */ |
| cros.factory.Note = class { |
| /** |
| * @param {string} name |
| * @param {string} text |
| * @param {number} timestamp |
| * @param {string} level |
| */ |
| constructor(name, text, timestamp, level) { |
| this.name = name; |
| this.text = text; |
| this.timestamp = timestamp; |
| this.level = level; |
| } |
| }; |
| |
| /** |
| * UI for displaying critical factory notes. |
| */ |
| cros.factory.CriticalNoteDisplay = class { |
| /** |
| * @param {!cros.factory.Goofy} goofy |
| */ |
| constructor(goofy) { |
| this.goofy = goofy; |
| this.div = goog.dom.createDom('div', 'goofy-fullnote-display-outer'); |
| document.getElementById('goofy-main').appendChild(this.div); |
| |
| const innerDiv = goog.dom.createDom('div', 'goofy-fullnote-display-inner'); |
| this.div.appendChild(innerDiv); |
| |
| const titleDiv = goog.dom.createDom('div', 'goofy-fullnote-title'); |
| const titleImg = goog.dom.createDom( |
| 'img', {class: 'goofy-fullnote-logo', src: '/images/warning.svg'}); |
| titleDiv.appendChild(titleImg); |
| titleDiv.appendChild( |
| cros.factory.i18n.i18nLabelNode('Factory tests stopped')); |
| innerDiv.appendChild(titleDiv); |
| |
| const noteDiv = goog.dom.createDom('div', 'goofy-fullnote-note'); |
| goog.dom.safe.setInnerHtml(noteDiv, this.goofy.getNotesView()); |
| innerDiv.appendChild(noteDiv); |
| } |
| |
| /** |
| * Disposes of the critical factory notes display. |
| */ |
| dispose() { |
| if (this.div) { |
| this.div.remove(); |
| this.div = null; |
| } |
| } |
| }; |
| |
| /** |
| * The main Goofy UI. |
| */ |
| cros.factory.Goofy = class { |
| constructor() { |
| /** |
| * The WebSocket we'll use to communicate with the backend. |
| * @type {!goog.net.WebSocket} |
| */ |
| this.ws = new goog.net.WebSocket(); |
| |
| /** |
| * The UUID that we received from Goofy when starting up. |
| * @type {?string} |
| */ |
| this.uuid = null; |
| |
| /** |
| * The currently visible context menu, if any. |
| * @type {?goog.ui.PopupMenu} |
| */ |
| this.contextMenu = null; |
| |
| /** |
| * The last test for which a context menu was displayed. |
| * @type {?string} |
| */ |
| this.lastContextMenuPath = null; |
| |
| /** |
| * The time at which the last context menu was hidden. |
| * @type {?number} |
| */ |
| this.lastContextMenuHideTime = null; |
| |
| /** |
| * All tooltips that we have created. |
| * @type {!Array<!goog.ui.AdvancedTooltip>} |
| */ |
| this.tooltips = []; |
| |
| /** |
| * The test tree. |
| * @type {!goog.ui.tree.TreeControl} |
| */ |
| this.testTree = new goog.ui.tree.TreeControl('Tests'); |
| this.testTree.setShowRootNode(false); |
| this.testTree.setShowLines(false); |
| |
| /** |
| * A map from test path to the tree node for each test. |
| * @type {!Object<string, !goog.ui.tree.BaseNode>} |
| */ |
| this.pathNodeMap = {}; |
| |
| /** |
| * A map from test path to the entry in the test list for that test. |
| * @type {!Object<string, !cros.factory.TestListEntry>} |
| */ |
| this.pathTestMap = {}; |
| |
| /** |
| * A map from test path to the tree node html id for external reference. |
| * @type {!Object<string, string>} |
| */ |
| this.pathNodeIdMap = {}; |
| |
| /** |
| * What locale is currently enabled. |
| * @type {string} |
| */ |
| this.locale = 'en-US'; |
| |
| /** |
| * UIs for individual test invocations (by UUID). |
| * Use a Map to guarantee that the iteration order is same as insertion |
| * order. |
| * @type {!Map<string, !cros.factory.Invocation>} |
| */ |
| this.invocations = new Map(); |
| |
| /** |
| * Eng mode prompt. |
| * @type {?goog.ui.Dialog} |
| */ |
| this.engineeringModeDialog = null; |
| |
| /** |
| * Shutdown prompt dialog. |
| * @type {?goog.ui.Dialog} |
| */ |
| this.shutdownDialog = null; |
| |
| /** |
| * Visible dialogs. |
| * @type {!Array<!goog.ui.Dialog>} |
| */ |
| this.dialogs = []; |
| |
| /** |
| * Whether eng mode is enabled. |
| * @type {boolean} |
| */ |
| this.engineeringMode = false; |
| |
| /** |
| * SHA1 hash of password to take UI out of operator mode. If null, eng mode |
| * is always enabled. Defaults to an invalid '?', which means that eng mode |
| * cannot be entered (will be set from Goofy's shared_data). |
| * @type {?string} |
| */ |
| this.engineeringPasswordSHA1 = '?'; |
| |
| /** |
| * Debug window. |
| * @type {!goog.debug.FancyWindow} |
| */ |
| this.debugWindow = new goog.debug.FancyWindow('main'); |
| this.debugWindow.setEnabled(false); |
| this.debugWindow.init(); |
| |
| /** |
| * Various tests lists that can be enabled in engineering mode. |
| * @type {!Array<!cros.factory.TestListInfo>} |
| */ |
| this.testLists = []; |
| |
| /** |
| * All current notes. |
| * @type {!Array<!cros.factory.Note>} |
| */ |
| this.notes = []; |
| |
| /** |
| * The display for notes. |
| * @type {?cros.factory.CriticalNoteDisplay} |
| */ |
| this.noteDisplay = null; |
| |
| /** |
| * The DOM element for console. |
| * @type {?Element} |
| */ |
| this.console = null; |
| |
| /** |
| * The DOM element for terminal window. |
| * @type {?Element} |
| */ |
| this.terminal_win = null; |
| |
| /** |
| * The WebSocket for terminal window. |
| * @type {?WebSocket} |
| */ |
| this.terminal_sock = null; |
| |
| /** |
| * The menu items for Goofy plugin. |
| * @type {?Array<!cros.factory.PluginMenuItem>} |
| */ |
| this.pluginMenuItems = null; |
| |
| /** |
| * The UI manager for invocations. |
| * @type {?cros.factory.testUI.Manager} |
| */ |
| this.testUIManager = null; |
| |
| /** |
| * The type of UI manager. |
| * @type {?string} |
| */ |
| this.testUIManagerType = null; |
| |
| // Set up magic keyboard shortcuts. |
| goog.events.listen( |
| window, goog.events.EventType.KEYDOWN, this.keyListener, true, this); |
| |
| /** |
| * The device manager. |
| * @type {!cros.factory.DeviceManager} |
| */ |
| this.deviceManager = new cros.factory.DeviceManager(this); |
| |
| if (cros.factory.ENABLE_DIAGNOSIS_TOOL) { |
| /** |
| * The diagnosis tool (not yet enabled). |
| * @type {?cros.factory.DiagnosisTool} |
| */ |
| this.diagnosisTool = new cros.factory.DiagnosisTool(this); |
| } |
| } |
| |
| /** |
| * Sets the title of a modal dialog. |
| * @param {!goog.ui.Dialog} dialog |
| * @param {string|!goog.html.SafeHtml} title |
| */ |
| static setDialogTitle(dialog, title) { |
| goog.dom.safe.setInnerHtml( |
| goog.asserts.assertElement(dialog.getTitleTextElement()), |
| goog.html.SafeHtml.htmlEscapePreservingNewlines(title)); |
| } |
| |
| /** |
| * Sets the content of a modal dialog. |
| * @param {!goog.ui.Dialog} dialog |
| * @param {string|!goog.html.SafeHtml} content |
| */ |
| static setDialogContent(dialog, content) { |
| dialog.setSafeHtmlContent( |
| goog.html.SafeHtml.htmlEscapePreservingNewlines(content)); |
| } |
| |
| /** |
| * Event listener for Ctrl-Alt-keypress. |
| * @param {!goog.events.KeyEvent} event |
| */ |
| keyListener(event) { |
| // Prevent alt+left, or alt+right to do page navigation. |
| if ((event.keyCode === goog.events.KeyCodes.LEFT || |
| event.keyCode === goog.events.KeyCodes.RIGHT) && |
| event.altKey) { |
| event.preventDefault(); |
| } |
| |
| if (event.altKey && event.ctrlKey) { |
| switch (String.fromCharCode(event.keyCode)) { |
| case '0': |
| if (!this.dialogs.length) { |
| this.promptEngineeringPassword(); |
| } |
| break; |
| case '1': |
| this.debugWindow.setEnabled(true); |
| break; |
| default: |
| // Nothing |
| } |
| } |
| // Disable shortcut Ctrl-Alt-* when not in engineering mode. |
| // Note: platformModifierKey == Command-key for Mac browser; |
| // for non-Mac browsers, it is Ctrl-key. |
| if (!this.engineeringMode && event.altKey && event.platformModifierKey) { |
| event.stopPropagation(); |
| event.preventDefault(); |
| } |
| } |
| |
| /** |
| * Initializes a splitpane and decorate it on an element. |
| * @param {string} id |
| * @param {!goog.ui.SplitPane.Orientation} orientation |
| * @return {!goog.ui.SplitPane} |
| */ |
| initSplitPane(id, orientation) { |
| const splitPane = new goog.ui.SplitPane( |
| new goog.ui.Component(), new goog.ui.Component(), orientation); |
| const element = goog.asserts.assertElement(document.getElementById(id)); |
| splitPane.decorate(element); |
| |
| // Remove the inline size style set by splitPane.decorate, so resizing works |
| // better. Note that the goog-splitpane-{first,second}-container would still |
| // have inline size style. |
| element.removeAttribute('style'); |
| |
| // Remove the maximize splitpane when double-click on splitpane handle |
| // behavior, since it's pretty easy to misclick, and not very useful. |
| const handle = element.querySelector(':scope > .goog-splitpane-handle'); |
| goog.events.removeAll(handle, 'dblclick'); |
| |
| return splitPane; |
| } |
| |
| /** |
| * Create a callback object to be passed to test UI manager. |
| * @return {!cros.factory.testUI.CallBacks} |
| */ |
| createTestUICallbacks() { |
| return { |
| /** |
| * @param {string} path |
| * @param {boolean} visible |
| */ |
| notifyTestVisible: (path, visible) => { |
| // Change the background color of the node in tree. |
| const elt = this.pathNodeMap[path].getElement(); |
| elt.classList.toggle('goofy-test-visible', visible); |
| } |
| }; |
| } |
| |
| /** |
| * Initializes the split panes and the test ui. |
| */ |
| initUIComponents() { |
| const viewportSize = goog.dom.getViewportSize(goog.dom.getWindow(document)); |
| |
| const topSplitPane = this.initSplitPane( |
| 'goofy-splitpane', goog.ui.SplitPane.Orientation.HORIZONTAL); |
| topSplitPane.setFirstComponentSize(Math.max( |
| cros.factory.CONTROL_PANEL_MIN_WIDTH, |
| viewportSize.width * cros.factory.CONTROL_PANEL_WIDTH_FRACTION)); |
| |
| const mainAndConsole = this.initSplitPane( |
| 'goofy-main-and-console', goog.ui.SplitPane.Orientation.VERTICAL); |
| mainAndConsole.setFirstComponentSize( |
| viewportSize.height - |
| Math.max( |
| cros.factory.LOG_PANE_MIN_HEIGHT, |
| viewportSize.height * cros.factory.LOG_PANE_HEIGHT_FRACTION)); |
| |
| goog.debug.catchErrors(({ |
| /** string */ fileName, |
| /** string */ line, |
| /** string */ message |
| }) => { |
| try { |
| this.logToConsole( |
| `JavaScript error (${fileName}, line ${line}): ${message}`, |
| 'goofy-internal-error'); |
| } catch (e) { |
| // Oof... error while logging an error! Maybe the DOM isn't set |
| // up properly yet; just ignore. |
| } |
| }); |
| |
| window.addEventListener('unhandledrejection', (event) => { |
| try { |
| this.logToConsole( |
| `Unhandled promise rejection: ${event.reason}`, |
| 'goofy-internal-error'); |
| } catch (e) { |
| // Oof... error while logging an error! Maybe the DOM isn't set |
| // up properly yet; just ignore. |
| } |
| }); |
| |
| const fixSplitPaneSize = (/** !goog.ui.SplitPane */ splitPane) => { |
| splitPane.setFirstComponentSize(splitPane.getFirstComponentSize()); |
| }; |
| |
| // Recalculate the sub-container size when the window is resized. |
| goog.events.listen(window, goog.events.EventType.RESIZE, () => { |
| fixSplitPaneSize(topSplitPane); |
| fixSplitPaneSize(mainAndConsole); |
| }); |
| |
| goog.events.listen( |
| topSplitPane, goog.ui.SplitPane.EventType.HANDLE_DRAG, () => { |
| fixSplitPaneSize(mainAndConsole); |
| }); |
| |
| // Disable context menu except in engineering mode. |
| goog.events.listen( |
| window, goog.events.EventType.CONTEXTMENU, |
| (/** !goog.events.Event */ event) => { |
| if (!this.engineeringMode) { |
| event.stopPropagation(); |
| event.preventDefault(); |
| } |
| }); |
| |
| // Whenever we get focus, try to focus any visible iframe (if there's no |
| // dialog or context menu). |
| goog.events.listen(window, goog.events.EventType.FOCUS, () => { |
| this.focusInvocation(); |
| }); |
| |
| this.setTestUILayout('tab', {}); |
| |
| this.console = |
| goog.asserts.assertElement(document.getElementById('goofy-console')); |
| } |
| |
| /** |
| * Sets the test UI layout to use. |
| * @param {string} type the type of layout, should be ['tab', 'tiled']. |
| * @param {!Object} options |
| */ |
| setTestUILayout(type, options) { |
| goog.asserts.assert(['tab', 'tiled'].includes(type)); |
| goog.asserts.assert(this.invocations.size === 0); |
| if (type !== this.testUIManagerType) { |
| if (this.testUIManager) { |
| this.testUIManager.dispose(); |
| } |
| |
| const testUIMainDiv = goog.asserts.assertElement( |
| document.getElementById('goofy-test-ui-main')); |
| |
| if (type === 'tab') { |
| this.testUIManager = new cros.factory.testUI.TabManager( |
| testUIMainDiv, this.createTestUICallbacks()); |
| } else if (type === 'tiled') { |
| this.testUIManager = new cros.factory.testUI.TileManager( |
| testUIMainDiv, this.createTestUICallbacks()); |
| } |
| this.testUIManagerType = type; |
| } |
| this.testUIManager.setOptions(options); |
| } |
| |
| /** |
| * Add the invocation to UI manager. |
| * @param {!cros.factory.Invocation} invocation |
| */ |
| addInvocationUI(invocation) { |
| const {path, iframe} = invocation; |
| const label = this.pathTestMap[path].label; |
| this.testUIManager.addTestUI(path, label, iframe); |
| } |
| |
| /** |
| * Remove the invocation to UI manager. |
| * @param {!cros.factory.Invocation} invocation |
| */ |
| removeInvocationUI(invocation) { |
| this.testUIManager.removeTestUI(invocation.path); |
| } |
| |
| /** |
| * Returns focus to any visible invocation. |
| */ |
| focusInvocation() { |
| // We need a setTimeout(, 0) since the i.iframe.contentWindow.focus() |
| // doesn't work directly in the onfocus handler of window. |
| setTimeout(() => { |
| // Don't divert focus if there's a dialog visible, a context menu or |
| // terminal opened. |
| if (this.dialogs.length || this.contextMenu || this.terminal_win) { |
| return; |
| } |
| |
| for (const i of this.invocations.values()) { |
| if (i && i.iframe && this.testUIManager.isVisible(i.path)) { |
| i.iframe.contentWindow.focus(); |
| break; |
| } |
| } |
| }, 0); |
| } |
| |
| /** |
| * Initializes the WebSocket. |
| */ |
| initWebSocket() { |
| goog.events.listen(this.ws, goog.net.WebSocket.EventType.OPENED, () => { |
| this.logInternal('Connection to Goofy opened.'); |
| }); |
| goog.events.listen(this.ws, goog.net.WebSocket.EventType.ERROR, () => { |
| this.logInternal('Error connecting to Goofy.'); |
| }); |
| goog.events.listen(this.ws, goog.net.WebSocket.EventType.CLOSED, () => { |
| this.logInternal('Connection to Goofy closed.'); |
| }); |
| goog.events.listen( |
| this.ws, goog.net.WebSocket.EventType.MESSAGE, |
| (/** !goog.net.WebSocket.MessageEvent */ event) => { |
| this.handleBackendEvent(event.message); |
| }); |
| window.setInterval( |
| this.keepAlive.bind(this), cros.factory.KEEP_ALIVE_INTERVAL_MSEC); |
| this.ws.open(`ws://${window.location.host}/event`); |
| } |
| |
| /** |
| * Waits for the Goofy backend to be ready, and then starts UI. |
| */ |
| async preInit() { |
| while (true) { |
| let /** boolean */ isReady = false; |
| try { |
| isReady = await this.sendRpc('IsReadyForUIConnection'); |
| } catch (e) { |
| // There's a chance that goofy RPC isn't ready before this, since we |
| // initialize goofy RPC server after static files. |
| // We can't change the initialization order, since the static page need |
| // to be initialized early to prevent Chrome from getting a 404 page for |
| // index.html. |
| } |
| if (isReady) { |
| await this.init(); |
| return; |
| } |
| window.console.log('Waiting for the Goofy backend to be ready...'); |
| await cros.factory.utils.delay(500); |
| } |
| } |
| |
| /** |
| * Starts the UI. |
| */ |
| async init() { |
| this.initUIComponents(); |
| await this.initLocaleSelector(); |
| const testList = await this.sendRpc('GetTestList'); |
| await this.setTestList(testList); |
| this.initWebSocket(); |
| document.getElementById('goofy-div-wait').style.display = 'none'; |
| |
| await Promise.all([ |
| (async () => { |
| this.testLists = await this.sendRpc('GetTestLists'); |
| })(), |
| (async () => { |
| this.pluginMenuItems = await this.sendRpc('GetPluginMenuItems'); |
| })(), |
| (async () => { |
| const configs = await this.sendRpc('GetPluginFrontendConfigs'); |
| this.setPluginUI(configs); |
| })(), |
| (async () => { |
| const notes = |
| await this.sendRpc('get_shared_data', 'factory_note', true); |
| this.updateNote(notes); |
| })(), |
| (async () => { |
| const options = |
| await this.sendRpc('get_shared_data', 'test_list_options', true); |
| this.engineeringPasswordSHA1 = |
| options ? options['engineering_password_sha1'] : null; |
| // If no password, enable eng mode, and don't show the 'disable' |
| // link, since there is no way to enable it. |
| goog.style.setElementShown( |
| document.getElementById('goofy-disable-engineering-mode'), |
| this.engineeringPasswordSHA1 != null); |
| this.setEngineeringMode(this.engineeringPasswordSHA1 == null); |
| })(), |
| (async () => { |
| const error = |
| await this.sendRpc('get_shared_data', 'startup_error', true); |
| if (error) { |
| const alertHtml = goog.html.SafeHtml.concat( |
| cros.factory.i18n.i18nLabel( |
| 'An error occurred while starting the factory test system\n' + |
| 'Factory testing cannot proceed.'), |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-startup-error'}, error)); |
| this.alert(alertHtml); |
| } |
| })() |
| ]); |
| } |
| |
| /** |
| * Sets the locale of Goofy. |
| * @param {string} locale |
| */ |
| async setLocale(locale) { |
| this.locale = locale; |
| this.updateCSSClasses(); |
| await this.sendRpc('set_shared_data', 'ui_locale', this.locale); |
| } |
| |
| /** |
| * Sets up the locale selector. |
| */ |
| async initLocaleSelector() { |
| const rootNode = goog.asserts.assertElement( |
| document.getElementById('goofy-locale-selector')); |
| |
| const localeNames = cros.factory.i18n.getLocaleNames(); |
| const locales = cros.factory.i18n.locales; |
| |
| if (locales.length === 2) { |
| const [locale0, locale1] = locales; |
| // There are only two locales, a simple toggle button is enough. |
| let label = cros.factory.i18n.stringFormat( |
| _('Switch to\n{target_locale}'), {target_locale: localeNames}); |
| |
| // We have to swap the two values, so we show the other locale's prompt |
| // when in one locale. |
| const value0 = label[locale0]; |
| const value1 = label[locale1]; |
| label[locale0] = value1; |
| label[locale1] = value0; |
| |
| rootNode.appendChild(goog.dom.createDom( |
| 'div', {class: 'goofy-locale-toggle'}, |
| cros.factory.i18n.i18nLabelNode(label))); |
| goog.events.listen(rootNode, goog.events.EventType.CLICK, () => { |
| const locale = this.locale === locale0 ? locale1 : locale0; |
| this.setLocale(locale); |
| }); |
| } else if (locales.length > 2) { |
| // Show a dropdown menu for locale selection. |
| rootNode.appendChild(goog.dom.createDom( |
| 'div', {class: 'goofy-locale-dropdown'}, |
| cros.factory.i18n.i18nLabelNode('Language'))); |
| goog.events.listen( |
| rootNode, |
| [goog.events.EventType.MOUSEDOWN, goog.events.EventType.CONTEXTMENU], |
| (/** !goog.events.Event */ event) => { |
| event.stopPropagation(); |
| event.preventDefault(); |
| |
| // We reuse the same lastContextMenu{Path, HideTime} with |
| // showTestPopup. Choose some path that would never collide with any |
| // test. |
| const localeSelectorPath = '..fake.path.localeSelector'; |
| const menu = new goog.ui.PopupMenu(); |
| if (!this.registerMenu(menu, localeSelectorPath)) { |
| menu.dispose(); |
| return; |
| } |
| |
| for (const locale of locales) { |
| const item = new goog.ui.MenuItem(localeNames[locale]); |
| goog.events.listen( |
| item, goog.ui.Component.EventType.ACTION, () => { |
| this.setLocale(locale); |
| }); |
| menu.addChild(item, true); |
| } |
| |
| menu.render(); |
| menu.showAtElement( |
| rootNode, goog.positioning.Corner.BOTTOM_LEFT, |
| goog.positioning.Corner.TOP_LEFT); |
| }); |
| } |
| |
| this.updateCSSClasses(); |
| const /** string */ locale = |
| await this.sendRpc('get_shared_data', 'ui_locale'); |
| this.locale = locale; |
| this.updateCSSClasses(); |
| } |
| |
| /** |
| * Create an invocation for a test. |
| * @param {string} path |
| * @param {string} invocationUuid |
| * @return {!cros.factory.Invocation} the invocation. |
| */ |
| createInvocation(path, invocationUuid) { |
| cros.factory.logger.info( |
| `Creating UI for test ${path} (invocation ${invocationUuid})`); |
| const invocation = new cros.factory.Invocation(this, path, invocationUuid); |
| this.invocations.set(invocationUuid, invocation); |
| return invocation; |
| } |
| |
| /** |
| * Updates classes in a document based on the current settings. |
| * @param {!Document} doc |
| */ |
| updateCSSClassesInDocument(doc) { |
| const body = doc.body; |
| if (body) { |
| for (const locale of cros.factory.i18n.locales) { |
| body.classList.toggle(`goofy-locale-${locale}`, locale === this.locale); |
| } |
| body.classList.toggle('goofy-engineering-mode', this.engineeringMode); |
| body.classList.toggle('goofy-operator-mode', !this.engineeringMode); |
| } |
| } |
| |
| /** |
| * Updates classes in the UI based on the current settings. |
| */ |
| updateCSSClasses() { |
| this.updateCSSClassesInDocument(document); |
| document.getElementById('goofy-terminal') |
| .classList.toggle('goofy-engineering-mode', this.engineeringMode); |
| for (const i of this.invocations.values()) { |
| if (i && i.iframe && i.iframe.contentDocument) { |
| this.updateCSSClassesInDocument(i.iframe.contentDocument); |
| } |
| } |
| for (const i of /** @type {!NodeList<!HTMLIFrameElement>} */ ( |
| document.querySelectorAll('.goofy-plugin iframe'))) { |
| if (i.contentDocument) { |
| this.updateCSSClassesInDocument(i.contentDocument); |
| } |
| } |
| } |
| |
| /** |
| * Updates notes. |
| * @param {?Array<!cros.factory.Note>} notes |
| */ |
| updateNote(notes) { |
| notes = notes || []; |
| this.notes = notes; |
| const currentLevel = notes.length ? notes[notes.length - 1].level : ''; |
| |
| for (const {name} of cros.factory.NOTE_LEVEL) { |
| document.getElementById('goofy-logo') |
| .classList.toggle( |
| `goofy-note-${name.toLowerCase()}`, currentLevel === name); |
| } |
| |
| if (this.noteDisplay) { |
| this.noteDisplay.dispose(); |
| this.noteDisplay = null; |
| } |
| |
| if (currentLevel === 'CRITICAL') { |
| this.noteDisplay = new cros.factory.CriticalNoteDisplay(this); |
| } else if (currentLevel === 'WARNING') { |
| this.viewNotes(); |
| } |
| } |
| |
| /** |
| * Gets factory notes list. |
| * @return {!goog.html.SafeHtml} |
| */ |
| getNotesView() { |
| const createRowHTML = ({timestamp, name, text}) => { |
| const d = new Date(0); |
| d.setUTCSeconds(timestamp); |
| return goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create( |
| 'td', {}, cros.factory.Goofy.MDHMS_TIME_FORMAT.format(d)), |
| goog.html.SafeHtml.create('th', {}, name), |
| goog.html.SafeHtml.create('td', {}, text) |
| ]); |
| }; |
| const rows = this.notes.map(createRowHTML).reverse(); |
| return goog.html.SafeHtml.create('table', {id: 'goofy-note-list'}, rows); |
| } |
| |
| /** |
| * Displays a dialog of notes. |
| */ |
| viewNotes() { |
| if (!this.notes.length) { |
| return; |
| } |
| |
| this.createSimpleDialog('Factory Notes', this.getNotesView()) |
| .setVisible(true); |
| } |
| |
| /** |
| * Registers a dialog. Sets the dialog setDisposeOnHide to true, and returns |
| * focus to any running invocation when the dialog is hidden/disposed. |
| * @param {!goog.ui.Dialog} dialog |
| */ |
| registerDialog(dialog) { |
| this.dialogs.push(dialog); |
| dialog.setDisposeOnHide(true); |
| |
| goog.events.listen(dialog, goog.ui.Component.EventType.SHOW, () => { |
| // Hack: if the dialog contains an input element or button, focus it. (For |
| // instance, Prompt only calls select(), not focus(), on the text field, |
| // which causes ESC and Enter shortcuts not to work.) |
| const elt = dialog.getElement(); |
| let inputs = elt.getElementsByTagName('input'); |
| if (!inputs.length) { |
| inputs = elt.getElementsByTagName('button'); |
| } |
| if (inputs.length) { |
| inputs[0].focus(); |
| } |
| }); |
| |
| goog.events.listen(dialog, goog.ui.Component.EventType.HIDE, () => { |
| goog.array.remove(this.dialogs, dialog); |
| this.focusInvocation(); |
| }); |
| } |
| |
| /** |
| * Registers a context menu. Returns focus to any running invocation when the |
| * menu is hidden/disposed. Return false if the menu is hid recently. |
| * @param {!goog.ui.PopupMenu} menu |
| * @param {string} path |
| * @return {boolean} |
| */ |
| registerMenu(menu, path) { |
| if (path === this.lastContextMenuPath && |
| (+new Date() - this.lastContextMenuHideTime < |
| goog.ui.PopupBase.DEBOUNCE_DELAY_MS)) { |
| // We just hid it; don't reshow. |
| return false; |
| } |
| |
| // Hide all tooltips so that they don't fight with the context menu. |
| this.hideTooltips(); |
| |
| this.contextMenu = menu; |
| this.lastContextMenuPath = path; |
| goog.events.listen(menu, goog.ui.Component.EventType.HIDE, (event) => { |
| if (event.target !== menu) { |
| // We also receive HIDE events for submenus, but we're interested |
| // only in events for this top-level menu. |
| return; |
| } |
| menu.dispose(); |
| this.contextMenu = null; |
| this.lastContextMenuHideTime = +new Date(); |
| // Return focus to visible test, if any. |
| this.focusInvocation(); |
| }); |
| return true; |
| } |
| |
| /** |
| * Creates a simple dialog with an ok button. |
| * @param {string|!goog.html.SafeHtml} title |
| * @param {string|!cros.factory.i18n.TranslationDict|!goog.html.SafeHtml} |
| * content |
| * @return {!goog.ui.Dialog} |
| */ |
| createSimpleDialog(title, content) { |
| const dialog = new goog.ui.Dialog(); |
| this.registerDialog(dialog); |
| |
| dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk()); |
| dialog.setModal(false); |
| |
| cros.factory.Goofy.setDialogTitle(dialog, title); |
| let /** !goog.html.SafeHtml */ html; |
| if (content instanceof goog.html.SafeHtml) { |
| html = content; |
| } else { |
| html = cros.factory.i18n.i18nLabel(content); |
| } |
| cros.factory.Goofy.setDialogContent(dialog, html); |
| |
| dialog.getElement().classList.add('goofy-dialog'); |
| dialog.reposition(); |
| return dialog; |
| } |
| |
| /** |
| * Displays an alert. |
| * @param {string|!cros.factory.i18n.TranslationDict|!goog.html.SafeHtml} |
| * message |
| */ |
| alert(message) { |
| const dialog = this.createSimpleDialog('Alert', message); |
| dialog.setModal(true); |
| dialog.setVisible(true); |
| } |
| |
| /** |
| * Centers an element over the console. |
| * @param {?Element} element |
| */ |
| positionOverConsole(element) { |
| if (element && this.console) { |
| const consoleBound = goog.asserts.assertElement(this.console.parentNode) |
| .getBoundingClientRect(); |
| const elementBound = element.getBoundingClientRect(); |
| goog.style.setPosition( |
| element, |
| consoleBound.left + consoleBound.width / 2 - elementBound.width / 2, |
| consoleBound.top + consoleBound.height / 2 - elementBound.height / 2); |
| } |
| } |
| |
| /** |
| * Prompts to enter eng mode. |
| */ |
| promptEngineeringPassword() { |
| if (this.engineeringModeDialog) { |
| this.engineeringModeDialog.setVisible(false); |
| this.engineeringModeDialog.dispose(); |
| this.engineeringModeDialog = null; |
| } |
| if (!this.engineeringPasswordSHA1) { |
| this.alert('No password has been set.'); |
| return; |
| } |
| if (this.engineeringMode) { |
| this.setEngineeringMode(false); |
| return; |
| } |
| |
| this.engineeringModeDialog = |
| new goog.ui.Prompt('Password', '', (/** string */ text) => { |
| if (!text) { |
| return; |
| } |
| const hash = new goog.crypt.Sha1(); |
| hash.update(text); |
| const digest = goog.crypt.byteArrayToHex(hash.digest()); |
| if (digest === this.engineeringPasswordSHA1) { |
| this.setEngineeringMode(true); |
| } else { |
| this.alert('Incorrect password.'); |
| } |
| }); |
| this.registerDialog(this.engineeringModeDialog); |
| this.engineeringModeDialog.setVisible(true); |
| this.engineeringModeDialog.getElement().classList.add( |
| 'goofy-engineering-mode-dialog'); |
| this.engineeringModeDialog.reposition(); |
| this.positionOverConsole(this.engineeringModeDialog.getElement()); |
| } |
| |
| /** |
| * Sets eng mode. |
| * @param {boolean} enabled |
| */ |
| setEngineeringMode(enabled) { |
| this.engineeringMode = enabled; |
| this.updateCSSClasses(); |
| this.sendRpc('set_shared_data', 'engineering_mode', enabled); |
| } |
| |
| /** |
| * Deals with data about a pending reboot. |
| * @param {?cros.factory.PendingShutdownEvent} shutdownInfo |
| */ |
| setPendingShutdown(shutdownInfo) { |
| if (this.shutdownDialog) { |
| this.shutdownDialog.setVisible(false); |
| this.shutdownDialog.dispose(); |
| this.shutdownDialog = null; |
| } |
| if (!shutdownInfo || !shutdownInfo.operation) { |
| return; |
| } |
| |
| const action = shutdownInfo.operation == 'reboot' ? _('Rebooting') : |
| _('Shutting down'); |
| |
| const timesText = shutdownInfo.iterations == 1 ? |
| _('once') : |
| cros.factory.i18n.stringFormat( |
| _('{count} of {total} times'), |
| {count: shutdownInfo.iteration, total: shutdownInfo.iterations}); |
| |
| this.shutdownDialog = new goog.ui.Dialog(); |
| this.registerDialog(this.shutdownDialog); |
| const messageDiv = goog.dom.createDom('div'); |
| this.shutdownDialog.getContentElement().appendChild(messageDiv); |
| |
| const progressBar = new goog.ui.ProgressBar(); |
| progressBar.render(this.shutdownDialog.getContentElement()); |
| |
| const startTime = +new Date() / 1000; |
| const endTime = startTime + shutdownInfo.delay_secs; |
| const shutdownDialog = this.shutdownDialog; |
| |
| const tick = () => { |
| const now = +new Date() / 1000; |
| |
| if (now < endTime) { |
| const fraction = (now - startTime) / (endTime - startTime); |
| progressBar.setValue(goog.math.clamp(fraction, 0, 1) * 100); |
| |
| const secondsLeft = 1 + Math.floor(Math.max(0, endTime - now)); |
| goog.dom.safe.setInnerHtml( |
| messageDiv, |
| cros.factory.i18n.i18nLabel( |
| _('{action} in {seconds_left} seconds ({times_text}).\n' + |
| 'To cancel, press the Escape key.', |
| {action, times_text: timesText, seconds_left: secondsLeft}))); |
| } else if (now - endTime < shutdownInfo.wait_shutdown_secs) { |
| cros.factory.Goofy.setDialogContent( |
| shutdownDialog, cros.factory.i18n.i18nLabel('Shutting down...')); |
| } else { |
| this.setPendingShutdown(null); |
| } |
| }; |
| tick(); |
| |
| const timer = new goog.Timer(20); |
| goog.events.listen(timer, goog.Timer.TICK, tick); |
| timer.start(); |
| |
| goog.events.listen( |
| this.shutdownDialog, goog.ui.PopupBase.EventType.BEFORE_HIDE, () => { |
| timer.dispose(); |
| }); |
| |
| goog.events.listen( |
| this.shutdownDialog.getElement(), goog.events.EventType.KEYDOWN, |
| (/** goog.events.KeyEvent */ e) => { |
| if (e.keyCode === goog.events.KeyCodes.ESC) { |
| this.cancelShutdown(); |
| } |
| }); |
| |
| const buttonSet = new goog.ui.Dialog.ButtonSet(); |
| buttonSet.set( |
| goog.ui.Dialog.DefaultButtonKeys.CANCEL, |
| cros.factory.i18n.i18nLabelNode('Cancel'), true, true); |
| this.shutdownDialog.setButtonSet(buttonSet); |
| |
| goog.events.listen( |
| this.shutdownDialog, goog.ui.Dialog.EventType.SELECT, |
| (/** goog.ui.Dialog.Event */ e) => { |
| if (e.key === goog.ui.Dialog.DefaultButtonKeys.CANCEL) { |
| this.cancelShutdown(); |
| } |
| }); |
| |
| this.shutdownDialog.setHasTitleCloseButton(false); |
| this.shutdownDialog.setEscapeToCancel(false); |
| this.shutdownDialog.getElement().classList.add('goofy-shutdown-dialog'); |
| this.shutdownDialog.setVisible(true); |
| goog.events.listen( |
| this.shutdownDialog.getElement(), goog.events.EventType.BLUR, () => { |
| goog.Timer.callOnce( |
| this.shutdownDialog.focus.bind(this.shutdownDialog)); |
| }); |
| } |
| |
| /** |
| * Cancels a pending shutdown. |
| */ |
| cancelShutdown() { |
| this.sendEvent('goofy:cancel_shutdown', {}); |
| // Wait for Goofy to reset the pending_shutdown data. |
| } |
| |
| /** |
| * Does "auto-run": run all tests that have not yet passed. |
| */ |
| startAutoTest() { |
| this.sendEvent( |
| 'goofy:run_tests_with_status', |
| {'status': ['UNTESTED', 'ACTIVE', 'FAILED', 'FAILED_AND_WAIVED']}); |
| } |
| |
| /** |
| * Makes a menu item for a context-sensitive menu. |
| * @param {string|!cros.factory.i18n.TranslationDict} text the text to |
| * display for non-leaf node. |
| * @param {string|!cros.factory.i18n.TranslationDict} text_leaf the text to |
| * display for leaf node. |
| * @param {number} count the number of tests. |
| * @param {!cros.factory.TestListEntry} test the root node containing the |
| * tests. |
| * @param {function(!goog.events.Event)} handler the handler function (see |
| * goog.events.listen). |
| * @return {!goog.ui.MenuItem} |
| */ |
| makeMenuItem(text, text_leaf, count, test, handler) { |
| const test_label = cros.factory.i18n.translated(test.label); |
| |
| const item = new goog.ui.MenuItem(cros.factory.i18n.i18nLabelNode( |
| _(test.subtests.length ? text : text_leaf, {count, test: test_label}))); |
| item.setEnabled(count !== 0); |
| goog.events.listen( |
| item, goog.ui.Component.EventType.ACTION, handler, true, this); |
| return item; |
| } |
| |
| /** |
| * Returns true if all tests in the test lists before a given test have been |
| * run. |
| * @param {!cros.factory.TestListEntry} test |
| * @return {boolean} |
| */ |
| allTestsRunBefore(test) { |
| const root = goog.asserts.assert(this.pathTestMap['']); |
| |
| // Create a stack containing only the root node, and walk through it |
| // depth-first. (Use a stack rather than recursion since we want to be able |
| // to bail out easily when we hit 'test' or an incomplete test.) |
| const /** !Array<!cros.factory.TestListEntry> */ stack = [root]; |
| while (stack) { |
| const item = stack.pop(); |
| if (item === test) { |
| return true; |
| } |
| if (item.subtests.length) { |
| // Append elements in right-to-left order so we will examine them in the |
| // correct order. |
| stack.push(...item.subtests.slice().reverse()); |
| } else { |
| if (item.state.status === 'ACTIVE' || |
| item.state.status === 'UNTESTED') { |
| return false; |
| } |
| } |
| } |
| // We should never reach this, since it means that we never saw test while |
| // iterating! |
| throw Error('Test not in test list'); |
| } |
| |
| /** |
| * Displays a context menu for a test in the test tree. |
| * @param {string} path the path of the test whose context menu should be |
| * displayed. |
| * @param {!Element} labelElement the label element of the node in the test |
| * tree. |
| * @param {!Array<!goog.ui.Control>=} extraItems items to prepend to the |
| * menu. |
| * @return {boolean} |
| */ |
| showTestPopup(path, labelElement, extraItems) { |
| const test = this.pathTestMap[path]; |
| |
| const menu = new goog.ui.PopupMenu(); |
| if (!this.registerMenu(menu, path)) { |
| menu.dispose(); |
| return false; |
| } |
| |
| const addSeparator = () => { |
| if (menu.getChildCount() && |
| !(menu.getChildAt(menu.getChildCount() - 1) instanceof |
| goog.ui.MenuSeparator)) { |
| menu.addChild(new goog.ui.MenuSeparator(), true); |
| } |
| }; |
| |
| let numLeaves = 0; |
| const /** !Object<string, number> */ numLeavesByStatus = {}; |
| const allPaths = []; |
| let activeAndDisableAbort = false; |
| |
| const countLeaves = (/** !cros.factory.TestListEntry */ test) => { |
| allPaths.push(test.path); |
| for (const subtest of test.subtests) { |
| countLeaves(subtest); |
| } |
| |
| if (!test.subtests.length) { |
| ++numLeaves; |
| numLeavesByStatus[test.state.status] = |
| 1 + (numLeavesByStatus[test.state.status] || 0); |
| // If there is any subtest that is active and can not be aborted, this |
| // test can not be aborted. |
| if (test.state.status === 'ACTIVE' && test.disable_abort) { |
| activeAndDisableAbort = true; |
| } |
| } |
| }; |
| countLeaves(test); |
| |
| if (this.noteDisplay) { |
| const item = new goog.ui.MenuItem(cros.factory.i18n.i18nLabelNode( |
| 'Critical factory note; cannot run tests')); |
| menu.addChild(item, true); |
| item.setEnabled(false); |
| } else if (!this.engineeringMode && !this.allTestsRunBefore(test)) { |
| const item = new goog.ui.MenuItem(cros.factory.i18n.i18nLabelNode( |
| 'Not in engineering mode; cannot skip tests')); |
| menu.addChild(item, true); |
| item.setEnabled(false); |
| } else { |
| if (this.engineeringMode || |
| (!test.subtests.length && test.state.status !== 'PASSED')) { |
| // Allow user to restart all tests under a particular node if |
| // (a) in engineering mode, or (b) if this is a single non-passed test. |
| // If neither of these is true, it's too easy to accidentally re-run a |
| // bunch of tests and wipe their state. |
| const allUntested = numLeavesByStatus['UNTESTED'] === numLeaves; |
| const handler = () => { |
| this.sendEvent('goofy:restart_tests', {path}); |
| }; |
| if (allUntested) { |
| menu.addChild( |
| this.makeMenuItem( |
| _('Run all {count} tests in "{test}"'), |
| _('Run test "{test}"'), numLeaves, test, handler), |
| true); |
| } else { |
| menu.addChild( |
| this.makeMenuItem( |
| _('Restart all {count} tests in "{test}"'), |
| _('Restart test "{test}"'), numLeaves, test, handler), |
| true); |
| } |
| } |
| |
| if (test.subtests.length) { |
| const /** !Array<string> */ status = |
| ['UNTESTED', 'ACTIVE', 'FAILED', 'FAILED_AND_WAIVED']; |
| let /** number */ count = 0; |
| for (const s of status) { |
| count += numLeavesByStatus[s] || 0; |
| } |
| // Only show for parents. |
| menu.addChild( |
| this.makeMenuItem( |
| _('Restart {count} tests in "{test}" that have not passed'), '', |
| count, test, |
| () => { |
| this.sendEvent('goofy:run_tests_with_status', {status, path}); |
| }), |
| true); |
| } |
| |
| if (this.engineeringMode) { |
| menu.addChild( |
| this.makeMenuItem( |
| _('Clear status of {count} tests in "{test}"'), |
| _('Clear status of test "{test}"'), numLeaves, test, |
| () => { |
| this.sendEvent('goofy:clear_state', {path}); |
| }), |
| true); |
| } |
| if (this.engineeringMode && test.subtests.length) { |
| menu.addChild( |
| this.makeMenuItem( |
| _('Run {count} untested tests in "{test}"'), '', |
| (numLeavesByStatus['UNTESTED'] || 0) + |
| (numLeavesByStatus['ACTIVE'] || 0), |
| test, |
| () => { |
| this.sendEvent('goofy:auto_run', {path}); |
| }), |
| true); |
| } |
| } |
| addSeparator(); |
| |
| const stopAllItem = |
| new goog.ui.MenuItem(cros.factory.i18n.i18nLabelNode('Stop all tests')); |
| stopAllItem.setEnabled(numLeavesByStatus['ACTIVE'] > 0); |
| goog.events.listen(stopAllItem, goog.ui.Component.EventType.ACTION, () => { |
| this.sendEvent( |
| 'goofy:stop', {'fail': true, 'reason': 'Operator requested abort'}); |
| }); |
| menu.addChild(stopAllItem, true); |
| |
| // When there is any active test, enable abort item in menu if goofy is in |
| // engineering mode or there is no active subtest with disable_abort=true. |
| if (numLeavesByStatus['ACTIVE'] && |
| (this.engineeringMode || !activeAndDisableAbort)) { |
| menu.addChild( |
| this.makeMenuItem( |
| _('Abort {count} active tests in "{test}" and continue testing'), |
| _('Abort active test "{test}" and continue testing'), |
| numLeavesByStatus['ACTIVE'], test, |
| () => { |
| this.sendEvent('goofy:stop', { |
| 'path': path, |
| 'fail': true, |
| 'reason': 'Operator requested abort' |
| }); |
| }), |
| true); |
| } |
| |
| if (!test.subtests.length && test.state.status === 'ACTIVE') { |
| addSeparator(); |
| const item = |
| new goog.ui.MenuItem(cros.factory.i18n.i18nLabelNode('Show test UI')); |
| goog.events.listen(item, goog.ui.Component.EventType.ACTION, () => { |
| this.testUIManager.showTest(test.path); |
| }); |
| item.setEnabled(!this.testUIManager.isVisible(test.path)); |
| menu.addChild(item, true); |
| } |
| |
| if (this.engineeringMode && !test.subtests.length) { |
| addSeparator(); |
| menu.addChild(this.createViewLogMenu(path), true); |
| } |
| |
| if (extraItems && extraItems.length) { |
| addSeparator(); |
| for (const item of extraItems) { |
| menu.addChild(item, true); |
| } |
| } |
| |
| menu.render(document.body); |
| menu.showAtElement( |
| labelElement, goog.positioning.Corner.BOTTOM_LEFT, |
| goog.positioning.Corner.TOP_LEFT); |
| return true; |
| } |
| |
| /** |
| * Create a scrollable goog.ui.SubMenu. |
| * The menu of returned SubMenu can be scrolled when the menu is too long. |
| * @param {!goog.ui.ControlContent} content the content passed to constructor |
| * of goog.ui.SubMenu. |
| * @return {!goog.ui.SubMenu} |
| */ |
| createScrollableSubMenu(content) { |
| const subMenu = new goog.ui.SubMenu(content); |
| goog.dom.safe.setStyle( |
| /** @type {!Element} */ (subMenu.getMenu().getElement()), |
| goog.html.SafeStyle.create({overflow: 'scroll'})); |
| // Override the positionSubMenu function of subMenu to use RESIZE_HEIGHT, so |
| // it would resize the subMenu's menu when it's too long to fit in the |
| // viewport. |
| subMenu.positionSubMenu = function() { |
| goog.ui.SubMenu.prototype.positionSubMenu.apply(this); |
| const position = new goog.positioning.AnchoredViewportPosition( |
| this.getElement(), goog.positioning.Corner.TOP_END, true); |
| /** |
| * @suppress {accessControls} |
| * @param {number} status the status of the last positionAtAnchor call. |
| * @param {!goog.positioning.Corner} corner the corner to adjust. |
| * @return {!goog.positioning.Corner} |
| */ |
| position.adjustCorner = (status, corner) => { |
| if (status & goog.positioning.OverflowStatus.FAILED_HORIZONTAL) { |
| corner = goog.positioning.flipCornerHorizontal(corner); |
| } |
| // Prefer to anchor to bottom corner, since it works better with |
| // RESIZE_HEIGHT when there's little space downward. |
| if (status & goog.positioning.OverflowStatus.FAILED_VERTICAL && |
| !(corner & goog.positioning.CornerBit.BOTTOM)) { |
| corner = goog.positioning.flipCornerVertical(corner); |
| } |
| return corner; |
| }; |
| position.setLastResortOverflow( |
| goog.positioning.Overflow.ADJUST_X | |
| goog.positioning.Overflow.ADJUST_Y | |
| goog.positioning.Overflow.RESIZE_HEIGHT); |
| position.reposition( |
| this.getMenu().getElement(), goog.positioning.Corner.TOP_START); |
| }.bind(subMenu); |
| return subMenu; |
| } |
| |
| /** |
| * Returns a "View logs" submenu for a given test path. |
| * @param {string} path |
| * @return {!goog.ui.SubMenu} |
| */ |
| createViewLogMenu(path) { |
| const subMenu = this.createScrollableSubMenu('View logs'); |
| const loadingItem = new goog.ui.MenuItem('Loading...'); |
| loadingItem.setEnabled(false); |
| subMenu.addItem(loadingItem); |
| |
| (async () => { |
| const history = await this.sendRpc('GetTestHistory', path); |
| if (!history.length) { |
| loadingItem.setCaption('No logs available'); |
| return; |
| } |
| |
| if (subMenu.getMenu().indexOfChild(loadingItem) >= 0) { |
| subMenu.getMenu().removeChild(loadingItem, true); |
| } |
| |
| // Arrange in descending order of time (it is returned in ascending |
| // order). |
| history.reverse(); |
| |
| let count = history.length; |
| for (const entry of history) { |
| const status = entry.status ? entry.status.toLowerCase() : 'started'; |
| let title = `${count}.`; |
| count--; |
| |
| if (entry.init_time) { |
| // TODO(jsalz): Localize (but not that important since this is not |
| // for operators) |
| |
| title += ` Run at ${ |
| cros.factory.Goofy.HMS_TIME_FORMAT.format( |
| new Date(entry.init_time * 1000))}`; |
| } |
| title += ` (${status}`; |
| |
| const time = entry.end_time || entry.init_time; |
| if (time) { |
| let secondsAgo = +new Date() / 1000 - time; |
| |
| const hoursAgo = Math.floor(secondsAgo / 3600); |
| secondsAgo -= hoursAgo * 3600; |
| |
| const minutesAgo = Math.floor(secondsAgo / 60); |
| secondsAgo -= minutesAgo * 60; |
| |
| if (hoursAgo) { |
| title += ` ${hoursAgo} h`; |
| } |
| if (minutesAgo) { |
| title += ` ${minutesAgo} m`; |
| } |
| title += ` ${Math.floor(secondsAgo)} s ago`; |
| } |
| title += ')…'; |
| |
| const item = new goog.ui.MenuItem(goog.dom.createDom( |
| 'span', `goofy-view-logs-status-${status}`, title)); |
| goog.events.listen(item, goog.ui.Component.EventType.ACTION, () => { |
| this.showHistoryEntry(entry.path, entry.invocation); |
| }); |
| |
| subMenu.addItem(item); |
| } |
| })(); |
| |
| return subMenu; |
| } |
| |
| /** |
| * Displays a dialog containing logs. |
| * @param {string|!cros.factory.i18n.TranslationDict} title |
| * @param {string} data text to show in the dialog. |
| */ |
| showLogDialog(title, data) { |
| const content = goog.html.SafeHtml.concat( |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-log-data'}, |
| cros.factory.i18n.i18nLabel(data)), |
| goog.html.SafeHtml.create('div', {class: 'goofy-log-time'})); |
| |
| const dialog = |
| this.createSimpleDialog(cros.factory.i18n.i18nLabel(title), content); |
| |
| const dialogContentElement = |
| goog.asserts.assertInstanceof(dialog.getContentElement(), HTMLElement); |
| |
| const logDataElement = goog.asserts.assertElement( |
| dialogContentElement.getElementsByClassName('goofy-log-data')[0]); |
| logDataElement.scrollTop = logDataElement.scrollHeight; |
| |
| const logTimeElement = goog.asserts.assertElement( |
| dialogContentElement.getElementsByClassName('goofy-log-time')[0]); |
| |
| const timer = new goog.Timer(1000); |
| goog.events.listen(timer, goog.Timer.TICK, () => { |
| // Show time in the same format as in the logs |
| const timeStr = |
| new goog.date.DateTime().toUTCIsoString(true, true).replace(' ', 'T'); |
| goog.dom.safe.setInnerHtml( |
| logTimeElement, |
| goog.html.SafeHtml.concat( |
| cros.factory.i18n.i18nLabel('System time: '), |
| goog.html.SafeHtml.htmlEscape(timeStr))); |
| }); |
| timer.dispatchTick(); |
| timer.start(); |
| goog.events.listen(dialog, goog.ui.Component.EventType.HIDE, () => { |
| timer.dispose(); |
| }); |
| dialog.setVisible(true); |
| } |
| |
| /** |
| * Add a factory note. |
| * @param {string} name |
| * @param {string} note |
| * @param {string} level |
| * @return {boolean} |
| */ |
| addNote(name, note, level) { |
| if (!name || !note) { |
| alert('Both name and note fields must not be empty!'); |
| return false; |
| } |
| // The timestamp for Note is set in the RPC call AddNote. |
| this.sendRpc('AddNote', new cros.factory.Note(name, note, 0, level)); |
| return true; |
| } |
| |
| /** |
| * Displays a dialog to modify factory note. |
| */ |
| showNoteDialog() { |
| const rows = []; |
| rows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create( |
| 'th', {}, cros.factory.i18n.i18nLabel('Your Name')), |
| goog.html.SafeHtml.create( |
| 'td', {}, |
| goog.html.SafeHtml.create('input', {id: 'goofy-addnote-name'})) |
| ])); |
| rows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create( |
| 'th', {}, cros.factory.i18n.i18nLabel('Note Content')), |
| goog.html.SafeHtml.create( |
| 'td', {}, |
| goog.html.SafeHtml.create('textarea', {id: 'goofy-addnote-text'})) |
| ])); |
| |
| const options = []; |
| for (const {name, message} of cros.factory.NOTE_LEVEL) { |
| const selected = name === 'INFO' ? 'selected' : null; |
| options.push(goog.html.SafeHtml.create( |
| 'option', {value: name, selected}, `${name}: ${message}`)); |
| } |
| rows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create( |
| 'th', {}, cros.factory.i18n.i18nLabel('Severity')), |
| goog.html.SafeHtml.create( |
| 'td', {}, |
| goog.html.SafeHtml.create( |
| 'select', {id: 'goofy-addnote-level'}, options)) |
| ])); |
| |
| const table = goog.html.SafeHtml.create( |
| 'table', {class: 'goofy-addnote-table'}, rows); |
| const buttons = goog.ui.Dialog.ButtonSet.createOkCancel(); |
| |
| const dialog = |
| this.createSimpleDialog(cros.factory.i18n.i18nLabel('Add Note'), table); |
| dialog.setModal(true); |
| dialog.setButtonSet(buttons); |
| |
| const nameBox = goog.asserts.assertInstanceof( |
| document.getElementById('goofy-addnote-name'), HTMLInputElement); |
| const textBox = goog.asserts.assertInstanceof( |
| document.getElementById('goofy-addnote-text'), HTMLTextAreaElement); |
| const levelBox = goog.asserts.assertInstanceof( |
| document.getElementById('goofy-addnote-level'), HTMLSelectElement); |
| |
| goog.events.listen( |
| dialog, goog.ui.Dialog.EventType.SELECT, |
| (/** !goog.ui.Dialog.Event */ event) => { |
| if (event.key === goog.ui.Dialog.DefaultButtonKeys.OK) { |
| if (!this.addNote(nameBox.value, textBox.value, levelBox.value)) { |
| event.preventDefault(); |
| } |
| } |
| }); |
| dialog.setVisible(true); |
| } |
| |
| /** |
| * Uploads factory logs to the factory server. |
| * @param {string} name name of the person uploading logs |
| * @param {string} serial serial number of this device |
| * @param {string} description bug description |
| */ |
| async uploadFactoryLogs(name, serial, description) { |
| const dialog = new goog.ui.Dialog(); |
| this.registerDialog(dialog); |
| cros.factory.Goofy.setDialogTitle( |
| dialog, cros.factory.i18n.i18nLabel('Uploading factory logs...')); |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| cros.factory.i18n.i18nLabel('Uploading factory logs. Please wait...')); |
| |
| dialog.setButtonSet(null); |
| dialog.setVisible(true); |
| |
| try { |
| const {/** number */ size, /** string */ key} = |
| await this.sendRpc('UploadFactoryLogs', name, serial, description); |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| goog.html.SafeHtml.concat( |
| goog.html.SafeHtml.htmlEscapePreservingNewlines( |
| `Success! Uploaded factory logs (${ |
| size} bytes).\nThe archive key is `), |
| goog.html.SafeHtml.create( |
| 'span', {class: 'goofy-ul-archive-key'}, key), |
| goog.html.SafeHtml.htmlEscapePreservingNewlines( |
| '.\nPlease use this key when filing bugs\n' + |
| 'or corresponding with the factory team.'))); |
| dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk()); |
| dialog.reposition(); |
| } catch (error) { |
| cros.factory.Goofy.setDialogContent( |
| dialog, `Unable to upload factory logs:\n${error.message}`); |
| dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk()); |
| dialog.reposition(); |
| } |
| } |
| |
| /** |
| * Ask goofy to reload current test list to apply local changes. |
| */ |
| async reloadTestList() { |
| try { |
| await this.sendRpc('ReloadTestList'); |
| } catch (error) { |
| this.alert(`Failed to reload test list\n${error.message}`); |
| throw error; |
| } |
| } |
| |
| /** |
| * Displays a dialog to upload factory logs to factory server. |
| */ |
| showUploadFactoryLogsDialog() { |
| const dialog = new goog.ui.Dialog(); |
| this.registerDialog(dialog); |
| dialog.setModal(true); |
| |
| const rows = []; |
| rows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create( |
| 'th', {}, cros.factory.i18n.i18nLabel('Your Name')), |
| goog.html.SafeHtml.create( |
| 'td', {}, |
| goog.html.SafeHtml.create( |
| 'input', {id: 'goofy-ul-name', size: 30})) |
| ])); |
| rows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create( |
| 'th', {}, cros.factory.i18n.i18nLabel('Serial Number')), |
| goog.html.SafeHtml.create( |
| 'td', {}, |
| goog.html.SafeHtml.create( |
| 'input', {id: 'goofy-ul-serial', size: 30})) |
| ])); |
| rows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create( |
| 'th', {}, cros.factory.i18n.i18nLabel('Bug Description')), |
| goog.html.SafeHtml.create( |
| 'td', {}, |
| goog.html.SafeHtml.create( |
| 'input', {id: 'goofy-ul-description', size: 50})) |
| ])); |
| |
| const table = |
| goog.html.SafeHtml.create('table', {class: 'goofy-ul-table'}, rows); |
| cros.factory.Goofy.setDialogContent(dialog, table); |
| |
| const buttons = goog.ui.Dialog.ButtonSet.createOkCancel(); |
| dialog.setButtonSet(buttons); |
| |
| cros.factory.Goofy.setDialogTitle( |
| dialog, cros.factory.i18n.i18nLabel('Upload Factory Logs')); |
| dialog.setVisible(true); |
| |
| const nameElt = goog.asserts.assertInstanceof( |
| document.getElementById('goofy-ul-name'), HTMLInputElement); |
| const serialElt = goog.asserts.assertInstanceof( |
| document.getElementById('goofy-ul-serial'), HTMLInputElement); |
| const descriptionElt = goog.asserts.assertInstanceof( |
| document.getElementById('goofy-ul-description'), HTMLInputElement); |
| |
| // Enable OK only if all three of these text fields are filled in. |
| const /** !Array<HTMLInputElement> */ elts = |
| [nameElt, serialElt, descriptionElt]; |
| const checkOKEnablement = () => { |
| buttons.setButtonEnabled( |
| goog.ui.Dialog.DefaultButtonKeys.OK, elts.every((elt) => elt.value)); |
| }; |
| for (const elt of elts) { |
| goog.events.listen( |
| elt, [goog.events.EventType.CHANGE, goog.events.EventType.KEYUP], |
| checkOKEnablement, false); |
| } |
| checkOKEnablement(); |
| |
| goog.events.listen( |
| dialog, goog.ui.Dialog.EventType.SELECT, |
| (/** !goog.ui.Dialog.Event */ event) => { |
| if (event.key !== goog.ui.Dialog.DefaultButtonKeys.OK) { |
| return; |
| } |
| |
| this.uploadFactoryLogs( |
| nameElt.value, serialElt.value, descriptionElt.value) |
| .then(() => { |
| dialog.dispose(); |
| }); |
| |
| event.preventDefault(); |
| }); |
| } |
| |
| /** |
| * Saves factory logs to a USB drive. |
| */ |
| async saveFactoryLogsToUSB() { |
| const title = cros.factory.i18n.i18nLabel('Save Factory Logs to USB'); |
| |
| const doSave = () => { |
| const callback = async (/** ?string */ id) => { |
| if (id == null) { |
| // Cancelled. |
| return; |
| } |
| |
| const dialog = new goog.ui.Dialog(); |
| this.registerDialog(dialog); |
| cros.factory.Goofy.setDialogTitle(dialog, title); |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| cros.factory.i18n.i18nLabel('Saving factory logs to USB drive...')); |
| dialog.setButtonSet(null); |
| dialog.setVisible(true); |
| this.positionOverConsole(dialog.getElement()); |
| try { |
| const {dev, name: filename, size, temporary} = /** |
| * @type {{dev: string, name: string, size: number, |
| * temporary: boolean}} |
| */ (await this.sendRpc('SaveLogsToUSB', id)); |
| if (temporary) { |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| cros.factory.i18n.i18nLabel(_( |
| 'Success! Saved factory logs ({size}) bytes) to {dev} as' + |
| '\n{filename}. The drive has been unmounted.', |
| {size: size.toString(), dev, filename}))); |
| } else { |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| cros.factory.i18n.i18nLabel(_( |
| 'Success! Saved factory logs ({size}) bytes) to {dev} as' + |
| '\n{filename}.', |
| {size: size.toString(), dev, filename}))); |
| } |
| } catch (error) { |
| cros.factory.Goofy.setDialogContent( |
| dialog, `Unable to save logs: ${error.message}`); |
| } |
| dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk()); |
| this.positionOverConsole(dialog.getElement()); |
| }; |
| |
| const idDialog = new goog.ui.Prompt('', '', callback.bind(this)); |
| cros.factory.Goofy.setDialogTitle(idDialog, title); |
| idDialog.getContentElement().prepend(cros.factory.i18n.i18nLabelNode( |
| 'Enter an optional identifier for the archive ' + |
| '(or press Enter for none):')); |
| this.registerDialog(idDialog); |
| idDialog.setVisible(true); |
| idDialog.getElement().classList.add('goofy-log-identifier-prompt'); |
| this.positionOverConsole(idDialog.getElement()); |
| }; |
| |
| const waitForUSBDialog = new goog.ui.Dialog(); |
| this.registerDialog(waitForUSBDialog); |
| cros.factory.Goofy.setDialogContent( |
| waitForUSBDialog, |
| cros.factory.i18n.i18nLabel( |
| 'Please insert a formatted USB stick' + |
| ' and wait a moment for it to be mounted.')); |
| waitForUSBDialog.setButtonSet(new goog.ui.Dialog.ButtonSet().addButton( |
| goog.ui.Dialog.ButtonSet.DefaultButtons.CANCEL, false, true)); |
| cros.factory.Goofy.setDialogTitle(waitForUSBDialog, title); |
| waitForUSBDialog.setVisible(true); |
| this.positionOverConsole(waitForUSBDialog.getElement()); |
| |
| while (waitForUSBDialog.isVisible()) { |
| const /** boolean */ available = |
| await this.sendRpc('IsUSBDriveAvailable'); |
| if (available) { |
| waitForUSBDialog.dispose(); |
| doSave(); |
| return; |
| } |
| await cros.factory.utils.delay(cros.factory.MOUNT_USB_DELAY_MSEC); |
| } |
| } |
| |
| /** |
| * Displays a dialog containing history for a given test invocation. |
| * @param {string} path |
| * @param {string} invocation |
| */ |
| async showHistoryEntry(path, invocation) { |
| const /** !cros.factory.HistoryEntry */ entry = |
| await this.sendRpc('GetTestHistoryEntry', path, invocation); |
| const metadataRows = []; |
| for (const [name, title] of /** @type {!Array<!Array<string>>} */ ([ |
| ['status', 'Status'], ['init_time', 'Creation time'], |
| ['start_time', 'Start time'], ['end_time', 'End time'] |
| ])) { |
| if (entry.metadata[name]) { |
| let /** string|number */ value = entry.metadata[name]; |
| delete entry.metadata[name]; |
| if (name.endsWith('_time')) { |
| value = cros.factory.Goofy.FULL_TIME_FORMAT.format( |
| new Date(value * 1000)); |
| } |
| metadataRows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create('th', {}, title), |
| goog.html.SafeHtml.create('td', {}, value) |
| ])); |
| } |
| } |
| |
| const keys = Object.keys(entry.metadata).sort(); |
| for (const key of keys) { |
| if (key === 'log_tail') { |
| // Skip log_tail, since we already have the entire log. |
| continue; |
| } |
| const /** string|number|!Object */ value = entry.metadata[key]; |
| const /** string|number */ valueRepr = |
| goog.isObject(value) ? JSON.stringify(value) : value; |
| metadataRows.push(goog.html.SafeHtml.create('tr', {}, [ |
| goog.html.SafeHtml.create('th', {}, key), |
| goog.html.SafeHtml.create('td', {}, valueRepr) |
| ])); |
| } |
| |
| const metadataTable = goog.html.SafeHtml.create( |
| 'table', {class: 'goofy-history-metadata'}, metadataRows); |
| |
| const title = |
| `${entry.metadata.path} (invocation ${entry.metadata.invocation})`; |
| const content = goog.html.SafeHtml.concat( |
| goog.html.SafeHtml.create('div', {class: 'goofy-history'}, [ |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-history-header'}, 'Test Info'), |
| metadataTable, |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-history-header'}, 'Log'), |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-history-log'}, entry.log) |
| ])); |
| this.createSimpleDialog(title, content).setVisible(true); |
| } |
| |
| /** |
| * Updates the tooltip for a test based on its status. |
| * The tooltip will be displayed only for failed tests. |
| * @param {string} path |
| * @param {!goog.ui.AdvancedTooltip} tooltip |
| */ |
| updateTestToolTip(path, tooltip) { |
| const test = this.pathTestMap[path]; |
| const tooltipElement = |
| goog.asserts.assertInstanceof(tooltip.getElement(), HTMLElement); |
| |
| tooltip.setText(''); |
| |
| const errorMsg = test.state.error_msg; |
| if ((test.state.status !== 'FAILED' && |
| test.state.status !== 'FAILED_AND_WAIVED') || |
| this.contextMenu || !errorMsg) { |
| // Just show the test path, with a very short hover delay. |
| tooltip.setText(test.path); |
| tooltip.setHideDelayMs(cros.factory.NON_FAILING_TEST_HOVER_DELAY_MSEC); |
| } else { |
| // Show the last failure. |
| const lines = errorMsg.split('\n'); |
| const html = []; |
| const invocation = test.state.invocation; |
| html.push( |
| goog.html.SafeHtml.htmlEscape(`${test.path} failed:`), |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-test-failure'}, lines.shift())); |
| |
| if (lines.length) { |
| html.push( |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-test-failure-detail-link'}, |
| 'Show more detail...'), |
| goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-test-failure-detail'}, |
| goog.html.SafeHtml.htmlEscapePreservingNewlines( |
| lines.join('\n')))); |
| } |
| if (invocation) { |
| html.push(goog.html.SafeHtml.create( |
| 'div', {class: 'goofy-test-failure-view-log-link'}, 'View log...')); |
| } |
| |
| tooltip.setSafeHtml(goog.html.SafeHtml.concat(html)); |
| |
| if (lines.length) { |
| const link = |
| goog.asserts.assertElement(tooltipElement.getElementsByClassName( |
| 'goofy-test-failure-detail-link')[0]); |
| goog.events.listen(link, goog.events.EventType.CLICK, () => { |
| tooltipElement.classList.add('goofy-test-failure-expanded'); |
| tooltip.reposition(); |
| }, true); |
| } |
| if (invocation) { |
| const link = |
| goog.asserts.assertElement(tooltipElement.getElementsByClassName( |
| 'goofy-test-failure-view-log-link')[0]); |
| goog.events.listen(link, goog.events.EventType.CLICK, () => { |
| tooltip.setVisible(false); |
| this.showHistoryEntry(test.path, /** @type {string} */ (invocation)); |
| }); |
| } |
| } |
| } |
| |
| /** |
| * Sets up the UI for a the test list. (Should be invoked only once, when the |
| * test list is received.) |
| * @param {!cros.factory.TestListEntry} testList the test list (the return |
| * value of the GetTestList RPC call). |
| */ |
| async setTestList(testList) { |
| cros.factory.logger.info( |
| `Received test list: ${goog.debug.expose(testList)}`); |
| document.getElementById('goofy-loading').style.display = 'none'; |
| |
| this.addToNode(null, testList); |
| // expandAll is necessary to get all the elements to actually be created |
| // right away so we can add listeners. We'll collapse it later. |
| this.testTree.expandAll(); |
| this.testTree.render(document.getElementById('goofy-test-tree')); |
| |
| const addListener = ( |
| /** string */ path, /** !Element */ labelElement, |
| /** !Element */ rowElement) => { |
| const tooltip = new goog.ui.AdvancedTooltip(rowElement); |
| tooltip.setHideDelayMs(1000); |
| this.tooltips.push(tooltip); |
| goog.events.listen( |
| tooltip, goog.ui.Component.EventType.BEFORE_SHOW, () => { |
| this.updateTestToolTip(path, tooltip); |
| }); |
| goog.events.listen( |
| rowElement, goog.events.EventType.CONTEXTMENU, |
| (/** !goog.events.KeyEvent */ event) => { |
| if (event.ctrlKey) { |
| // Ignore; let the default (browser) context menu show up. |
| return; |
| } |
| |
| this.showTestPopup(path, labelElement); |
| event.stopPropagation(); |
| event.preventDefault(); |
| }); |
| goog.events.listen( |
| labelElement, goog.events.EventType.MOUSEDOWN, |
| (/** !goog.events.KeyEvent */ event) => { |
| if (event.button === 0) { |
| this.showTestPopup(path, labelElement); |
| event.stopPropagation(); |
| event.preventDefault(); |
| } |
| }); |
| }; |
| |
| for (const path of Object.keys(this.pathNodeMap)) { |
| const node = this.pathNodeMap[path]; |
| const labelElement = goog.asserts.assertElement(node.getLabelElement()); |
| const rowElement = goog.asserts.assertElement(node.getRowElement()); |
| addListener(path, labelElement, rowElement); |
| } |
| |
| const buildTitleExtras = () => { |
| const extraItems = []; |
| const addExtraItem = |
| (/** !cros.factory.i18n.TranslationDict */ label, |
| /** function() */ action) => { |
| const item = |
| new goog.ui.MenuItem(cros.factory.i18n.i18nLabelNode(label)); |
| goog.events.listen( |
| item, goog.ui.Component.EventType.ACTION, action, false, this); |
| extraItems.push(item); |
| }; |
| |
| if (this.engineeringMode) { |
| addExtraItem(_('Update factory software'), this.updateFactory); |
| extraItems.push(this.makeSwitchTestListMenu()); |
| extraItems.push(new goog.ui.MenuSeparator()); |
| addExtraItem(_('Save note on device'), this.showNoteDialog); |
| addExtraItem(_('View notes'), this.viewNotes); |
| addExtraItem(_('Clear notes'), () => this.sendRpc('ClearNotes')); |
| extraItems.push(new goog.ui.MenuSeparator()); |
| if (cros.factory.ENABLE_DIAGNOSIS_TOOL) { |
| addExtraItem( |
| _('Diagnosis Tool'), |
| this.diagnosisTool.showWindow.bind(this.diagnosisTool)); |
| } |
| } |
| |
| addExtraItem( |
| _('Save factory logs to USB drive...'), this.saveFactoryLogsToUSB); |
| addExtraItem(_('Upload factory logs...'), async () => { |
| try { |
| await this.sendRpc('PingFactoryServer'); |
| } catch (error) { |
| this.alert(`Unable to contact factory server.\n${error.message}`); |
| return; |
| } |
| this.showUploadFactoryLogsDialog(); |
| }); |
| addExtraItem(_('Reload Test List'), () => { |
| this.reloadTestList(); |
| }); |
| addExtraItem( |
| _('Toggle engineering mode'), this.promptEngineeringPassword); |
| |
| if (this.pluginMenuItems) { |
| extraItems.push(new goog.ui.MenuSeparator()); |
| const engineeringMode = this.engineeringMode; |
| for (const item of this.pluginMenuItems) { |
| if (item.eng_mode_only && !engineeringMode) { |
| continue; |
| } |
| addExtraItem(item.text, async () => { |
| const /** !cros.factory.PluginMenuReturnData */ return_data = |
| await this.sendRpc('OnPluginMenuItemClicked', item.id); |
| if (return_data.action === 'SHOW_IN_DIALOG') { |
| this.showLogDialog(item.text, return_data.data); |
| } else if (return_data.action === 'RUN_AS_JS') { |
| eval(return_data.data); |
| } else { |
| this.alert(`Unknown return action: ${return_data.action}`); |
| } |
| }); |
| } |
| } |
| return extraItems; |
| }; |
| |
| for (const eventType of /** @type {!Array<string>} */ ([ |
| goog.events.EventType.MOUSEDOWN, goog.events.EventType.CONTEXTMENU |
| ])) { |
| goog.events.listen( |
| document.getElementById('goofy-title'), eventType, |
| (/** !goog.events.KeyEvent */ event) => { |
| if (eventType === goog.events.EventType.MOUSEDOWN && |
| event.button !== 0) { |
| // Only process primary button for MOUSEDOWN. |
| return; |
| } |
| if (event.ctrlKey) { |
| // Ignore; let the default (browser) context menu show up. |
| return; |
| } |
| |
| const logo = goog.asserts.assertElement( |
| document.getElementById('goofy-logo-text')); |
| this.showTestPopup('', logo, buildTitleExtras()); |
| |
| event.stopPropagation(); |
| event.preventDefault(); |
| }); |
| } |
| |
| this.testTree.collapseAll(); |
| const /** !Object<string, !cros.factory.TestState> */ stateMap = |
| await this.sendRpc('GetTestStates'); |
| for (const path of Object.keys(stateMap)) { |
| if (!path.startsWith('_')) { // e.g., __jsonclass__ |
| this.setTestState(path, stateMap[path]); |
| } |
| } |
| } |
| |
| /** |
| * Create the switch test list menu. |
| * @return {!goog.ui.SubMenu} |
| */ |
| makeSwitchTestListMenu() { |
| const subMenu = new goog.ui.SubMenu( |
| cros.factory.i18n.i18nLabelNode('Switch test list')); |
| for (const {name, id, enabled} of this.testLists) { |
| const item = new goog.ui.MenuItem(cros.factory.i18n.i18nLabelNode(name)); |
| item.setSelectable(true); |
| item.setSelected(enabled); |
| subMenu.addItem(item); |
| if (enabled) { |
| // Don't do anything if the active one is selected. |
| continue; |
| } |
| goog.events.listen(item, goog.ui.Component.EventType.ACTION, () => { |
| const dialog = new goog.ui.Dialog(); |
| this.registerDialog(dialog); |
| const title = cros.factory.i18n.stringFormat( |
| _('Switch Test List: {test_list}'), {test_list: name}); |
| cros.factory.Goofy.setDialogTitle( |
| dialog, cros.factory.i18n.i18nLabel(title)); |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| cros.factory.i18n.i18nLabel(_( |
| 'Warning: Switching to test list "{test_list}"' + |
| ' will clear all test state.\n' + |
| 'Are you sure you want to proceed?', |
| {test_list: name}))); |
| |
| const buttonSet = new goog.ui.Dialog.ButtonSet(); |
| buttonSet.set( |
| goog.ui.Dialog.DefaultButtonKeys.OK, |
| cros.factory.i18n.i18nLabelNode('Yes, clear state and restart')); |
| buttonSet.set( |
| goog.ui.Dialog.DefaultButtonKeys.CANCEL, |
| cros.factory.i18n.i18nLabelNode('Cancel'), true, true); |
| dialog.setButtonSet(buttonSet); |
| dialog.setVisible(true); |
| |
| dialog.reposition(); |
| |
| goog.events.listen( |
| dialog, goog.ui.Dialog.EventType.SELECT, |
| (/** !goog.ui.Dialog.Event */ e) => { |
| if (e.key === goog.ui.Dialog.DefaultButtonKeys.OK) { |
| const dialog = this.showIndefiniteActionDialog( |
| title, _('Switching test list. Please wait...')); |
| this.sendRpc('SwitchTestList', id).catch((error) => { |
| dialog.dispose(); |
| this.alert(`Unable to switch test list:\n${error.message}`); |
| }); |
| } |
| }); |
| }); |
| } |
| return subMenu; |
| } |
| |
| /** |
| * Displays a dialog for an operation that should never return. |
| * @param {string|!cros.factory.i18n.TranslationDict} title |
| * @param {string|!cros.factory.i18n.TranslationDict} label |
| * @return {!goog.ui.Dialog} |
| */ |
| showIndefiniteActionDialog(title, label) { |
| const dialog = new goog.ui.Dialog(); |
| this.registerDialog(dialog); |
| dialog.setHasTitleCloseButton(false); |
| cros.factory.Goofy.setDialogTitle( |
| dialog, cros.factory.i18n.i18nLabel(title)); |
| cros.factory.Goofy.setDialogContent( |
| dialog, cros.factory.i18n.i18nLabel(label)); |
| dialog.setButtonSet(null); |
| dialog.setVisible(true); |
| dialog.reposition(); |
| return dialog; |
| } |
| |
| /** |
| * Sends an event to update factory software. |
| */ |
| async updateFactory() { |
| const dialog = this.showIndefiniteActionDialog( |
| _('Software update'), _('Updating factory software. Please wait...')); |
| |
| const {success, updated, error_msg: errorMsg} = /** |
| * @type {{success: boolean, updated: boolean, error_msg: ?string}} |
| */ (await this.sendRpc('UpdateFactory')); |
| if (updated) { |
| dialog.setTitle('Update succeeded'); |
| cros.factory.Goofy.setDialogContent( |
| dialog, cros.factory.i18n.i18nLabel('Update succeeded. Restarting.')); |
| } else if (success) { // but not updated |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| cros.factory.i18n.i18nLabel('No update is currently necessary.')); |
| dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk()); |
| } else { |
| cros.factory.Goofy.setDialogContent( |
| dialog, |
| goog.html.SafeHtml.concat( |
| cros.factory.i18n.i18nLabel('Update failed:'), |
| goog.html.SafeHtml.create('pre', {}, errorMsg || ''))); |
| dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk()); |
| } |
| dialog.reposition(); |
| } |
| |
| /** |
| * Sets the state for a particular test. |
| * @param {string} path |
| * @param {!cros.factory.TestState} state the TestState object (contained in |
| * an event or as a response to the RPC call). |
| */ |
| setTestState(path, state) { |
| const node = this.pathNodeMap[path]; |
| if (!node) { |
| goog.log.warning( |
| cros.factory.logger, `No node found for test path ${path}`); |
| return; |
| } |
| |
| const elt = goog.asserts.assertElement(node.getElement()); |
| const test = this.pathTestMap[path]; |
| test.state = state; |
| |
| // Assign the appropriate class to the node, and remove all other status |
| // classes. |
| cros.factory.utils.removeClassesWithPrefix(elt, 'goofy-status-'); |
| elt.classList.add( |
| `goofy-status-${state.status.toLowerCase().replace(/_/g, '-')}`); |
| |
| if (state.status === 'ACTIVE') { |
| // Automatically show the test if it is running. |
| node.reveal(); |
| } |
| } |
| |
| /** |
| * Adds a test node to the tree. |
| * @param {?goog.ui.tree.BaseNode} parent |
| * @param {!cros.factory.TestListEntry} test |
| */ |
| addToNode(parent, test) { |
| let node; |
| if (parent == null) { |
| node = this.testTree; |
| } else { |
| const html = cros.factory.i18n.i18nLabel(test.label); |
| node = this.testTree.createNode(); |
| node.setSafeHtml(html); |
| parent.addChild(node); |
| } |
| for (const subtest of test.subtests) { |
| this.addToNode(node, subtest); |
| } |
| |
| node.setIconClass('goofy-test-icon'); |
| node.setExpandedIconClass('goofy-test-icon'); |
| |
| this.pathNodeMap[test.path] = node; |
| this.pathTestMap[test.path] = test; |
| this.pathNodeIdMap[test.path] = node.getId(); |
| } |
| |
| /** |
| * Sends an event to Goofy. |
| * @param {string} type the event type (e.g., 'goofy:hello'). |
| * @param {!Object} properties of event. |
| */ |
| sendEvent(type, properties) { |
| const dict = goog.object.clone(properties); |
| dict.type = type; |
| // Transform all undefined in object values to null. |
| const serialized = JSON.stringify( |
| dict, (key, value) => value === undefined ? null : value); |
| goog.log.info(cros.factory.logger, `Sending event: ${serialized}`); |
| if (this.ws.isOpen()) { |
| this.ws.send(serialized); |
| } |
| } |
| |
| |
| /** |
| * Calls an RPC function. |
| * @param {string} method |
| * @param {...?} args |
| * @returns {Promise} |
| */ |
| sendRpc(method, ...args) { |
| return this.sendRpcImpl_(method, args, '/goofy'); |
| } |
| |
| /** |
| * Calls an RPC function for plugin. |
| * @param {string} pluginName |
| * @param {string} method |
| * @param {...?} args |
| * @returns {Promise} |
| */ |
| sendRpcToPlugin(pluginName, method, ...args) { |
| return this.sendRpcImpl_( |
| method, args, `/plugin/${pluginName.replace('.', '_')}`); |
| } |
| |
| /** |
| * Implementation for sendRpc and sendRpcToPlugin. |
| * @param {string} method |
| * @param {!Array<?Object>} params |
| * @param {string} path |
| * @returns {Promise} |
| * @private |
| */ |
| async sendRpcImpl_(method, params, path) { |
| const body = JSON.stringify({method, params, id: 1}); |
| let response; |
| try { |
| response = await fetch(path, {body, method: 'POST'}); |
| } catch (error) { |
| const message = `RPC error calling ${method}: ${error.message}`; |
| this.logToConsole(message, 'goofy-internal-error'); |
| throw error; |
| } |
| const /** {error: ?{message: string}, result: ?Object} */ json = |
| await response.json(); |
| cros.factory.logger.info( |
| `RPC response for ${method}: ${JSON.stringify(json)}`); |
| if (json.error) { |
| throw new Error(json.error.message); |
| } |
| return json.result; |
| } |
| |
| /** |
| * Sends a keepalive event if the web socket is open. |
| */ |
| keepAlive() { |
| if (this.ws.isOpen()) { |
| this.sendEvent('goofy:keepalive', {uuid: this.uuid}); |
| } |
| } |
| |
| /** |
| * Writes a message to the console log. |
| * @param {string} message |
| * @param {!Object|!Array<string>|string=} opt_attributes attributes to add |
| * to the div element containing the log entry. |
| */ |
| logToConsole(message, opt_attributes) { |
| if (this.console) { |
| const div = goog.dom.createDom('div', opt_attributes); |
| div.appendChild(document.createTextNode(message)); |
| this.console.appendChild(div); |
| |
| // Restrict the size of the log to avoid UI lag. |
| if (this.console.childNodes.length > cros.factory.MAX_LINE_CONSOLE_LOG) { |
| this.console.removeChild(this.console.firstChild); |
| } |
| |
| // Scroll to bottom. TODO(jsalz): Scroll only if already at the bottom, |
| // or add scroll lock. |
| this.console.scrollTop = this.console.scrollHeight; |
| } |
| } |
| |
| /** |
| * Logs an "internal" message to the console (as opposed to a line from |
| * console.log). |
| * @param {string} message |
| */ |
| logInternal(message) { |
| this.logToConsole(message, 'goofy-internal-log'); |
| } |
| |
| /** |
| * Hides tooltips, and cancels pending shows. |
| * @suppress {accessControls} |
| */ |
| hideTooltips() { |
| for (const tooltip of this.tooltips) { |
| tooltip.clearShowTimer(); |
| tooltip.setVisible(false); |
| } |
| } |
| |
| /** |
| * Handles an event sends from the backend. |
| * @param {string} jsonMessage the message as a JSON string. |
| */ |
| handleBackendEvent(jsonMessage) { |
| goog.log.info(cros.factory.logger, `Got message: ${jsonMessage}`); |
| const untypedMessage = |
| /** @type {{type: string}} */ (JSON.parse(jsonMessage)); |
| const messageType = untypedMessage.type; |
| |
| switch (messageType) { |
| case 'goofy:hello': { |
| const message = /** @type {{uuid: string}} */ (untypedMessage); |
| if (this.uuid && message.uuid !== this.uuid) { |
| // The goofy process has changed; reload the page. |
| goog.log.info(cros.factory.logger, 'Incorrect UUID; reloading'); |
| window.location.reload(); |
| return; |
| } else { |
| this.uuid = message.uuid; |
| // Send a keepAlive to confirm the UUID with the backend. |
| this.keepAlive(); |
| } |
| break; |
| } |
| case 'goofy:log': { |
| const message = /** @type {{message: string}} */ (untypedMessage); |
| this.logToConsole(message.message); |
| break; |
| } |
| case 'goofy:state_change': { |
| const message = |
| /** @type {{path: string, state: !cros.factory.TestState}} */ ( |
| untypedMessage); |
| this.setTestState(message.path, message.state); |
| break; |
| } |
| case 'goofy:init_test_ui': { |
| const message = |
| /** @type {{test: string, invocation: string}} */ (untypedMessage); |
| const invocation = |
| this.createInvocation(message.test, message.invocation); |
| |
| invocation.loaded.then(() => { |
| const doc = goog.asserts.assert(invocation.iframe.contentDocument); |
| this.updateCSSClassesInDocument(doc); |
| }); |
| |
| goog.events.listen( |
| invocation.iframe.contentWindow, goog.events.EventType.KEYDOWN, |
| this.keyListener.bind(this)); |
| break; |
| } |
| case 'goofy:set_html': { |
| const message = /** |
| * @type {{test: string, invocation: string, id: ?string, |
| * append: boolean, html: string, autoscroll: boolean}} |
| */ (untypedMessage); |
| const invocation = this.invocations.get(message.invocation); |
| if (!invocation) { |
| break; |
| } |
| |
| invocation.loaded.then(() => { |
| const document = invocation.iframe.contentDocument; |
| const element = |
| message.id ? document.getElementById(message.id) : document.body; |
| if (!element) { |
| return; |
| } |
| |
| // Add some margin so that people don't need to scroll to the very |
| // bottom to make autoscroll work. |
| const scrollAtBottom = |
| (element.scrollHeight - element.scrollTop >= |
| element.clientHeight - 10); |
| |
| if (message.append) { |
| const fragment = cros.factory.utils.createFragmentFromHTML( |
| message.html, goog.asserts.assert(document)); |
| element.appendChild(fragment); |
| } else { |
| element.innerHTML = message.html; |
| } |
| |
| if (message.autoscroll && scrollAtBottom) { |
| element.scrollTop = element.scrollHeight - element.clientHeight; |
| } |
| }); |
| break; |
| } |
| case 'goofy:import_html': { |
| const message = /** |
| * @type {{test: string, invocation: string, url: string}} |
| */ (untypedMessage); |
| const invocation = this.invocations.get(message.invocation); |
| if (invocation) { |
| invocation.loaded = invocation.loaded.then(() => { |
| const doc = invocation.iframe.contentDocument; |
| const link = doc.createElement('link'); |
| link.rel = 'import'; |
| link.href = message.url; |
| doc.head.appendChild(link); |
| return new Promise((resolve, reject) => { |
| link.onload = resolve; |
| link.onerror = reject; |
| }); |
| }); |
| } |
| break; |
| } |
| case 'goofy:run_js': { |
| const message = /** |
| * @type {{test: string, invocation: string, args: !Object, js: string}} |
| */ (untypedMessage); |
| const invocation = this.invocations.get(message.invocation); |
| if (invocation) { |
| invocation.loaded.then(() => { |
| // We need to evaluate the code in the context of the content |
| // window, but we also need to give it a variable. Stash it in the |
| // window and load it directly in the eval command. |
| invocation.iframe.contentWindow.__goofy_args = message.args; |
| invocation.iframe.contentWindow.eval( |
| `const args = window.__goofy_args; ${message.js}`); |
| if (invocation) { |
| delete invocation.iframe.contentWindow.__goofy_args; |
| } |
| }); |
| } |
| break; |
| } |
| case 'goofy:extension_rpc': { |
| const message = /** |
| * @type {{is_response: boolean, name: string, args: !Object, |
| * rpc_id: string}} |
| */ (untypedMessage); |
| if (!message.is_response) { |
| window.chrome.runtime.sendMessage( |
| cros.factory.EXTENSION_ID, |
| {name: message.name, args: message.args}, (...args) => { |
| // If an error occurs while connecting to the extension, this |
| // function would be called without arguments. In this case we |
| // should ignore this result. |
| if (args.length === 1) { |
| this.sendEvent(messageType, { |
| name: message.name, |
| rpc_id: message.rpc_id, |
| is_response: true, |
| args: args[0] |
| }); |
| } |
| }); |
| } |
| break; |
| } |
| case 'goofy:destroy_test': { |
| const message = /** @type {{invocation: string}} */ (untypedMessage); |
| // We send destroy_test event only in the top-level invocation from |
| // Goofy backend. |
| cros.factory.logger.info( |
| `Received destroy_test event for top-level invocation ${ |
| message.invocation}`); |
| const invocation = this.invocations.get(message.invocation); |
| if (invocation) { |
| invocation.dispose(); |
| } |
| break; |
| } |
| case 'goofy:pending_shutdown': { |
| const message = |
| /** @type {?cros.factory.PendingShutdownEvent} */ (untypedMessage); |
| this.setPendingShutdown(message); |
| break; |
| } |
| case 'goofy:update_notes': { |
| this.sendRpc('get_shared_data', 'factory_note', true) |
| .then((/** !Array<!cros.factory.Note> */ notes) => { |
| this.updateNote(notes); |
| }); |
| break; |
| } |
| case 'goofy:diagnosis_tool:event': { |
| const message = /** @type {!Object} */ (untypedMessage); |
| this.diagnosisTool.handleBackendEvent(message); |
| break; |
| } |
| case 'goofy:set_test_ui_layout': { |
| const message = |
| /** @type {{layout_type: string, layout_options: !Object}} */ ( |
| untypedMessage); |
| this.setTestUILayout(message.layout_type, message.layout_options); |
| break; |
| } |
| } |
| } |
| |
| /** |
| * Start the terminal session. |
| */ |
| launchTerminal() { |
| this.sendEvent('goofy:key_filter_mode', {enabled: false}); |
| |
| if (this.terminal_win) { |
| this.terminal_win.style.display = ''; |
| document.getElementById('goofy-terminal').style.opacity = 1.0; |
| return; |
| } |
| |
| const mini = goog.dom.createDom('div', 'goofy-terminal-minimize'); |
| const close = goog.dom.createDom('div', 'goofy-terminal-close'); |
| const win = goog.dom.createDom( |
| 'div', {class: 'goofy-terminal-window', id: 'goofy-terminal-window'}, |
| goog.dom.createDom('div', 'goofy-terminal-title', 'Terminal'), |
| goog.dom.createDom('div', 'goofy-terminal-control', mini, close)); |
| |
| goog.events.listen( |
| close, goog.events.EventType.MOUSEUP, this.closeTerminal.bind(this)); |
| goog.events.listen( |
| mini, goog.events.EventType.MOUSEUP, this.hideTerminal.bind(this)); |
| document.body.appendChild(win); |
| |
| const ws_url = `ws://${window.location.host}/pty`; |
| const sock = new WebSocket(ws_url); |
| |
| this.terminal_sock = sock; |
| this.terminal_win = win; |
| |
| sock.onerror = (/** !Error */ e) => { |
| goog.log.info(cros.factory.logger, 'socket error', e); |
| }; |
| jQuery(win).draggable({ |
| handle: '.goofy-terminal-title', |
| stop(/** ? */ event, /** {helper: jQuery.Type} */ ui) { |
| // Remove the width and height set by draggable, so the size is same as |
| // child size. |
| ui.helper.css('width', ''); |
| ui.helper.css('height', ''); |
| } |
| }); |
| sock.onopen = () => { |
| const term = |
| new Terminal({cols: 80, rows: 24, useStyle: true, screenKeys: true}); |
| term.open(win); |
| term.on('data', (data) => { |
| sock.send(data); |
| }); |
| sock.onmessage = ({/** string */ data}) => { |
| term.write(Base64.decode(data)); |
| }; |
| |
| const $terminal = jQuery(term.element); |
| const widthPerChar = term.element.clientWidth / term.cols; |
| const heightPerChar = term.element.clientHeight / term.rows; |
| |
| $terminal.resizable({ |
| grid: [widthPerChar, heightPerChar], |
| minWidth: 20 * widthPerChar, |
| minHeight: 5 * heightPerChar, |
| resize: ( |
| /** ? */ event, |
| /** {size: {width: number, height: number}} */ ui) => { |
| const newCols = Math.round(ui.size.width / widthPerChar); |
| const newRows = Math.round(ui.size.height / heightPerChar); |
| if (newCols !== term.cols || newRows !== term.rows) { |
| term.resize(newCols, newRows); |
| term.refresh(0, term.rows - 1); |
| |
| // Ghost uses the CONTROL_START and CONTROL_END to know the control |
| // string. |
| // format: CONTROL_START ControlString CONTROL_END |
| const CONTROL_START = 128; |
| const CONTROL_END = 129; |
| const msg = {command: 'resize', params: [newRows, newCols]}; |
| // Send to ghost to set new size |
| sock.send((new Uint8Array([CONTROL_START])).buffer); |
| sock.send(JSON.stringify(msg)); |
| sock.send((new Uint8Array([CONTROL_END])).buffer); |
| } |
| } |
| }); |
| }; |
| sock.onclose = () => { |
| this.closeTerminal(); |
| }; |
| } |
| |
| /** |
| * Close the terminal window. |
| */ |
| closeTerminal() { |
| if (this.terminal_win) { |
| this.terminal_win.remove(); |
| this.terminal_win = null; |
| this.terminal_sock.close(); |
| this.terminal_sock = null; |
| this.sendEvent('goofy:key_filter_mode', {enabled: true}); |
| } |
| } |
| |
| /** |
| * Hide the terminal window. |
| */ |
| hideTerminal() { |
| this.terminal_win.style.display = 'none'; |
| document.getElementById('goofy-terminal').style.opacity = 0.5; |
| this.sendEvent('goofy:key_filter_mode', {enabled: true}); |
| } |
| |
| /** |
| * Setup the UI for plugin. |
| * @param {!Array<!cros.factory.PluginFrontendConfig>} configs |
| */ |
| setPluginUI(configs) { |
| for (const {location, url} of configs) { |
| const pluginArea = |
| document.getElementById(`goofy-plugin-area-${location}`); |
| const newPlugin = goog.dom.createDom('div', 'goofy-plugin'); |
| const iframe = goog.asserts.assertInstanceof( |
| goog.dom.createDom( |
| 'iframe', {'class': 'goofy-plugin-iframe', 'src': url}), |
| HTMLIFrameElement); |
| pluginArea.appendChild(newPlugin); |
| newPlugin.appendChild(iframe); |
| iframe.contentWindow.plugin = new cros.factory.Plugin(this, newPlugin); |
| // TODO(pihsun): Extract these exports to iframe to a function. |
| iframe.contentWindow.cros = cros; |
| iframe.contentWindow.goog = goog; |
| iframe.contentWindow.goofy = this; |
| iframe.contentWindow._ = _; |
| iframe.onload = () => { |
| this.updateCSSClassesInDocument( |
| goog.asserts.assert(iframe.contentDocument)); |
| }; |
| iframe.contentWindow.addEventListener('focus', () => { |
| this.focusInvocation(); |
| }); |
| } |
| if (configs.some(({location}) => location === 'goofy-full')) { |
| // We need to trigger a window resize event if there's a UI with full |
| // width, so the top level splitpane would be sized properly. |
| goog.events.fireListeners( |
| window, goog.events.EventType.RESIZE, false, null); |
| } |
| } |
| }; |
| |
| /** @type {!goog.i18n.DateTimeFormat} */ |
| cros.factory.Goofy.MDHMS_TIME_FORMAT = |
| new goog.i18n.DateTimeFormat('MM/dd HH:mm:ss'); |
| |
| /** @type {!goog.i18n.DateTimeFormat} */ |
| cros.factory.Goofy.HMS_TIME_FORMAT = new goog.i18n.DateTimeFormat('HH:mm:ss'); |
| |
| /** @type {!goog.i18n.DateTimeFormat} */ |
| cros.factory.Goofy.FULL_TIME_FORMAT = |
| new goog.i18n.DateTimeFormat('yyyy-MM-dd HH:mm:ss.SSS'); |
| |
| goog.events.listenOnce(window, goog.events.EventType.LOAD, () => { |
| window.goofy = new cros.factory.Goofy(); |
| window.goofy.preInit(); |
| }); |