diff --git a/package.json b/package.json index 743134cea027a..1380678cc5d74 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "auto-release-sinon": "1.0.3", "babel-eslint": "4.1.8", "chai": "3.5.0", + "cheerio": "0.22.0", "chokidar": "1.6.0", "chromedriver": "2.24.1", "elasticdump": "2.1.1", diff --git a/src/server/status/states.js b/src/server/status/states.js index 8c6f3fb90b09d..433faaf3c4728 100644 --- a/src/server/status/states.js +++ b/src/server/status/states.js @@ -45,7 +45,7 @@ exports.all = [ severity: -1, icon: 'toggle-off', nicknames: [ - 'I\'m I even a thing?' + 'Am I even a thing?' ] } ]; diff --git a/src/ui/__tests__/fixtures/test_app/index.js b/src/ui/__tests__/fixtures/test_app/index.js new file mode 100644 index 0000000000000..4f5115faff151 --- /dev/null +++ b/src/ui/__tests__/fixtures/test_app/index.js @@ -0,0 +1,13 @@ +module.exports = kibana => new kibana.Plugin({ + uiExports: { + app: { + name: 'test_app', + main: 'plugins/test_app/index.js', + injectVars() { + return { + from_test_app: true + }; + } + } + } +}); diff --git a/src/ui/__tests__/fixtures/test_app/package.json b/src/ui/__tests__/fixtures/test_app/package.json new file mode 100644 index 0000000000000..3aeb029e4f4cc --- /dev/null +++ b/src/ui/__tests__/fixtures/test_app/package.json @@ -0,0 +1,4 @@ +{ + "name": "test_app", + "version": "kibana" +} diff --git a/src/ui/__tests__/fixtures/test_app/public/index.js b/src/ui/__tests__/fixtures/test_app/public/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/ui/__tests__/ui_exports_replace_injected_vars.js b/src/ui/__tests__/ui_exports_replace_injected_vars.js new file mode 100644 index 0000000000000..fe578642eae84 --- /dev/null +++ b/src/ui/__tests__/ui_exports_replace_injected_vars.js @@ -0,0 +1,125 @@ +import { resolve } from 'path'; + +import { delay } from 'bluebird'; +import expect from 'expect.js'; +import sinon from 'sinon'; +import cheerio from 'cheerio'; +import { noop } from 'lodash'; + +import KbnServer from '../../server/kbn_server'; + +const getInjectedVarsFromResponse = (resp) => { + const $ = cheerio.load(resp.payload); + const data = $('kbn-initial-state').attr('data'); + return JSON.parse(data).vars; +}; + +const injectReplacer = (kbnServer, replacer) => { + // normally the replacer would be defined in a plugin's uiExports, + // but that requires stubbing out an entire plugin directory for + // each test, so we fake it and jam the replacer into uiExports + kbnServer.uiExports.injectedVarsReplacers.push(replacer); +}; + +describe('UiExports', function () { + describe('#replaceInjectedVars', function () { + this.slow(2000); + this.timeout(10000); + + let kbnServer; + beforeEach(async () => { + kbnServer = new KbnServer({ + server: { port: 0 }, // pick a random open port + logging: { silent: true }, // no logs + optimize: { enabled: false }, + uiSettings: { enabled: false }, + plugins: { + paths: [resolve(__dirname, './fixtures/test_app')] // inject an app so we can hit /app/{id} + }, + }); + + await kbnServer.ready(); + kbnServer.status.get('ui settings').state = 'green'; + kbnServer.server.decorate('server', 'uiSettings', () => { + return { getDefaults: noop }; + }); + }); + + afterEach(async () => { + await kbnServer.close(); + kbnServer = null; + }); + + it('allows sync replacing of injected vars', async () => { + injectReplacer(kbnServer, () => ({ a: 1 })); + + const resp = await kbnServer.inject('/app/test_app'); + const injectedVars = getInjectedVarsFromResponse(resp); + + expect(injectedVars).to.eql({ a: 1 }); + }); + + it('allows async replacing of injected vars', async () => { + const asyncThing = () => delay(100).return('world'); + + injectReplacer(kbnServer, async () => { + return { + hello: await asyncThing() + }; + }); + + const resp = await kbnServer.inject('/app/test_app'); + const injectedVars = getInjectedVarsFromResponse(resp); + + expect(injectedVars).to.eql({ + hello: 'world' + }); + }); + + it('passes originalInjectedVars, request, and server to replacer', async () => { + const stub = sinon.stub(); + injectReplacer(kbnServer, () => ({ foo: 'bar' })); + injectReplacer(kbnServer, stub); + + await kbnServer.inject('/app/test_app'); + + sinon.assert.calledOnce(stub); + expect(stub.firstCall.args[0]).to.eql({ foo: 'bar' }); // originalInjectedVars + expect(stub.firstCall.args[1]).to.have.property('path', '/app/test_app'); // request + expect(stub.firstCall.args[1]).to.have.property('server', kbnServer.server); // request + expect(stub.firstCall.args[2]).to.be(kbnServer.server); + }); + + it('calls the methods sequentially', async () => { + injectReplacer(kbnServer, () => ({ name: '' })); + injectReplacer(kbnServer, orig => ({ name: orig.name + 's' })); + injectReplacer(kbnServer, orig => ({ name: orig.name + 'a' })); + injectReplacer(kbnServer, orig => ({ name: orig.name + 'm' })); + + const resp = await kbnServer.inject('/app/test_app'); + const injectedVars = getInjectedVarsFromResponse(resp); + + expect(injectedVars).to.eql({ name: 'sam' }); + }); + + it('propogates errors thrown in replacers', async () => { + injectReplacer(kbnServer, async () => { + await delay(100); + throw new Error('replacer failed'); + }); + + const resp = await kbnServer.inject('/app/test_app'); + expect(resp).to.have.property('statusCode', 500); + }); + + it('starts off with the injected vars for the app merged with the default injected vars', async () => { + const stub = sinon.stub(); + injectReplacer(kbnServer, stub); + kbnServer.uiExports.defaultInjectedVars.from_defaults = true; + + const resp = await kbnServer.inject('/app/test_app'); + sinon.assert.calledOnce(stub); + expect(stub.firstCall.args[0]).to.eql({ from_defaults: true, from_test_app: true }); + }); + }); +}); diff --git a/src/ui/index.js b/src/ui/index.js index 1325681343c52..c88ec53c82e6f 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -2,6 +2,7 @@ import { format as formatUrl } from 'url'; import { readFileSync as readFile } from 'fs'; import { defaults } from 'lodash'; import Boom from 'boom'; +import { reduce as reduceAsync } from 'bluebird'; import { resolve } from 'path'; import fromRoot from '../utils/from_root'; import UiExports from './ui_exports'; @@ -43,15 +44,19 @@ export default async (kbnServer, server, config) => { server.route({ path: '/app/{id}', method: 'GET', - handler: function (req, reply) { + async handler(req, reply) { const id = req.params.id; const app = uiExports.apps.byId[id]; if (!app) return reply(Boom.notFound('Unknown app ' + id)); - if (kbnServer.status.isGreen()) { - return reply.renderApp(app); - } else { - return reply.renderStatusPage(); + try { + if (kbnServer.status.isGreen()) { + await reply.renderApp(app); + } else { + await reply.renderStatusPage(); + } + } catch (err) { + reply(Boom.wrap(err)); } } }); @@ -70,7 +75,11 @@ export default async (kbnServer, server, config) => { defaults: await server.uiSettings().getDefaults(), user: {} }, - vars: defaults(app.getInjectedVars() || {}, uiExports.defaultInjectedVars), + vars: await reduceAsync( + uiExports.injectedVarsReplacers, + async (acc, replacer) => await replacer(acc, this.request, server), + defaults(await app.getInjectedVars() || {}, uiExports.defaultInjectedVars) + ) }; } @@ -83,16 +92,18 @@ export default async (kbnServer, server, config) => { } async function renderApp(app) { - const isElasticsearchPluginRed = server.plugins.elasticsearch.status.state === 'red'; - const payload = await getPayload(app); - if (!isElasticsearchPluginRed) { + const payload = await getPayload.call(this, app); + + const esStatus = kbnServer.status.getForPluginId('elasticsearch'); + if (esStatus && esStatus.state !== 'red') { payload.uiSettings.user = await server.uiSettings().getUserProvided(); } + return viewAppWithPayload.call(this, app, payload); } async function renderAppWithDefaultConfig(app) { - const payload = await getPayload(app); + const payload = await getPayload.call(this, app); return viewAppWithPayload.call(this, app, payload); } diff --git a/src/ui/ui_exports.js b/src/ui/ui_exports.js index 2bb3ccfac681c..2e84ca8340397 100644 --- a/src/ui/ui_exports.js +++ b/src/ui/ui_exports.js @@ -14,6 +14,7 @@ class UiExports { this.consumers = []; this.bundleProviders = []; this.defaultInjectedVars = {}; + this.injectedVarsReplacers = []; } consumePlugin(plugin) { @@ -107,6 +108,11 @@ class UiExports { _.merge(this.defaultInjectedVars, await injector.call(plugin, server, options)); }); }; + + case 'replaceInjectedVars': + return (plugin, replacer) => { + this.injectedVarsReplacers.push(replacer); + }; } }