blob: 48f95b3171993595bb89ed33f2434f612edbbe5b [file] [log] [blame]
// Copyright (c) 2012 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.debug.ErrorHandler');
goog.require('goog.debug.FancyWindow');
goog.require('goog.debug.Logger');
goog.require('goog.dom');
goog.require('goog.dom.classes');
goog.require('goog.dom.iframe');
goog.require('goog.events');
goog.require('goog.events.EventHandler');
goog.require('goog.json');
goog.require('goog.net.WebSocket');
goog.require('goog.net.XhrIo');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.Uri');
goog.require('goog.ui.AdvancedTooltip');
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.Select');
goog.require('goog.ui.SplitPane');
goog.require('goog.ui.tree.TreeControl');
cros.factory.logger = goog.debug.Logger.getLogger('cros.factory');
/**
* @define {boolean} Whether to automatically collapse items once tests have
* completed.
*/
cros.factory.AUTO_COLLAPSE = false;
/**
* 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;
/**
* Width of the control panel, as a fraction of the viewport size.
* @type number
*/
cros.factory.CONTROL_PANEL_WIDTH_FRACTION = 0.2;
/**
* Height of the log pane, as a fraction of the viewport size.
* @type number
*/
cros.factory.LOG_PANE_HEIGHT_FRACTION = 0.2;
/**
* Makes a label that displays English (or optionally Chinese).
* @param {string} en
* @param {string=} zh
*/
cros.factory.Label = function(en, zh) {
return '<span class="goofy-label-en">' + en + '</span>' +
'<span class="goofy-label-zh">' + (zh || en) + '</span>';
};
/**
* Makes control content that displays English (or optionally Chinese).
* @param {string} en
* @param {string=} zh
* @return {Node}
*/
cros.factory.Content = function(en, zh) {
var span = document.createElement('span');
span.innerHTML = cros.factory.Label(en, zh);
return span;
};
/**
* Labels for items in system info.
* @type Array.<Object.<string, string>>
*/
cros.factory.SYSTEM_INFO_LABELS = [
{key: 'serial_number', label: cros.factory.Label('Serial Number')},
{key: 'factory_image_version',
label: cros.factory.Label('Factory Image Version')},
{key: 'wlan0_mac', label: cros.factory.Label('WLAN MAC')},
{key: 'kernel_version', label: cros.factory.Label('Kernel')},
{key: 'ec_version', label: cros.factory.Label('EC')},
{key: 'firmware_version', label: cros.factory.Label('Firmware')},
{key: 'factory_md5sum', label: cros.factory.Label('Factory MD5SUM'),
transform: function(value) {
return value || cros.factory.Label('(no update)')
}}
];
cros.factory.UNKNOWN_LABEL = '<span class="goofy-unknown">' +
cros.factory.Label('Unknown') + '</span>';
/**
* An item in the test list.
* @typedef {{path: string, label_en: string, label_zh: string,
* kbd_shortcut: string, subtests: Array}}
*/
cros.factory.TestListEntry;
/**
* Public API for tests.
* @constructor
* @param {cros.factory.Invocation} invocation
*/
cros.factory.Test = function(invocation) {
/**
* @type cros.factory.Invocation
*/
this.invocation = invocation;
};
/**
* Passes the test.
* @export
*/
cros.factory.Test.prototype.pass = function() {
this.invocation.goofy.sendEvent(
'goofy:end_test', {
'status': 'PASSED',
'invocation': this.invocation.uuid,
'test': this.invocation.path
});
this.invocation.dispose();
};
/**
* Fails the test with the given error message.
* @export
* @param {string} errorMsg
*/
cros.factory.Test.prototype.fail = function(errorMsg) {
this.invocation.goofy.sendEvent('goofy:end_test', {
'status': 'FAILED',
'error_msg': errorMsg,
'invocation': this.invocation.uuid,
'test': this.invocation.path
});
this.invocation.dispose();
};
/**
* Sends an event to the test backend.
* @export
* @param {string} subtype the event type
* @param {string} data the event data
*/
cros.factory.Test.prototype.sendTestEvent = function(subtype, data) {
this.invocation.goofy.sendEvent('goofy:test_ui_event', {
'test': this.invocation.path,
'invocation': this.invocation.uuid,
'subtype': subtype,
'data': data
});
};
/**
* UI for a single test invocation.
* @constructor
* @param {cros.factory.Goofy} goofy
* @param {string} path
*/
cros.factory.Invocation = function(goofy, path, uuid) {
/**
* Reference to the Goofy object.
* @type cros.factory.Goofy
*/
this.goofy = goofy;
/**
* @type string
*/
this.path = path;
/**
* UUID of the invocation.
* @type string
*/
this.uuid = uuid;
/**
* Test API for the invocation.
*/
this.test = new cros.factory.Test(this);
/**
* The iframe containing the test.
* @type HTMLIFrameElement
*/
this.iframe = goog.dom.iframe.createBlank(new goog.dom.DomHelper(document));
document.getElementById('goofy-main').appendChild(this.iframe);
this.iframe.contentWindow.test = this.test;
};
/**
* Disposes of the invocation (and destroys the iframe).
*/
cros.factory.Invocation.prototype.dispose = function() {
if (this.iframe) {
goog.dom.removeNode(this.iframe);
this.goofy.invocations[this.uuid] = null;
this.iframe = null;
}
};
/**
* The main Goofy UI.
*
* @constructor
*/
cros.factory.Goofy = function() {
/**
* The WebSocket we'll use to communicate with the backend.
* @type goog.net.WebSocket
*/
this.ws = new goog.net.WebSocket();
/**
* Whether we have opened the WebSocket yet.
* @type boolean
*/
this.wsOpened = false;
/**
* The UUID that we received from Goofy when starting up.
* @type {?string}
*/
this.uuid = null;
/**
* Whether the context menu is currently visible.
* @type boolean
*/
this.contextMenuVisible = false;
/**
* All tooltips that we have created.
* @type Array.<goog.ui.AdvancedTooltip>
*/
this.tooltips = [];
/**
* The test tree.
*/
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 = new Object();
/**
* A map from test path to the entry in the test list for that test.
* @type Object.<string, cros.factory.TestListEntry>
*/
this.pathTestMap = new Object();
/**
* Whether Chinese mode is currently enabled.
*
* TODO(jsalz): Generalize this to multiple languages (but this isn't
* really necessary now).
*
* @type boolean
*/
this.zhMode = false;
/**
* The tooltip for version number information.
*/
this.infoTooltip = new goog.ui.AdvancedTooltip(
document.getElementById('goofy-system-info-hover'));
this.infoTooltip.setHtml('Version information not yet available.');
/**
* UIs for individual test invocations (by UUID).
* @type Object.<string, cros.factory.Invocation>
*/
this.invocations = {};
var debugWindow = new goog.debug.FancyWindow('main');
debugWindow.setEnabled(false);
debugWindow.init();
// Magic keyboard shortcut Ctrl-Alt-1 to open the debugging window.
goog.events.listen(
window, goog.events.EventType.KEYDOWN,
function(event) {
if (event.altKey && event.ctrlKey &&
'1' == String.fromCharCode(event.keyCode)) {
debugWindow.setEnabled(true);
}
}, false, this);
};
/**
* Initializes the split panes.
*/
cros.factory.Goofy.prototype.initSplitPanes = function() {
var viewportSize = goog.dom.getViewportSize(goog.dom.getWindow(document));
var mainComponent = new goog.ui.Component();
var consoleComponent = new goog.ui.Component();
var mainAndConsole = new goog.ui.SplitPane(
mainComponent, consoleComponent,
goog.ui.SplitPane.Orientation.VERTICAL);
mainAndConsole.setInitialSize(viewportSize.height *
(1 - cros.factory.LOG_PANE_HEIGHT_FRACTION));
var controlComponent = new goog.ui.Component();
var topSplitPane = new goog.ui.SplitPane(
controlComponent, mainAndConsole,
goog.ui.SplitPane.Orientation.HORIZONTAL);
topSplitPane.setInitialSize(viewportSize.width *
cros.factory.CONTROL_PANEL_WIDTH_FRACTION);
topSplitPane.decorate(document.getElementById('goofy-splitpane'));
mainComponent.getElement().id = 'goofy-main';
consoleComponent.getElement().id = 'goofy-console';
this.console = consoleComponent.getElement();
this.main = mainComponent.getElement();
var propagate = true;
goog.events.listen(
topSplitPane, goog.ui.Component.EventType.CHANGE,
function(event) {
if (!propagate) {
// Prevent infinite recursion
return;
}
propagate = false;
mainAndConsole.setFirstComponentSize(
mainAndConsole.getFirstComponentSize());
propagate = true;
var rect = mainComponent.getElement().getBoundingClientRect();
this.sendRpc('get_shared_data', ['ui_scale_factor'],
function(uiScaleFactor) {
this.sendRpc('set_shared_data',
['test_widget_size',
[rect.width * uiScaleFactor,
rect.height * uiScaleFactor],
'test_widget_position',
[rect.left * uiScaleFactor,
rect.top * uiScaleFactor]]);
});
}, false, this);
mainAndConsole.setFirstComponentSize(
mainAndConsole.getFirstComponentSize());
goog.events.listen(
window, goog.events.EventType.RESIZE,
function(event) {
topSplitPane.setSize(
goog.dom.getViewportSize(goog.dom.getWindow(document) ||
window));
});
}
/**
* Initializes the WebSocket.
*/
cros.factory.Goofy.prototype.initWebSocket = function() {
goog.events.listen(this.ws, goog.net.WebSocket.EventType.OPENED,
function(event) {
this.logInternal('Connection to Goofy opened.');
this.wsOpened = true;
}, false, this);
goog.events.listen(this.ws, goog.net.WebSocket.EventType.ERROR,
function(event) {
this.logInternal('Error connecting to Goofy.');
}, false, this);
goog.events.listen(this.ws, goog.net.WebSocket.EventType.CLOSED,
function(event) {
if (this.wsOpened) {
this.logInternal('Connection to Goofy closed.');
this.wsOpened = false;
}
}, false, this);
goog.events.listen(this.ws, goog.net.WebSocket.EventType.MESSAGE,
function(event) {
this.handleBackendEvent(event.message);
}, false, this);
window.setInterval(goog.bind(this.keepAlive, this),
cros.factory.KEEP_ALIVE_INTERVAL_MSEC);
this.ws.open("ws://" + window.location.host + "/event");
};
/**
* Starts the UI.
*/
cros.factory.Goofy.prototype.init = function() {
this.initLanguageSelector();
this.initSplitPanes();
// Listen for keyboard shortcuts.
goog.events.listen(
window, goog.events.EventType.KEYDOWN,
function(event) {
if (event.altKey || event.ctrlKey) {
this.handleShortcut(String.fromCharCode(event.keyCode));
}
}, false, this);
this.initWebSocket();
this.sendRpc('get_test_list', [], this.setTestList);
this.sendRpc('get_shared_data', ['system_info'], this.setSystemInfo);
};
/**
* Sets up the language selector.
*/
cros.factory.Goofy.prototype.initLanguageSelector = function() {
goog.events.listen(
document.getElementById('goofy-language-selector'),
goog.events.EventType.CLICK,
function(event) {
this.zhMode = !this.zhMode;
this.updateLanguage();
this.sendRpc('set_shared_data',
['ui_lang', this.zhMode ? 'zh' : 'en']);
}, false, this);
this.updateLanguage();
this.sendRpc('get_shared_data', ['ui_lang'], function(lang) {
this.zhMode = lang == 'zh';
this.updateLanguage();
});
};
/**
* Gets an invocation for a test (creating it if necessary).
*
* @param {string} path
* @param {string} invocationUuid
* @return the invocation, or null if the invocation has already been created
* and deleted.
*/
cros.factory.Goofy.prototype.getOrCreateInvocation = function(
path, invocationUuid) {
if (!(invocationUuid in this.invocations)) {
cros.factory.logger.info('Creating UI for test ' + path +
' (invocation ' + invocationUuid);
this.invocations[invocationUuid] =
new cros.factory.Invocation(this, path, invocationUuid);
}
return this.invocations[invocationUuid];
};
/**
* Updates language classes in the UI based on the current value of
* zhMode.
*/
cros.factory.Goofy.prototype.updateLanguage = function() {
goog.dom.classes.enable(document.body, 'goofy-lang-en', !this.zhMode);
goog.dom.classes.enable(document.body, 'goofy-lang-zh', this.zhMode);
}
/**
* Updates the system info tooltip.
* @param systemInfo Object.<string, string>
*/
cros.factory.Goofy.prototype.setSystemInfo = function(systemInfo) {
var table = [];
table.push('<table id="goofy-system-info">');
goog.array.forEach(cros.factory.SYSTEM_INFO_LABELS, function(item) {
var value = systemInfo[item.key];
var html;
if (item.transform) {
html = item.transform(value);
} else {
html = value == undefined ?
cros.factory.UNKNOWN_LABEL :
goog.string.htmlEscape(value);
}
table.push(
'<tr><th>' + item.label + '</th><td>' + html +
'</td></tr>');
});
table.push('</table>');
this.infoTooltip.setHtml(table.join(''));
};
/**
* Handles a keyboard shortcut.
* @param {string} key the key that was depressed (e.g., 'a' for Alt-A).
*/
cros.factory.Goofy.prototype.handleShortcut = function(key) {
for (var path in this.pathTestMap) {
var test = this.pathTestMap[path];
if (test.kbd_shortcut &&
test.kbd_shortcut.toLowerCase() == key.toLowerCase()) {
this.sendEvent('goofy:restart_tests', {path: path});
return;
}
}
};
/**
* Makes a menu item for a context-sensitive menu.
*
* TODO(jsalz): Figure out the correct logic for this and how to localize this.
* (Please just consider this a rough cut for now!)
*
* @param {string} verbEn the action in English.
* @param {string} verbZh the action in Chinese.
* @param {string} adjectiveEn a descriptive adjective for the tests (e.g.,
* 'failed').
* @param {string} adjectiveZh the adjective in Chinese.
* @param {number} count the number of tests.
* @param {cros.factory.TestListEntry} test the name of the root node containing
* the tests.
* @param {Object} handler the handler function (see goog.events.listen).
*/
cros.factory.Goofy.prototype.makeMenuItem = function(
verbEn, verbZh, adjectiveEn, adjectiveZh, count, test, handler) {
var labelEn = verbEn + ' ';
var labelZh = verbZh;
if (!test.subtests.length) {
// leaf node
labelEn += adjectiveEn + ' test ' + test.label_en;
labelZh += adjectiveZh + '測試';
} else {
labelEn += count + ' ' + adjectiveEn + ' ' +
(count == 1 ? 'test' : 'tests');
if (test.label_en) {
labelEn += ' in "' + goog.string.htmlEscape(test.label_en) + '"';
}
labelZh += count + '個' + adjectiveZh;
if (test.label_en || test.label_zh) {
labelZh += ('在“' +
goog.string.htmlEscape(test.label_en || test.label_zh) +
'”裡面的');
}
labelZh += '測試';
}
var item = new goog.ui.MenuItem(cros.factory.Content(labelEn, labelZh));
item.setEnabled(count != 0);
goog.events.listen(item, goog.ui.Component.EventType.ACTION,
handler, true, this);
return item;
};
/**
* Displays test logs in a modal dialog.
* @param {Array.<string>} paths paths whose logs should be displayed.
* (The first entry should be the root; its name will be used as the
* title.)
*/
cros.factory.Goofy.prototype.showTestLogs = function(paths) {
this.sendRpc('get_test_history', [paths], function(history) {
var dialog = new goog.ui.Dialog();
if (history.length) {
var viewSize = goog.dom.getViewportSize(
goog.dom.getWindow(document) || window);
var maxWidth = viewSize.width * 0.75;
var maxHeight = viewSize.height * 0.75;
var content = [
'<dl class="goofy-history" style="max-width: ' +
maxWidth + 'px; max-height: ' + maxHeight + 'px">'
];
goog.array.forEach(history, function(item) {
content.push('<dt class="goofy-history-item history-item-' +
item.state.status +
'">' + goog.string.htmlEscape(item.path) +
' (run ' +
item.state.count + ')</dt>');
content.push('<dd>' + goog.string.htmlEscape(item.log) +
'</dd>');
}, this);
content.push('</dl>');
dialog.setContent(content.join(''));
} else {
dialog.setContent('<div class="goofy-history-none">' +
'No test runs have completed yet.</div>');
}
dialog.setTitle(
'Logs for ' + (paths[0] == '' ? 'all tests' :
'"' + goog.string.htmlEscape(paths[0]) + '"'));
dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk())
dialog.setVisible(true);
});
};
/**
* 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.
*/
cros.factory.Goofy.prototype.showTestPopup = function(path, labelElement,
extraItems) {
this.contextMenuVisible = true;
// Hide all tooltips so that they don't fight with the context menu.
goog.array.forEach(this.tooltips, function(tooltip) {
tooltip.setVisible(false);
});
var menu = new goog.ui.PopupMenu();
if (extraItems && extraItems.length) {
goog.array.forEach(extraItems, function(item) {
menu.addChild(item, true);
}, this);
menu.addChild(new goog.ui.MenuSeparator(), true);
}
var numLeaves = 0;
var numLeavesByStatus = {};
var test = this.pathTestMap[path];
var allPaths = [];
function countLeaves(test) {
allPaths.push(test.path);
goog.array.forEach(test.subtests, function(subtest) {
countLeaves(subtest);
}, this);
if (!test.subtests.length) {
++numLeaves;
numLeavesByStatus[test.state.status] = 1 + (
numLeavesByStatus[test.state.status] || 0);
}
}
countLeaves(test);
var restartOrRunEn = numLeavesByStatus['UNTESTED'] == numLeaves ?
'Run' : 'Restart';
var restartOrRunZh = numLeavesByStatus['UNTESTED'] == numLeaves ?
'執行' : '重跑';
if (numLeaves > 1) {
restartOrRunEn += ' all';
restartOrRunZh += '所有的';
}
menu.addChild(this.makeMenuItem(restartOrRunEn, restartOrRunZh,
'', '',
numLeaves, test,
function(event) {
this.sendEvent('goofy:restart_tests', {'path': path});
}), true);
if (test.subtests.length) {
// Only show for parents.
menu.addChild(this.makeMenuItem(
'Restart', '重跑', 'failed', '已失敗的',
numLeavesByStatus['FAILED'] || 0,
test, function(event) {
this.sendEvent('goofy:re_run_failed', {'path': path});
}), true);
menu.addChild(this.makeMenuItem(
'Run', '執行', 'untested', '未測的',
(numLeavesByStatus['UNTESTED'] || 0 +
numLeavesByStatus['ACTIVE'] || 0),
test, function(event) {
this.sendEvent('goofy:auto_run', {'path': path});
}), true);
}
menu.addChild(new goog.ui.MenuSeparator(), true);
// TODO(jsalz): This isn't quite right since it stops all tests.
// But close enough for now.
menu.addChild(this.makeMenuItem('Stop', '停止', 'active', '正在跑的',
numLeavesByStatus['ACTIVE'] || 0,
test, function(event) {
this.sendEvent('goofy:stop');
}), true);
var item = new goog.ui.MenuItem('Show test logs...');
item.setEnabled(test.state.status != 'UNTESTED');
goog.events.listen(item, goog.ui.Component.EventType.ACTION,
function(event) {
this.showTestLogs(allPaths);
}, true, this);
// Disable 'Show test logs...' for now since it is presented
// behind the running test; we'd need to hide test to show it
// properly. TODO(jsalz): Re-enable.
// menu.addChild(item, true);
menu.render(document.body);
menu.showAtElement(labelElement,
goog.positioning.Corner.BOTTOM_LEFT,
goog.positioning.Corner.TOP_LEFT);
goog.events.listen(menu, goog.ui.Component.EventType.HIDE,
function(event) {
menu.dispose();
this.contextMenuVisible = false;
}, true, this);
};
/**
* 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
* @param {goog.events.Event} event the BEFORE_SHOW event that will cause the
* tooltip to be displayed.
*/
cros.factory.Goofy.prototype.updateTestToolTip =
function(path, tooltip, event) {
var test = this.pathTestMap[path];
tooltip.setHtml('')
var errorMsg = test.state['error_msg'];
if (test.state.status != 'FAILED' || this.contextMenuVisible || !errorMsg) {
// Don't bother showing it.
event.preventDefault();
} else {
// Show the last failure.
var lines = errorMsg.split('\n');
var html = ('Failure in "' + test.label_en + '":' +
'<div class="goofy-test-failure">' +
goog.string.htmlEscape(lines.shift()) + '</span>');
if (lines.length) {
html += ('<div class="goofy-test-failure-detail-link">' +
'Show more detail...</div>' +
'<div class="goofy-test-failure-detail">' +
goog.string.htmlEscape(lines.join('\n')) + '</div>');
}
tooltip.setHtml(html);
if (lines.length) {
var link = goog.dom.getElementByClass(
'goofy-test-failure-detail-link', tooltip.getElement());
goog.events.listen(
link, goog.events.EventType.CLICK,
function(event) {
goog.dom.classes.add(tooltip.getElement(),
'goofy-test-failure-expanded');
tooltip.reposition();
}, true, this);
}
}
};
/**
* 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 get_test_list RPC call).
*/
cros.factory.Goofy.prototype.setTestList = function(testList) {
cros.factory.logger.info('Received test list: ' +
goog.debug.expose(testList));
goog.style.showElement(document.getElementById('goofy-loading'), false);
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'));
var addListener = goog.bind(function(path, labelElement, rowElement) {
var tooltip = new goog.ui.AdvancedTooltip(rowElement);
tooltip.setHideDelayMs(1000);
this.tooltips.push(tooltip);
goog.events.listen(
tooltip, goog.ui.Component.EventType.BEFORE_SHOW,
function(event) {
this.updateTestToolTip(path, tooltip, event);
}, true, this)
goog.events.listen(
rowElement, goog.events.EventType.CONTEXTMENU,
function(event) {
this.showTestPopup(path, labelElement);
event.stopPropagation();
event.preventDefault();
}, true, this);
goog.events.listen(
labelElement, goog.events.EventType.MOUSEDOWN,
function(event) {
this.showTestPopup(path, labelElement);
event.stopPropagation();
event.preventDefault();
}, true, this);
}, this);
for (var path in this.pathNodeMap) {
var node = this.pathNodeMap[path];
addListener(path, node.getLabelElement(), node.getRowElement());
}
goog.array.forEach([goog.events.EventType.MOUSEDOWN,
goog.events.EventType.CONTEXTMENU],
function(eventType) {
goog.events.listen(
document.getElementById('goofy-title'),
eventType,
function(event) {
var updateItem = new goog.ui.MenuItem(
cros.factory.Content('Update factory software',
'更新工廠軟體'));
goog.events.listen(
updateItem, goog.ui.Component.EventType.ACTION,
function(event) {
this.sendEvent('goofy:update_factory', {});
}, true, this);
this.showTestPopup(
'', document.getElementById('goofy-logo-text'),
[updateItem]);
event.stopPropagation();
event.preventDefault();
}, true, this);
}, this);
this.testTree.collapseAll();
this.sendRpc('get_test_states', [], function(stateMap) {
for (var path in stateMap) {
if (!goog.string.startsWith(path, "_")) { // e.g., __jsonclass__
this.setTestState(path, stateMap[path]);
}
}
});
};
/**
* Sets the state for a particular test.
* @param {string} path
* @param {Object.<string, Object>} state the TestState object (contained in
* an event or as a response to the RPC call).
*/
cros.factory.Goofy.prototype.setTestState = function(path, state) {
var node = this.pathNodeMap[path];
if (!node) {
cros.factory.logger.warning('No node found for test path ' + path);
return;
}
var elt = this.pathNodeMap[path].getElement();
var test = this.pathTestMap[path];
test.state = state;
// Assign the appropriate class to the node, and remove all other
// status classes.
goog.dom.classes.addRemove(
elt,
goog.array.filter(
goog.dom.classes.get(elt),
function(cls) {
return goog.string.startsWith(cls, "goofy-status-") && cls
}),
'goofy-status-' + state.status.toLowerCase());
if (state.status == 'ACTIVE') {
// Automatically show the test if it is running.
node.reveal();
} else if (cros.factory.AUTO_COLLAPSE) {
// If collapsible, then collapse it in 250ms if still inactive.
if (node.getChildCount() != 0) {
window.setTimeout(function(event) {
if (test.state.status != 'ACTIVE') {
node.collapse();
}
}, 250);
}
}
};
/**
* Adds a test node to the tree.
* @param {goog.ui.tree.BaseNode} parent
* @param {cros.factory.TestListEntry} test
*/
cros.factory.Goofy.prototype.addToNode = function(parent, test) {
var node;
if (parent == null) {
node = this.testTree;
} else {
var label = '<span class="goofy-label-en">' +
goog.string.htmlEscape(test.label_en) + '</span>';
label += '<span class="goofy-label-zh">' +
goog.string.htmlEscape(test.label_zh || test.label_en) + '</span>';
if (test.kbd_shortcut) {
label = '<span class="goofy-kbd-shortcut">Alt-' +
goog.string.htmlEscape(test.kbd_shortcut.toUpperCase()) +
'</span>' + label;
}
node = this.testTree.createNode(label);
parent.addChild(node);
}
goog.array.forEach(test.subtests, function(subtest) {
this.addToNode(node, subtest);
}, this);
node.setIconClass('goofy-test-icon');
node.setExpandedIconClass('goofy-test-icon');
this.pathNodeMap[test.path] = node;
this.pathTestMap[test.path] = test;
node.factoryTest = test;
};
/**
* Sends an event to Goofy.
* @param {string} type the event type (e.g., 'goofy:hello').
* @param {Object} properties of event.
*/
cros.factory.Goofy.prototype.sendEvent = function(type, properties) {
var dict = goog.object.clone(properties);
dict.type = type;
var serialized = goog.json.serialize(dict);
cros.factory.logger.info('Sending event: ' + serialized);
this.ws.send(serialized);
};
/**
* Calls an RPC function and invokes callback with the result.
* @param {Object} args
* @param {Object=} callback
*/
cros.factory.Goofy.prototype.sendRpc = function(method, args, callback) {
var request = goog.json.serialize({method: method, params: args, id: 1});
cros.factory.logger.info('RPC request: ' + request);
var factoryThis = this;
goog.net.XhrIo.send(
'/', function() {
cros.factory.logger.info('RPC response for ' + method + ': ' +
this.getResponseText());
// TODO(jsalz): handle errors
if (callback) {
callback.call(
factoryThis,
goog.json.unsafeParse(this.getResponseText()).result);
}
},
'POST', request);
};
/**
* Sends a keepalive event if the web socket is open.
*/
cros.factory.Goofy.prototype.keepAlive = function() {
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.
*/
cros.factory.Goofy.prototype.logToConsole = function(message, opt_attributes) {
var div = goog.dom.createDom('div', opt_attributes);
goog.dom.classes.add(div, 'goofy-log-line');
div.appendChild(document.createTextNode(message));
this.console.appendChild(div);
// Scroll to bottom. TODO(jsalz): Scroll only if already at the bottom,
// or add scroll lock.
var scrollPane = goog.dom.getAncestorByClass(this.console,
'goog-splitpane-second-container');
scrollPane.scrollTop = scrollPane.scrollHeight;
};
/**
* Logs an "internal" message to the console (as opposed to a line from
* console.log).
*/
cros.factory.Goofy.prototype.logInternal = function(message) {
this.logToConsole(message, 'goofy-internal-log');
};
/**
* Handles an event sends from the backend.
* @param {string} jsonMessage the message as a JSON string.
*/
cros.factory.Goofy.prototype.handleBackendEvent = function(jsonMessage) {
cros.factory.logger.info('Got message: ' + jsonMessage);
var message = /** @type Object.<string, Object> */ (
goog.json.unsafeParse(jsonMessage));
if (message.type == 'goofy:hello') {
if (this.uuid && message.uuid != this.uuid) {
// The goofy process has changed; reload the page.
cros.factory.logger.info('Incorrect UUID; reloading');
window.location.reload();
return;
} else {
this.uuid = message.uuid;
// Send a keepAlive to confirm the UUID with the backend.
this.keepAlive();
// TODO(jsalz): Process version number information.
}
} else if (message.type == 'goofy:log') {
this.logToConsole(message.message);
} else if (message.type == 'goofy:state_change') {
this.setTestState(message.path, message.state);
} else if (message.type == 'goofy:set_html') {
var invocation = this.getOrCreateInvocation(
message.test, message.invocation);
if (invocation) {
invocation.iframe.contentDocument.body.innerHTML = message['html'];
}
} else if (message.type == 'goofy:run_js') {
var invocation = this.getOrCreateInvocation(
message.test, message.invocation);
if (invocation) {
invocation.iframe.contentWindow.eval(
/** @type string */ (message['js']));
}
} else if (message.type == 'goofy:call_js_function') {
var invocation = this.getOrCreateInvocation(
message.test, message.invocation);
if (invocation) {
var func = invocation.iframe.contentWindow[message['name']];
if (func) {
func.apply(this, message['args']);
} else {
cros.factory.logger.severe('Unable to find function ' + func +
' in UI for test ' + message.test);
}
}
} else if (message.type == 'goofy:destroy_test') {
var invocation = this.invocations[message.invocation];
if (invocation) {
invocation.dispose();
}
} else if (message.type == 'goofy:system_info') {
this.setSystemInfo(message['system_info']);
}
};
goog.events.listenOnce(window, goog.events.EventType.LOAD, function() {
window.goofy = new cros.factory.Goofy();
window.goofy.init();
});