Skip to content

Commit

Permalink
Allow tensorboard title in header to be click target with configurabl…
Browse files Browse the repository at this point in the history
…e url (tensorflow#3249)

Allow tensorboard title in header to be clickable target with configurable href. Do basic sanitization on the input.

Co-authored-by: Stephan Lee <stephanwlee@gmail.com>
  • Loading branch information
bmd3k and stephanwlee committed Feb 21, 2020
1 parent 993f69c commit 62745f7
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 67 deletions.
210 changes: 145 additions & 65 deletions tensorboard/components/tf_tensorboard/test/tensorboardTests.ts
Expand Up @@ -16,7 +16,9 @@ namespace tf_tensorboard {
const {expect} = chai;

declare function fixture(id: string): void;
declare function flush(callback: Function): void;
declare const Polymer: any;
declare const TEST_ONLY: any;

function checkSlottedUnderAncestor(element: Element, ancestor: Element) {
expect(!!element.assignedSlot).to.be.true;
Expand All @@ -29,85 +31,163 @@ namespace tf_tensorboard {
describe('tf-tensorboard tests', () => {
window.HTMLImports.whenReady(() => {
let tensorboard: any;
beforeEach(function() {
tensorboard = fixture('tensorboardFixture');
tensorboard.demoDir = 'data';
tensorboard.autoReloadEnabled = false;
});

it('renders injected content', function() {
const overview = tensorboard.querySelector('#custom-overview');
const contentPane = tensorboard.$$('#content-pane');
checkSlottedUnderAncestor(overview, contentPane);
describe('base tests', () => {
beforeEach((done) => {
tensorboard = fixture('tensorboardFixture');
tensorboard.demoDir = 'data';
tensorboard.autoReloadEnabled = false;
flush(done);
});

const headerItem1 = tensorboard.querySelector('#custom-header-item1');
const headerItem2 = tensorboard.querySelector('#custom-header-item2');
const header = tensorboard.$$('.header');
checkSlottedUnderAncestor(headerItem1, header);
checkSlottedUnderAncestor(headerItem2, header);
});
it('renders injected content', function() {
const overview = tensorboard.querySelector('#custom-overview');
const contentPane = tensorboard.$$('#content-pane');
checkSlottedUnderAncestor(overview, contentPane);

// TODO(psybuzz): Restore/remove these old tests, which fail due to broken
// DOM ids that changed. Previously this folder's tests did not run.
xit('reloads the active dashboard on request', (done) => {
tensorboard.$.tabs.set('selected', 'scalars');
setTimeout(() => {
let called = false;
tensorboard._selectedDashboardComponent().reload = () => {
called = true;
};
tensorboard.reload();
chai.assert.isTrue(called, 'reload was called');
done();
const headerItem1 = tensorboard.querySelector('#custom-header-item1');
const headerItem2 = tensorboard.querySelector('#custom-header-item2');
const header = tensorboard.$$('.header');
checkSlottedUnderAncestor(headerItem1, header);
checkSlottedUnderAncestor(headerItem2, header);
});
});

// TODO(psybuzz): Restore/remove these old tests, which fail due to broken
// DOM ids that changed. Previously this folder's tests did not run.
xdescribe('top right global icons', function() {
it('Clicking the reload button will call reload', function() {
let called = false;
tensorboard.reload = function() {
called = true;
};
tensorboard.$$('#reload-button').click();
chai.assert.isTrue(called);
it('uses "TensorBoard-X" for title text by default', () => {
const title = tensorboard.shadowRoot.querySelector('.toolbar-title');
chai.assert.equal(title.textContent, 'TensorBoard-X');
});

it('settings pane is hidden', function() {
chai.assert.equal(tensorboard.$.settings.style['display'], 'none');
it('uses div for title element by default ', () => {
const title = tensorboard.shadowRoot.querySelector('.toolbar-title');
chai.assert.equal(title.nodeName, 'DIV');
chai.assert.isUndefined(title.href);
});

it('settings icon button opens the settings pane', function(done) {
tensorboard.$$('#settings-button').click();
// This test is a little hacky since we depend on polymer's
// async behavior, which is difficult to predict.

// keep checking until the panel is visible. error with a timeout if it
// is broken.
function verify() {
if (tensorboard.$.settings.style['display'] !== 'none') {
done();
} else {
setTimeout(verify, 3); // wait and see if it becomes true
// TODO(psybuzz): Restore/remove these old tests, which fail due to broken
// DOM ids that changed. Previously this folder's tests did not run.
xit('reloads the active dashboard on request', (done) => {
tensorboard.$.tabs.set('selected', 'scalars');
setTimeout(() => {
let called = false;
tensorboard._selectedDashboardComponent().reload = () => {
called = true;
};
tensorboard.reload();
chai.assert.isTrue(called, 'reload was called');
done();
});
});

// TODO(psybuzz): Restore/remove these old tests, which fail due to broken
// DOM ids that changed. Previously this folder's tests did not run.
xdescribe('top right global icons', function() {
it('Clicking the reload button will call reload', function() {
let called = false;
tensorboard.reload = function() {
called = true;
};
tensorboard.$$('#reload-button').click();
chai.assert.isTrue(called);
});

it('settings pane is hidden', function() {
chai.assert.equal(tensorboard.$.settings.style['display'], 'none');
});

it('settings icon button opens the settings pane', function(done) {
tensorboard.$$('#settings-button').click();
// This test is a little hacky since we depend on polymer's
// async behavior, which is difficult to predict.

// keep checking until the panel is visible. error with a timeout if it
// is broken.
function verify() {
if (tensorboard.$.settings.style['display'] !== 'none') {
done();
} else {
setTimeout(verify, 3); // wait and see if it becomes true
}
}
}
verify();
verify();
});

it('Autoreload checkbox toggle works', function() {
let checkbox = tensorboard.$$('#auto-reload-checkbox');
chai.assert.equal(checkbox.checked, tensorboard.autoReloadEnabled);
let oldValue = checkbox.checked;
checkbox.click();
chai.assert.notEqual(oldValue, checkbox.checked);
chai.assert.equal(checkbox.checked, tensorboard.autoReloadEnabled);
});

it('Autoreload checkbox contains correct interval info', function() {
let checkbox = tensorboard.$$('#auto-reload-checkbox');
let timeInSeconds = tensorboard.autoReloadIntervalSecs + 's';
chai.assert.include(checkbox.innerText, timeInSeconds);
});
});
});

describe('custom path', () => {
let sandbox: any;

beforeEach((done) => {
sandbox = sinon.sandbox.create();
sandbox.stub(TEST_ONLY.lib, 'getLocation').returns({
href: 'https://tensorboard.is/cool',
origin: 'https://tensorboard.is',
});

tensorboard = fixture('tensorboardFixture');
tensorboard.demoDir = 'data';
tensorboard.brand = 'Custom Brand';
tensorboard.autoReloadEnabled = false;
tensorboard.homePath = '/awesome';

// Branding and other components use `dom-if` which updates the dom in an
// animation frame. Flush and remove the asynchronicity.
flush(done);
});

afterEach(() => {
sandbox.restore();
});

it('uses customized brand for title', () => {
const title = tensorboard.shadowRoot.querySelector('.toolbar-title');
chai.assert.equal(title.textContent, 'Custom Brand');
});

it('uses customized path for title element ', () => {
const title = tensorboard.shadowRoot.querySelector('.toolbar-title');
chai.assert.equal(title.nodeName, 'A');
chai.assert.equal(title.href, 'https://tensorboard.is/awesome');
});

it('Autoreload checkbox toggle works', function() {
let checkbox = tensorboard.$$('#auto-reload-checkbox');
chai.assert.equal(checkbox.checked, tensorboard.autoReloadEnabled);
let oldValue = checkbox.checked;
checkbox.click();
chai.assert.notEqual(oldValue, checkbox.checked);
chai.assert.equal(checkbox.checked, tensorboard.autoReloadEnabled);
it('throws when homePath is one of bad wrong protocols', () => {
const expectedError1 =
"Expect 'homePath' to be of http: or https:. " +
'javascript:alert("PWNED!")';
chai.assert.throws(() => {
tensorboard.homePath = 'javascript:alert("PWNED!")';
}, expectedError1);

const expectedError2 =
"Expect 'homePath' to be of http: or https:. " +
'data:text/html,<img src="HEHE" onerror="alert(\'PWNED!\')" />';
chai.assert.throws(() => {
tensorboard.homePath =
'data:text/html,<img src="HEHE" onerror="alert(\'PWNED!\')" />';
}, expectedError2);
});

it('Autoreload checkbox contains correct interval info', function() {
let checkbox = tensorboard.$$('#auto-reload-checkbox');
let timeInSeconds = tensorboard.autoReloadIntervalSecs + 's';
chai.assert.include(checkbox.innerText, timeInSeconds);
it('throws when homePath is not a path', () => {
const expectedError1 =
"Expect 'homePath' be a path or have the same origin. " +
'https://tensorboard.was/good vs. https://tensorboard.is';
chai.assert.throws(() => {
tensorboard.homePath = 'https://tensorboard.was/good';
}, expectedError1);
});
});
});
Expand Down
1 change: 1 addition & 0 deletions tensorboard/components/tf_tensorboard/test/tests.html
Expand Up @@ -45,6 +45,7 @@
<autoreload-test-element></autoreload-test-element>
</template>
</test-fixture>

<script src="tensorboardTests.js"></script>
<script src="autoReloadTests.js"></script>
</body>
Expand Down
83 changes: 81 additions & 2 deletions tensorboard/components/tf_tensorboard/tf-tensorboard.html
Expand Up @@ -61,7 +61,17 @@ <h2>Settings</h2>
<paper-header-panel>
<paper-toolbar id="toolbar" slot="header" class="header">
<div id="toolbar-content" slot="top">
<div class="toolbar-title">[[brand]]</div>
<template is="dom-if" if="[[!_homePath]]">
<div class="toolbar-title">[[brand]]</div>
</template>
<template is="dom-if" if="[[_homePath]]">
<a
href="[[_homePath]]"
rel="noopener noreferrer"
class="toolbar-title"
>[[brand]]</a
>
</template>
<template is="dom-if" if="[[_activeDashboardsNotLoaded]]">
<span class="toolbar-message">
Loading active dashboards&hellip;
Expand Down Expand Up @@ -288,13 +298,22 @@ <h3>

.toolbar-title {
font-size: 20px;
margin-left: 10px;
margin-left: 6px;
/* Increase clickable area for case where title is an anchor. */
padding: 4px;
text-rendering: optimizeLegibility;
letter-spacing: -0.025em;
font-weight: 500;
display: var(--tb-toolbar-title-display, block);
}

a.toolbar-title {
/* Override default anchor color. */
color: inherit;
/* Override default anchor text-decoration. */
text-decoration: none;
}

.toolbar-message {
opacity: 0.7;
-webkit-font-smoothing: antialiased;
Expand Down Expand Up @@ -474,6 +493,24 @@ <h3>

const DATA_SELECTION_CHANGE_DEBOUNCE_MS = 200;

/** @type {Object} */
const LocationType = {};
/** @type {string} */
LocationType.href = '';
/** @type {string} */
LocationType.origin = '';

const lib = {
/** @return {LocationType} */
getLocation() {
return window.location;
},
};

const TEST_ONLY = {
lib,
};

Polymer({
is: 'tf-tensorboard',
behaviors: [tf_tensorboard.AutoReloadBehavior],
Expand All @@ -489,6 +526,20 @@ <h3>
value: 'TensorBoard-X',
},

/**
* URL for navigation when clicking on the Title in the top left corner.
* If unspecified then the Title will not be a clickable target.
*/
homePath: {
type: String,
value: '',
},

_homePath: {
type: String,
computed: '_sanitizeHomePath(homePath)',
},

/**
* Deprecated: Equivalent to 'brand' attribute.
*/
Expand Down Expand Up @@ -656,6 +707,34 @@ <h3>
'_activeDashboards, _selectedDashboard)',
],

_sanitizeHomePath(homePath) {
if (!homePath) {
return '';
}

const location = lib.getLocation();
const url = new URL(homePath, location.href);

// Do not allow javascript:, data:, or unknown protocols to render
// with Polymer data binding.
const isHttp = url.protocol === 'http:' || url.protocol === 'https:';
const sameOrigin = url.origin === location.origin;

if (!isHttp) {
throw new RangeError(
`Expect 'homePath' to be of http: or https:. ${homePath}`
);
}

if (!sameOrigin) {
throw new RangeError(
`Expect 'homePath' be a path or have the same origin. ${homePath} vs. ${location.origin}`
);
}

return isHttp && sameOrigin ? url.toString() : '';
},

_activeDashboardsUpdated(activeDashboards, selectedDashboard) {},

/**
Expand Down

0 comments on commit 62745f7

Please sign in to comment.