From 03ce368a6132279ed7d9cd65e57c56fe60600741 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 7 Jul 2020 19:58:46 +0100 Subject: [PATCH] ui: Remove jQuery from the production build (#8088) * ui: Split up client/http and replace $.ajax This splits the client/http service more in the following ways: 1. Connections are now split out into its own service 2. The transport is now split out into its own service that returns a listener based http transport 3. Various string parsing/stringifying functions are now split out into utils * Remove jQuery from our production build * Move the coverage serving to the server.js file * Self review amends * Add X-Requested-With header * Move some files around, externalize some functions * Move connection tracking to use native Set * Ensure HTTP parsing doesn't encode headers In the future this will change to deal with all HTTP parsing in one place, hence the commented out METHOD_PARSING etc * Start to fix up integration tests to use requestParams --- ui-v2/app/adapters/http.js | 3 + ui-v2/app/services/client/connections.js | 64 ++++ ui-v2/app/services/client/http.js | 360 +++++++----------- ui-v2/app/services/client/transports/xhr.js | 57 +++ ui-v2/app/utils/createURL.js | 35 -- ui-v2/app/utils/get-object-pool.js | 52 --- ui-v2/app/utils/http/create-headers.js | 11 + ui-v2/app/utils/http/create-query-params.js | 27 ++ ui-v2/app/utils/http/create-url.js | 72 ++++ ui-v2/app/utils/http/error.js | 6 + ui-v2/app/utils/http/request.js | 46 ++- ui-v2/app/utils/http/xhr.js | 29 ++ ui-v2/lib/startup/index.js | 4 - ui-v2/package.json | 1 - ui-v2/server/index.js | 3 + ui-v2/tests/integration/adapters/kv-test.js | 22 +- .../adapters/oidc-provider-test.js | 11 +- .../tests/integration/adapters/policy-test.js | 22 +- ui-v2/tests/integration/adapters/role-test.js | 22 +- .../integration/adapters/service-test.js | 33 +- .../integration/adapters/session-test.js | 22 +- .../tests/integration/adapters/token-test.js | 44 +-- ui-v2/tests/test-helper.js | 27 ++ .../unit/services/client/connections-test.js | 12 + .../services/client/transports/xhr-test.js | 12 + .../tests/unit/utils/get-object-pool-test.js | 98 ----- .../unit/utils/http/create-headers-test.js | 18 + .../utils/http/create-query-params-test.js | 43 +++ .../create-url-test.js} | 20 +- ui-v2/tests/unit/utils/http/error-test.js | 10 + ui-v2/tests/unit/utils/http/xhr-test.js | 10 + ui-v2/yarn.lock | 14 +- 32 files changed, 684 insertions(+), 526 deletions(-) create mode 100644 ui-v2/app/services/client/connections.js create mode 100644 ui-v2/app/services/client/transports/xhr.js delete mode 100644 ui-v2/app/utils/createURL.js delete mode 100644 ui-v2/app/utils/get-object-pool.js create mode 100644 ui-v2/app/utils/http/create-headers.js create mode 100644 ui-v2/app/utils/http/create-query-params.js create mode 100644 ui-v2/app/utils/http/create-url.js create mode 100644 ui-v2/app/utils/http/error.js create mode 100644 ui-v2/app/utils/http/xhr.js create mode 100644 ui-v2/tests/unit/services/client/connections-test.js create mode 100644 ui-v2/tests/unit/services/client/transports/xhr-test.js delete mode 100644 ui-v2/tests/unit/utils/get-object-pool-test.js create mode 100644 ui-v2/tests/unit/utils/http/create-headers-test.js create mode 100644 ui-v2/tests/unit/utils/http/create-query-params-test.js rename ui-v2/tests/unit/utils/{createURL-test.js => http/create-url-test.js} (71%) create mode 100644 ui-v2/tests/unit/utils/http/error-test.js create mode 100644 ui-v2/tests/unit/utils/http/xhr-test.js diff --git a/ui-v2/app/adapters/http.js b/ui-v2/app/adapters/http.js index e8bd64e244..13ce4c2892 100644 --- a/ui-v2/app/adapters/http.js +++ b/ui-v2/app/adapters/http.js @@ -81,6 +81,9 @@ export default Adapter.extend({ // }); }, error: function(err) { + if (err instanceof TypeError) { + throw err; + } const errors = [ { status: `${err.statusCode}`, diff --git a/ui-v2/app/services/client/connections.js b/ui-v2/app/services/client/connections.js new file mode 100644 index 0000000000..f7d7dd0d5c --- /dev/null +++ b/ui-v2/app/services/client/connections.js @@ -0,0 +1,64 @@ +import Service, { inject as service } from '@ember/service'; + +export default Service.extend({ + dom: service('dom'), + env: service('env'), + init: function() { + this._super(...arguments); + this._listeners = this.dom.listeners(); + this.connections = new Set(); + this.addVisibilityChange(); + }, + willDestroy: function() { + this._listeners.remove(); + this.purge(); + this._super(...arguments); + }, + addVisibilityChange: function() { + // when the user hides the tab, abort all connections + this._listeners.add(this.dom.document(), { + visibilitychange: e => { + if (e.target.hidden) { + this.purge(); + } + }, + }); + }, + whenAvailable: function(e) { + // if the user has hidden the tab (hidden browser/tab switch) + // any aborted errors should restart + const doc = this.dom.document(); + if (doc.hidden) { + return new Promise(resolve => { + const remove = this._listeners.add(doc, { + visibilitychange: function(event) { + remove(); + // we resolve with the event that comes from + // whenAvailable not visibilitychange + resolve(e); + }, + }); + }); + } + return Promise.resolve(e); + }, + purge: function() { + [...this.connections].forEach(function(connection) { + // Cancelled + connection.abort(0); + }); + this.connections = new Set(); + }, + acquire: function(request) { + this.connections.add(request); + if (this.connections.size > this.env.var('CONSUL_HTTP_MAX_CONNECTIONS')) { + const connection = this.connections.values().next().value; + this.connections.delete(connection); + // Too Many Requests + connection.abort(429); + } + }, + release: function(request) { + this.connections.delete(request); + }, +}); diff --git a/ui-v2/app/services/client/http.js b/ui-v2/app/services/client/http.js index 71e081267f..ae92ddadea 100644 --- a/ui-v2/app/services/client/http.js +++ b/ui-v2/app/services/client/http.js @@ -1,15 +1,13 @@ -/*global $*/ import Service, { inject as service } from '@ember/service'; -import { get, set } from '@ember/object'; +import { get } from '@ember/object'; +import { next } from '@ember/runloop'; import { CACHE_CONTROL, CONTENT_TYPE } from 'consul-ui/utils/http/headers'; - import { HEADERS_TOKEN as CONSUL_TOKEN } from 'consul-ui/utils/http/consul'; -import { env } from 'consul-ui/env'; -import getObjectPool from 'consul-ui/utils/get-object-pool'; -import Request from 'consul-ui/utils/http/request'; -import createURL from 'consul-ui/utils/createURL'; +import createURL from 'consul-ui/utils/http/create-url'; +import createHeaders from 'consul-ui/utils/http/create-headers'; +import createQueryParams from 'consul-ui/utils/http/create-query-params'; // reopen EventSources if a user changes tab export const restartWhenAvailable = function(client) { @@ -26,250 +24,188 @@ export const restartWhenAvailable = function(client) { throw e; }; }; -class HTTPError extends Error { - constructor(statusCode, message) { - super(message); - this.statusCode = statusCode; - } -} -const dispose = function(request) { - if (request.headers()[CONTENT_TYPE.toLowerCase()] === 'text/event-stream') { - const xhr = request.connection(); - // unsent and opened get aborted - // headers and loading means wait for it - // to finish for the moment - if (xhr.readyState) { - switch (xhr.readyState) { - case 0: - case 1: - xhr.abort(); - break; - } - } - } - return request; -}; -// TODO: Potentially url should check if any of the params -// passed to it are undefined (null is fine). We could then get rid of the -// multitude of checks we do throughout the adapters -// right now createURL converts undefined to '' so we need to check thats not needed -// anywhere (todo written here for visibility) -const url = createURL(encodeURIComponent); -const createHeaders = function(lines) { - return lines.reduce(function(prev, item) { - const temp = item.split(':'); - if (temp.length > 1) { - prev[temp[0].trim()] = temp[1].trim(); +const stringifyQueryParams = createQueryParams(encodeURIComponent); +const parseURL = createURL(encodeURIComponent, stringifyQueryParams); +const parseHeaders = createHeaders(); + +const parseBody = function(strs, ...values) { + let body = {}; + const doubleBreak = strs.reduce(function(prev, item, i) { + // Ensure each line has no whitespace either end, including empty lines + item = item + .split('\n') + .map(item => item.trim()) + .join('\n'); + if (item.indexOf('\n\n') !== -1) { + return i; } return prev; - }, {}); + }, -1); + if (doubleBreak !== -1) { + // This merges request bodies together, so you can specify multiple bodies + // in the request and it will merge them together. + // Turns out we never actually do this, so it might be worth removing as it complicates + // matters slightly as we assumed post bodies would be an object. + // This actually works as it just uses the value of the first object, if its an array + // it concats + body = values.splice(doubleBreak).reduce(function(prev, item, i) { + switch (true) { + case Array.isArray(item): + if (i === 0) { + prev = []; + } + return prev.concat(item); + case typeof item !== 'string': + return { + ...prev, + ...item, + }; + default: + return item; + } + }, body); + } + return [body, ...values]; }; + +const CLIENT_HEADERS = [CACHE_CONTROL]; export default Service.extend({ dom: service('dom'), + connections: service('client/connections'), + transport: service('client/transports/xhr'), settings: service('settings'), init: function() { this._super(...arguments); this._listeners = this.dom.listeners(); - const maxConnections = env('CONSUL_HTTP_MAX_CONNECTIONS'); - set(this, 'connections', getObjectPool(dispose, maxConnections)); - if (typeof maxConnections !== 'undefined') { - set(this, 'maxConnections', maxConnections); - // when the user hides the tab, abort all connections - this._listeners.add(this.dom.document(), { - visibilitychange: e => { - if (e.target.hidden) { - this.connections.purge(); - } - }, - }); - } }, willDestroy: function() { this._listeners.remove(); - this.connections.purge(); - set(this, 'connections', undefined); this._super(...arguments); }, url: function() { - return url(...arguments); + return parseURL(...arguments); }, - body: function(strs, ...values) { - let body = {}; - const doubleBreak = strs.reduce(function(prev, item, i) { - // Ensure each line has no whitespace either end, including empty lines - item = item - .split('\n') - .map(item => item.trim()) - .join('\n'); - if (item.indexOf('\n\n') !== -1) { - return i; + body: function() { + return parseBody(...arguments); + }, + requestParams: function(strs, ...values) { + // first go to the end and remove/parse the http body + const [body, ...urlVars] = this.body(...arguments); + // with whats left get the method off the front + const [method, ...urlParts] = this.url(strs, ...urlVars).split(' '); + // with whats left use the rest of the line for the url + // with whats left after the line, use for the headers + const [url, ...headerParts] = urlParts.join(' ').split('\n'); + const params = { + url: url.trim(), + method: method.trim(), + headers: { + [CONTENT_TYPE]: 'application/json; charset=utf-8', + ...parseHeaders(headerParts), + }, + body: null, + data: body, + }; + // Remove and save things that shouldn't be sent in the request + params.clientHeaders = CLIENT_HEADERS.reduce(function(prev, item) { + if (typeof params.headers[item] !== 'undefined') { + prev[item.toLowerCase()] = params.headers[item]; + delete params.headers[item]; } return prev; - }, -1); - if (doubleBreak !== -1) { - // This merges request bodies together, so you can specify multiple bodies - // in the request and it will merge them together. - // Turns out we never actually do this, so it might be worth removing as it complicates - // matters slightly as we assumed post bodies would be an object. - // This actually works as it just uses the value of the first object, if its an array - // it concats - body = values.splice(doubleBreak).reduce(function(prev, item, i) { - switch (true) { - case Array.isArray(item): - if (i === 0) { - prev = []; - } - return prev.concat(item); - case typeof item !== 'string': - return { - ...prev, - ...item, - }; - default: - return item; + }, {}); + if (typeof body !== 'undefined') { + // Only read add HTTP body if we aren't GET + // Right now we do this to avoid having to put data in the templates + // for write-like actions + // potentially we should change things so you _have_ to do that + // as doing it this way is a little magical + if (params.method !== 'GET') { + if (params.headers[CONTENT_TYPE].indexOf('json') !== -1) { + params.body = JSON.stringify(params.data); + } else { + if ( + (typeof params.data === 'string' && params.data.length > 0) || + Object.keys(params.data).length > 0 + ) { + params.body = params.data; + } } - }, body); + } else { + const str = stringifyQueryParams(params.data); + if (str.length > 0) { + if (params.url.indexOf('?') !== -1) { + params.url = `${params.url}&${str}`; + } else { + params.url = `${params.url}?${str}`; + } + } + } } - return [body, ...values]; + // temporarily reset the headers/content-type so it works the same + // as previously, should be able to remove this once the data layer + // rewrite is over and we can assert sending via form-encoded is fine + // also see adapters/kv content-types in requestForCreate/UpdateRecord + // also see https://github.com/hashicorp/consul/issues/3804 + params.headers[CONTENT_TYPE] = 'application/json; charset=utf-8'; + return params; }, request: function(cb) { const client = this; return cb(function(strs, ...values) { - // first go to the end and remove/parse the http body - const [body, ...urlVars] = client.body(...arguments); - // with whats left get the method off the front - const [method, ...urlParts] = client.url(strs, ...urlVars).split(' '); - // with whats left use the rest of the line for the url - // with whats left after the line, use for the headers - const [url, ...headerParts] = urlParts.join(' ').split('\n'); - - return client.settings.findBySlug('token').then(function(token) { - const requestHeaders = createHeaders(headerParts); - const headers = { - // default to application/json - ...{ - [CONTENT_TYPE]: 'application/json; charset=utf-8', - }, - // add any application level headers - ...{ + const params = client.requestParams(...arguments); + return client.settings.findBySlug('token').then(token => { + const options = { + ...params, + headers: { [CONSUL_TOKEN]: typeof token.SecretID === 'undefined' ? '' : token.SecretID, + ...params.headers, }, - // but overwrite or add to those from anything in the specific request - ...requestHeaders, }; - // We use cache-control in the response - // but we don't want to send it, but we artificially - // tag it onto the response below if it is set on the request - delete headers[CACHE_CONTROL]; - - return new Promise(function(resolve, reject) { - const options = { - url: url.trim(), - method: method, - contentType: headers[CONTENT_TYPE], - // type: 'json', - complete: function(xhr, textStatus) { - client.complete(this.id); + const request = client.transport.request(options); + return new Promise((resolve, reject) => { + const remove = client._listeners.add(request, { + open: e => { + client.acquire(e.target); }, - success: function(response, status, xhr) { - const headers = createHeaders(xhr.getAllResponseHeaders().split('\n')); - if (typeof requestHeaders[CACHE_CONTROL] !== 'undefined') { - // if cache-control was on the request, artificially tag - // it back onto the response, also see comment above - headers[CACHE_CONTROL] = requestHeaders[CACHE_CONTROL]; - } - const respond = function(cb) { - return cb(headers, response); + message: e => { + const headers = { + ...Object.entries(e.data.headers).reduce(function(prev, [key, value], i) { + if (!CLIENT_HEADERS.includes(key)) { + prev[key] = value; + } + return prev; + }, {}), + ...params.clientHeaders, }; - // TODO: nextTick ? - resolve(respond); + const respond = function(cb) { + return cb(headers, e.data.response); + }; + next(() => resolve(respond)); }, - error: function(xhr, textStatus, err) { - let error; - if (err instanceof Error) { - error = err; - } else { - let status = xhr.status; - // TODO: Not sure if we actually need this, but ember-data checks it - if (textStatus === 'abort') { - status = 0; - } - if (textStatus === 'timeout') { - status = 408; - } - error = new HTTPError(status, xhr.responseText); - } - //TODO: nextTick ? - reject(error); + error: e => { + next(() => reject(e.error)); }, - converters: { - 'text json': function(response) { - try { - return $.parseJSON(response); - } catch (e) { - return response; - } - }, + close: e => { + client.release(e.target); + remove(); }, - }; - if (typeof body !== 'undefined') { - // Only read add HTTP body if we aren't GET - // Right now we do this to avoid having to put data in the templates - // for write-like actions - // potentially we should change things so you _have_ to do that - // as doing it this way is a little magical - if (method !== 'GET' && headers[CONTENT_TYPE].indexOf('json') !== -1) { - options.data = JSON.stringify(body); - } else { - // TODO: Does this need urlencoding? Assuming jQuery does this - options.data = body; - } - } - // temporarily reset the headers/content-type so it works the same - // as previously, should be able to remove this once the data layer - // rewrite is over and we can assert sending via form-encoded is fine - // also see adapters/kv content-types in requestForCreate/UpdateRecord - // also see https://github.com/hashicorp/consul/issues/3804 - options.contentType = 'application/json; charset=utf-8'; - headers[CONTENT_TYPE] = options.contentType; - // - options.beforeSend = function(xhr) { - if (headers) { - Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key])); - } - this.id = client.acquire(options, xhr); - }; - return $.ajax(options); + }); + request.fetch(); }); }); }); }, - abort: function(id = null) { - this.connections.purge(); - }, whenAvailable: function(e) { - // if we are using a connection limited protocol and the user has hidden the tab (hidden browser/tab switch) - // any aborted errors should restart - const doc = this.dom.document(); - if (typeof this.maxConnections !== 'undefined' && doc.hidden) { - return new Promise(resolve => { - const remove = this._listeners.add(doc, { - visibilitychange: function(event) { - remove(); - // we resolve with the event that comes from - // whenAvailable not visibilitychange - resolve(e); - }, - }); - }); - } - return Promise.resolve(e); + return this.connections.whenAvailable(e); }, - acquire: function(options, xhr) { - const request = new Request(options.method, options.url, { body: options.data || {} }, xhr); - return this.connections.acquire(request, request.getId()); + abort: function() { + return this.connections.purge(...arguments); }, - complete: function() { + acquire: function() { + return this.connections.acquire(...arguments); + }, + release: function() { return this.connections.release(...arguments); }, }); diff --git a/ui-v2/app/services/client/transports/xhr.js b/ui-v2/app/services/client/transports/xhr.js new file mode 100644 index 0000000000..5953123561 --- /dev/null +++ b/ui-v2/app/services/client/transports/xhr.js @@ -0,0 +1,57 @@ +import Service from '@ember/service'; + +import createHeaders from 'consul-ui/utils/http/create-headers'; +import createXHR from 'consul-ui/utils/http/xhr'; +import Request from 'consul-ui/utils/http/request'; +import HTTPError from 'consul-ui/utils/http/error'; + +const xhr = createXHR(createHeaders()); + +export default Service.extend({ + xhr: function(options) { + return xhr(options); + }, + request: function(params) { + const request = new Request(params.method, params.url, { body: params.data || {} }); + const options = { + ...params, + beforeSend: function(xhr) { + request.open(xhr); + }, + converters: { + 'text json': function(response) { + try { + return JSON.parse(response); + } catch (e) { + return response; + } + }, + }, + success: function(headers, response, status, statusText) { + // Response-ish + request.respond({ + headers: headers, + response: response, + status: status, + statusText: statusText, + }); + }, + error: function(headers, response, status, statusText, err) { + let error; + if (err instanceof Error) { + error = err; + } else { + error = new HTTPError(status, response); + } + request.error(error); + }, + complete: function(status) { + request.close(); + }, + }; + request.fetch = () => { + this.xhr(options); + }; + return request; + }, +}); diff --git a/ui-v2/app/utils/createURL.js b/ui-v2/app/utils/createURL.js deleted file mode 100644 index 35894752eb..0000000000 --- a/ui-v2/app/utils/createURL.js +++ /dev/null @@ -1,35 +0,0 @@ -export default function(encode) { - return function(strs, ...values) { - return strs - .map(function(item, i) { - let val = typeof values[i] === 'undefined' ? '' : values[i]; - switch (true) { - case typeof val === 'string': - val = encode(val); - break; - case Array.isArray(val): - val = val - .map(function(item) { - return `${encode(item)}`; - }, '') - .join('/'); - break; - case typeof val === 'object': - val = Object.keys(val) - .reduce(function(prev, key) { - if (val[key] === null) { - return prev.concat(`${encode(key)}`); - } else if (typeof val[key] !== 'undefined') { - return prev.concat(`${encode(key)}=${encode(val[key])}`); - } - return prev; - }, []) - .join('&'); - break; - } - return `${item}${val}`; - }) - .join('') - .trim(); - }; -} diff --git a/ui-v2/app/utils/get-object-pool.js b/ui-v2/app/utils/get-object-pool.js deleted file mode 100644 index ad9f0ebe83..0000000000 --- a/ui-v2/app/utils/get-object-pool.js +++ /dev/null @@ -1,52 +0,0 @@ -export default function(dispose = function() {}, max, objects = []) { - return { - acquire: function(obj, id) { - // TODO: what should happen if an ID already exists - // should we ignore and release both? Or prevent from acquiring? Or generate a unique ID? - // what happens if we can't get an id via getId or .id? - // could potentially use Set - objects.push(obj); - if (typeof max !== 'undefined') { - if (objects.length > max) { - return dispose(objects.shift()); - } - } - return id; - }, - // release releases the obj from the pool but **doesn't** dispose it - release: function(obj) { - let index = -1; - let id; - if (typeof obj === 'string') { - id = obj; - } else { - id = obj.id; - } - objects.forEach(function(item, i) { - let itemId; - if (typeof item.getId === 'function') { - itemId = item.getId(); - } else { - itemId = item.id; - } - if (itemId === id) { - index = i; - } - }); - if (index !== -1) { - return objects.splice(index, 1)[0]; - } - }, - purge: function() { - let obj; - const objs = []; - while ((obj = objects.shift())) { - objs.push(dispose(obj)); - } - return objs; - }, - dispose: function(id) { - return dispose(this.release(id)); - }, - }; -} diff --git a/ui-v2/app/utils/http/create-headers.js b/ui-v2/app/utils/http/create-headers.js new file mode 100644 index 0000000000..4b0280170d --- /dev/null +++ b/ui-v2/app/utils/http/create-headers.js @@ -0,0 +1,11 @@ +export default function() { + return function(lines) { + return lines.reduce(function(prev, item) { + const temp = item.split(':'); + if (temp.length > 1) { + prev[temp[0].trim()] = temp[1].trim(); + } + return prev; + }, {}); + }; +} diff --git a/ui-v2/app/utils/http/create-query-params.js b/ui-v2/app/utils/http/create-query-params.js new file mode 100644 index 0000000000..c9d1b6c273 --- /dev/null +++ b/ui-v2/app/utils/http/create-query-params.js @@ -0,0 +1,27 @@ +export default function(encode) { + return function stringify(obj, parent) { + return Object.entries(obj) + .reduce(function(prev, [key, value], i) { + // if the value is undefined do nothing + if (typeof value === 'undefined') { + return prev; + } + let prop = encode(key); + // if we have a parent, prefix the property with that + if (typeof parent !== 'undefined') { + prop = `${parent}[${prop}]`; + } + // if the value is null just print the prop + if (value === null) { + return prev.concat(prop); + } + // anything nested, recur + if (typeof value === 'object') { + return prev.concat(stringify(value, prop)); + } + // anything else print prop=value + return prev.concat(`${prop}=${encode(value)}`); + }, []) + .join('&'); + }; +} diff --git a/ui-v2/app/utils/http/create-url.js b/ui-v2/app/utils/http/create-url.js new file mode 100644 index 0000000000..2405ecdd79 --- /dev/null +++ b/ui-v2/app/utils/http/create-url.js @@ -0,0 +1,72 @@ +// const METHOD_PARSING = 0; +const PATH_PARSING = 1; +const QUERY_PARSING = 2; +const HEADER_PARSING = 3; +const BODY_PARSING = 4; +export default function(encode, queryParams) { + return function(strs, ...values) { + // TODO: Potentially url should check if any of the params + // passed to it are undefined (null is fine). We could then get rid of the + // multitude of checks we do throughout the adapters + // right now create-url converts undefined to '' so we need to check thats not needed + // anywhere + let state = PATH_PARSING; + return strs + .map(function(item, i, arr) { + if (i === 0) { + item = item.trimStart(); + } + // if(item.indexOf(' ') !== -1 && state === METHOD_PARSING) { + // state = PATH_PARSING; + // } + if (item.indexOf('?') !== -1 && state === PATH_PARSING) { + state = QUERY_PARSING; + } + if (item.indexOf('\n\n') !== -1) { + state = BODY_PARSING; + } + if (item.indexOf('\n') !== -1 && state !== BODY_PARSING) { + state = HEADER_PARSING; + } + let val = typeof values[i] !== 'undefined' ? values[i] : ''; + switch (state) { + case PATH_PARSING: + switch (true) { + // encode strings + case typeof val === 'string': + val = encode(val); + break; + // split encode and join arrays by `/` + case Array.isArray(val): + val = val + .map(function(item) { + return `${encode(item)}`; + }, '') + .join('/'); + break; + } + break; + case QUERY_PARSING: + switch (true) { + case typeof val === 'string': + val = encode(val); + break; + // objects offload to queryParams for encoding + case typeof val === 'object': + val = queryParams(val); + break; + } + break; + case BODY_PARSING: + // ignore body until we parse it here + return item.split('\n\n')[0]; + // case METHOD_PARSING: + case HEADER_PARSING: + // passthrough/ignore method and headers until we parse them here + } + return `${item}${val}`; + }) + .join('') + .trim(); + }; +} diff --git a/ui-v2/app/utils/http/error.js b/ui-v2/app/utils/http/error.js new file mode 100644 index 0000000000..ff2b222aa7 --- /dev/null +++ b/ui-v2/app/utils/http/error.js @@ -0,0 +1,6 @@ +export default class extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } +} diff --git a/ui-v2/app/utils/http/request.js b/ui-v2/app/utils/http/request.js index 1a9643d514..9737dfd31a 100644 --- a/ui-v2/app/utils/http/request.js +++ b/ui-v2/app/utils/http/request.js @@ -1,13 +1,13 @@ -export default class { - constructor(method, url, headers, xhr) { - this._xhr = xhr; +import EventTarget from 'consul-ui/utils/dom/event-target/rsvp'; +export default class extends EventTarget { + constructor(method, url, headers) { + super(); this._url = url; this._method = method; this._headers = headers; this._headers = { ...headers, 'content-type': 'application/json', - 'x-request-id': `${this._method} ${this._url}?${JSON.stringify(headers.body)}`, }; if (typeof this._headers.body.index !== 'undefined') { // this should probably be in a response @@ -17,13 +17,43 @@ export default class { headers() { return this._headers; } - getId() { - return this._headers['x-request-id']; + open(xhr) { + this._xhr = xhr; + this.dispatchEvent({ type: 'open' }); } - abort() { - this._xhr.abort(); + respond(data) { + this.dispatchEvent({ type: 'message', data: data }); + } + error(error) { + // if the xhr was aborted (status = 0) + // and this requests was aborted with a different status + // switch the status + if (error.statusCode === 0 && typeof this.statusCode !== 'undefined') { + error.statusCode = this.statusCode; + } + this.dispatchEvent({ type: 'error', error: error }); + } + close() { + this.dispatchEvent({ type: 'close' }); } connection() { return this._xhr; } + abort(statusCode = 0) { + if (this.headers()['content-type'] === 'text/event-stream') { + this.statusCode = statusCode; + const xhr = this.connection(); + // unsent and opened get aborted + // headers and loading means wait for it + // to finish for the moment + if (xhr.readyState) { + switch (xhr.readyState) { + case 0: + case 1: + xhr.abort(); + break; + } + } + } + } } diff --git a/ui-v2/app/utils/http/xhr.js b/ui-v2/app/utils/http/xhr.js new file mode 100644 index 0000000000..5965ef8994 --- /dev/null +++ b/ui-v2/app/utils/http/xhr.js @@ -0,0 +1,29 @@ +export default function(parseHeaders, XHR) { + return function(options) { + const xhr = new (XHR || XMLHttpRequest)(); + xhr.onreadystatechange = function() { + if (this.readyState === 4) { + const headers = parseHeaders(this.getAllResponseHeaders().split('\n')); + if (this.status >= 200 && this.status < 400) { + const response = options.converters['text json'](this.response); + options.success(headers, response, this.status, this.statusText); + } else { + options.error(headers, this.responseText, this.status, this.statusText, this.error); + } + options.complete(this.status); + } + }; + xhr.open(options.method, options.url, true); + if (typeof options.headers === 'undefined') { + options.headers = {}; + } + const headers = { + ...options.headers, + 'X-Requested-With': 'XMLHttpRequest', + }; + Object.entries(headers).forEach(([key, value]) => xhr.setRequestHeader(key, value)); + options.beforeSend(xhr); + xhr.send(options.body); + return xhr; + }; +} diff --git a/ui-v2/lib/startup/index.js b/ui-v2/lib/startup/index.js index dcc03fc5e6..dde5315121 100644 --- a/ui-v2/lib/startup/index.js +++ b/ui-v2/lib/startup/index.js @@ -12,7 +12,6 @@ const apiDoubleHeaders = require('@hashicorp/api-double/lib/headers'); const cookieParser = require('cookie-parser'); const bodyParser = require('body-parser'); -const express = require('express'); // module.exports = { name: 'startup', @@ -20,9 +19,6 @@ module.exports = { // TODO: see if we can move these into the project specific `/server` directory // instead of inside an addon - // Serve the coverage folder for easy viewing during development - server.app.use('/coverage', express.static('coverage')); - // TODO: This should all be moved out into ember-cli-api-double // and we should figure out a way to get to the settings here for // so we can set this path name centrally in config diff --git a/ui-v2/package.json b/ui-v2/package.json index 2d09288397..6e64438bd4 100644 --- a/ui-v2/package.json +++ b/ui-v2/package.json @@ -52,7 +52,6 @@ "devDependencies": { "@babel/core": "^7.2.2", "@babel/plugin-proposal-object-rest-spread": "^7.5.5", - "@ember/jquery": "^1.1.0", "@ember/optional-features": "^1.3.0", "@glimmer/component": "^1.0.0", "@glimmer/tracking": "^1.0.0", diff --git a/ui-v2/server/index.js b/ui-v2/server/index.js index a38cb9d893..d2a344eb25 100644 --- a/ui-v2/server/index.js +++ b/ui-v2/server/index.js @@ -2,6 +2,7 @@ const fs = require('fs'); const promisify = require('util').promisify; const read = promisify(fs.readFile); +const express = require('express'); module.exports = function(app, options) { // During development the proxy server has no way of @@ -23,4 +24,6 @@ module.exports = function(app, options) { } next(); }); + // Serve the coverage folder for easy viewing during development + app.use('/coverage', express.static('coverage')); }; diff --git a/ui-v2/tests/integration/adapters/kv-test.js b/ui-v2/tests/integration/adapters/kv-test.js index 38ef3a3ea2..f7f48867fd 100644 --- a/ui-v2/tests/integration/adapters/kv-test.js +++ b/ui-v2/tests/integration/adapters/kv-test.js @@ -13,30 +13,28 @@ module('Integration | Adapter | kv', function(hooks) { test(`requestForQuery returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:kv'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/kv/${id}?keys&dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/kv/${id}?keys&dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, id: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQueryRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:kv'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/kv/${id}?dc=${dc}`; - let actual = adapter.requestForQueryRecord(client.url, { + const expected = `GET /v1/kv/${id}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQueryRecord(client.requestParams.bind(client), { dc: dc, id: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForCreateRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:kv'); diff --git a/ui-v2/tests/integration/adapters/oidc-provider-test.js b/ui-v2/tests/integration/adapters/oidc-provider-test.js index be865e2f08..480ad96e3d 100644 --- a/ui-v2/tests/integration/adapters/oidc-provider-test.js +++ b/ui-v2/tests/integration/adapters/oidc-provider-test.js @@ -14,15 +14,14 @@ module('Integration | Adapter | oidc-provider', function(hooks) { test('requestForQuery returns the correct url/method', function(assert) { const adapter = this.owner.lookup('adapter:oidc-provider'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/internal/ui/oidc-auth-methods?dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/internal/ui/oidc-auth-methods?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test('requestForQueryRecord returns the correct url/method', function(assert) { const adapter = this.owner.lookup('adapter:oidc-provider'); diff --git a/ui-v2/tests/integration/adapters/policy-test.js b/ui-v2/tests/integration/adapters/policy-test.js index 6dba919406..a4d71c2d04 100644 --- a/ui-v2/tests/integration/adapters/policy-test.js +++ b/ui-v2/tests/integration/adapters/policy-test.js @@ -20,29 +20,27 @@ module('Integration | Adapter | policy', function(hooks) { test(`requestForQuery returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:policy'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/policies?dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/acl/policies?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQueryRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:policy'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/policy/${id}?dc=${dc}`; - let actual = adapter.requestForQueryRecord(client.url, { + const expected = `GET /v1/acl/policy/${id}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQueryRecord(client.requestParams.bind(client), { dc: dc, id: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForCreateRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:policy'); diff --git a/ui-v2/tests/integration/adapters/role-test.js b/ui-v2/tests/integration/adapters/role-test.js index e8ff856c22..d0e109c944 100644 --- a/ui-v2/tests/integration/adapters/role-test.js +++ b/ui-v2/tests/integration/adapters/role-test.js @@ -13,29 +13,27 @@ module('Integration | Adapter | role', function(hooks) { test(`requestForQuery returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:role'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/roles?dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/acl/roles?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQueryRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:role'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/role/${id}?dc=${dc}`; - let actual = adapter.requestForQueryRecord(client.url, { + const expected = `GET /v1/acl/role/${id}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQueryRecord(client.requestParams.bind(client), { dc: dc, id: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForCreateRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:role'); diff --git a/ui-v2/tests/integration/adapters/service-test.js b/ui-v2/tests/integration/adapters/service-test.js index 7588fdfc57..d5327b9724 100644 --- a/ui-v2/tests/integration/adapters/service-test.js +++ b/ui-v2/tests/integration/adapters/service-test.js @@ -13,44 +13,41 @@ module('Integration | Adapter | service', function(hooks) { test(`requestForQuery returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:service'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/internal/ui/services?dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/internal/ui/services?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQuery returns the correct url/method when called with gateway when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:service'); const client = this.owner.lookup('service:client/http'); const gateway = 'gateway'; - const expected = `GET /v1/internal/ui/gateway-services-nodes/${gateway}?dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/internal/ui/gateway-services-nodes/${gateway}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, ns: nspace, gateway: gateway, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQueryRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:service'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/health/service/${id}?dc=${dc}`; - let actual = adapter.requestForQueryRecord(client.url, { + const expected = `GET /v1/health/service/${id}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQueryRecord(client.requestParams.bind(client), { dc: dc, id: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); }); test("requestForQueryRecord throws if you don't specify an id", function(assert) { diff --git a/ui-v2/tests/integration/adapters/session-test.js b/ui-v2/tests/integration/adapters/session-test.js index f6cc57894f..e464596788 100644 --- a/ui-v2/tests/integration/adapters/session-test.js +++ b/ui-v2/tests/integration/adapters/session-test.js @@ -14,30 +14,28 @@ module('Integration | Adapter | session', function(hooks) { const adapter = this.owner.lookup('adapter:session'); const client = this.owner.lookup('service:client/http'); const node = 'node-id'; - const expected = `GET /v1/session/node/${node}?dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/session/node/${node}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, id: node, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQueryRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:session'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/session/info/${id}?dc=${dc}`; - let actual = adapter.requestForQueryRecord(client.url, { + const expected = `GET /v1/session/info/${id}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQueryRecord(client.requestParams.bind(client), { dc: dc, id: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForDeleteRecord returns the correct url/method when the nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:session'); diff --git a/ui-v2/tests/integration/adapters/token-test.js b/ui-v2/tests/integration/adapters/token-test.js index 5dd692e7ef..44de63b20f 100644 --- a/ui-v2/tests/integration/adapters/token-test.js +++ b/ui-v2/tests/integration/adapters/token-test.js @@ -13,57 +13,53 @@ module('Integration | Adapter | token', function(hooks) { test(`requestForQuery returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:token'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/tokens?dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/acl/tokens?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQuery returns the correct url/method when a policy is specified when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:token'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/tokens?policy=${id}&dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/acl/tokens?policy=${id}&dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, policy: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQuery returns the correct url/method when a role is specified when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:token'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/tokens?role=${id}&dc=${dc}`; - let actual = adapter.requestForQuery(client.url, { + const expected = `GET /v1/acl/tokens?role=${id}&dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQuery(client.requestParams.bind(client), { dc: dc, role: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForQueryRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:token'); const client = this.owner.lookup('service:client/http'); - const expected = `GET /v1/acl/token/${id}?dc=${dc}`; - let actual = adapter.requestForQueryRecord(client.url, { + const expected = `GET /v1/acl/token/${id}?dc=${dc}${ + shouldHaveNspace(nspace) ? `&ns=${nspace}` : `` + }`; + let actual = adapter.requestForQueryRecord(client.requestParams.bind(client), { dc: dc, id: id, ns: nspace, }); - actual = actual.split('\n'); - assert.equal(actual.shift().trim(), expected); - actual = actual.join('\n').trim(); - assert.equal(actual, `${shouldHaveNspace(nspace) ? `ns=${nspace}` : ``}`); + assert.equal(`${actual.method} ${actual.url}`, expected); }); test(`requestForCreateRecord returns the correct url/method when nspace is ${nspace}`, function(assert) { const adapter = this.owner.lookup('adapter:token'); diff --git a/ui-v2/tests/test-helper.js b/ui-v2/tests/test-helper.js index d80cf99f3d..58bc7a64a5 100644 --- a/ui-v2/tests/test-helper.js +++ b/ui-v2/tests/test-helper.js @@ -1,9 +1,36 @@ import Application from '../app'; import config from '../config/environment'; import { setApplication } from '@ember/test-helpers'; +import { registerWaiter } from '@ember/test'; import './helpers/flash-message'; import start from 'ember-exam/test-support/start'; +import ClientConnections from 'consul-ui/services/client/connections'; + +let activeRequests = 0; +registerWaiter(function() { + return activeRequests === 0; +}); +ClientConnections.reopen({ + addVisibilityChange: function() { + // for the moment don't listen for tab hiding during testing + // TODO: make this controllable from testing so we can fake a tab hide + }, + purge: function() { + const res = this._super(...arguments); + activeRequests = 0; + return res; + }, + acquire: function() { + activeRequests++; + return this._super(...arguments); + }, + release: function() { + const res = this._super(...arguments); + activeRequests--; + return res; + }, +}); const application = Application.create(config.APP); application.inject('component:copy-button', 'clipboard', 'service:clipboard/local-storage'); setApplication(application); diff --git a/ui-v2/tests/unit/services/client/connections-test.js b/ui-v2/tests/unit/services/client/connections-test.js new file mode 100644 index 0000000000..6463957008 --- /dev/null +++ b/ui-v2/tests/unit/services/client/connections-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | client/connections', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let service = this.owner.lookup('service:client/connections'); + assert.ok(service); + }); +}); diff --git a/ui-v2/tests/unit/services/client/transports/xhr-test.js b/ui-v2/tests/unit/services/client/transports/xhr-test.js new file mode 100644 index 0000000000..24a04abcdd --- /dev/null +++ b/ui-v2/tests/unit/services/client/transports/xhr-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | client/transports/xhr', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let service = this.owner.lookup('service:client/transports/xhr'); + assert.ok(service); + }); +}); diff --git a/ui-v2/tests/unit/utils/get-object-pool-test.js b/ui-v2/tests/unit/utils/get-object-pool-test.js deleted file mode 100644 index cdf93a7460..0000000000 --- a/ui-v2/tests/unit/utils/get-object-pool-test.js +++ /dev/null @@ -1,98 +0,0 @@ -import getObjectPool from 'consul-ui/utils/get-object-pool'; -import { module, skip } from 'qunit'; -import test from 'ember-sinon-qunit/test-support/test'; - -module('Unit | Utility | get object pool', function() { - skip('Decide what to do if you add 2 objects with the same id'); - test('acquire adds objects', function(assert) { - const actual = []; - const expected = { - hi: 'there', - id: 'hi-there-123', - }; - const expected2 = { - hi: 'there', - id: 'hi-there-456', - }; - const pool = getObjectPool(function() {}, 10, actual); - pool.acquire(expected, expected.id); - assert.deepEqual(actual[0], expected); - pool.acquire(expected2, expected2.id); - assert.deepEqual(actual[1], expected2); - }); - test('acquire adds objects and returns the id', function(assert) { - const arr = []; - const expected = 'hi-there-123'; - const obj = { - hi: 'there', - id: expected, - }; - const pool = getObjectPool(function() {}, 10, arr); - const actual = pool.acquire(obj, expected); - assert.equal(actual, expected); - }); - test('acquire adds objects, and disposes when there is no room', function(assert) { - const actual = []; - const expected = { - hi: 'there', - id: 'hi-there-123', - }; - const expected2 = { - hi: 'there', - id: 'hi-there-456', - }; - const dispose = this.stub() - .withArgs(expected) - .returnsArg(0); - const pool = getObjectPool(dispose, 1, actual); - pool.acquire(expected, expected.id); - assert.deepEqual(actual[0], expected); - pool.acquire(expected2, expected2.id); - assert.deepEqual(actual[0], expected2); - assert.ok(dispose.calledOnce); - }); - test('it disposes', function(assert) { - const arr = []; - const expected = { - hi: 'there', - id: 'hi-there-123', - }; - const expected2 = { - hi: 'there', - id: 'hi-there-456', - }; - const dispose = this.stub().returnsArg(0); - const pool = getObjectPool(dispose, 2, arr); - const id = pool.acquire(expected, expected.id); - assert.deepEqual(arr[0], expected); - pool.acquire(expected2, expected2.id); - assert.deepEqual(arr[1], expected2); - const actual = pool.dispose(id); - assert.ok(dispose.calledOnce); - assert.equal(arr.length, 1, 'object was removed from array'); - assert.deepEqual(actual, expected, 'returned object is expected object'); - assert.deepEqual(arr[0], expected2, 'object in the pool is expected object'); - }); - test('it purges', function(assert) { - const arr = []; - const expected = { - hi: 'there', - id: 'hi-there-123', - }; - const expected2 = { - hi: 'there', - id: 'hi-there-456', - }; - const dispose = this.stub().returnsArg(0); - const pool = getObjectPool(dispose, 2, arr); - pool.acquire(expected, expected.id); - assert.deepEqual(arr[0], expected); - pool.acquire(expected2, expected2.id); - assert.deepEqual(arr[1], expected2); - const actual = pool.purge(); - assert.ok(dispose.calledTwice, 'dispose was called on everything'); - assert.equal(arr.length, 0, 'the pool is empty'); - assert.deepEqual(actual[0], expected, 'the first purged object is correct'); - assert.deepEqual(actual[1], expected2, 'the second purged object is correct'); - }); -}); diff --git a/ui-v2/tests/unit/utils/http/create-headers-test.js b/ui-v2/tests/unit/utils/http/create-headers-test.js new file mode 100644 index 0000000000..00ea967183 --- /dev/null +++ b/ui-v2/tests/unit/utils/http/create-headers-test.js @@ -0,0 +1,18 @@ +import createHeaders from 'consul-ui/utils/http/create-headers'; +import { module, test } from 'qunit'; + +module('Unit | Utility | http/create-headers', function() { + const parseHeaders = createHeaders(); + test('it converts lines of header-like strings into an object', function(assert) { + const expected = { + 'Content-Type': 'application/json', + 'X-Consul-Index': '1', + }; + const lines = ` + Content-Type: application/json + X-Consul-Index: 1 + `.split('\n'); + const actual = parseHeaders(lines); + assert.deepEqual(actual, expected); + }); +}); diff --git a/ui-v2/tests/unit/utils/http/create-query-params-test.js b/ui-v2/tests/unit/utils/http/create-query-params-test.js new file mode 100644 index 0000000000..f0a8f61989 --- /dev/null +++ b/ui-v2/tests/unit/utils/http/create-query-params-test.js @@ -0,0 +1,43 @@ +import createQueryParams from 'consul-ui/utils/http/create-query-params'; +import { module, test } from 'qunit'; + +module('Unit | Utility | http/create-query-params', function() { + const stringifyQueryParams = createQueryParams(str => str); + test('it turns objects into query params formatted strings', function(assert) { + const expected = 'something=here&another=variable'; + const actual = stringifyQueryParams({ + something: 'here', + another: 'variable', + }); + assert.equal(actual, expected); + }); + test('it ignores undefined properties', function(assert) { + const expected = 'something=here'; + const actual = stringifyQueryParams({ + something: 'here', + another: undefined, + }); + assert.equal(actual, expected); + }); + test('it stringifies nested objects', function(assert) { + const expected = 'something=here&another[something]=here&another[another][something]=here'; + const actual = stringifyQueryParams({ + something: 'here', + another: { + something: 'here', + another: { + something: 'here', + }, + }, + }); + assert.equal(actual, expected); + }); + test('it only adds the property if the value is null', function(assert) { + const expected = 'something&another=here'; + const actual = stringifyQueryParams({ + something: null, + another: 'here', + }); + assert.equal(actual, expected); + }); +}); diff --git a/ui-v2/tests/unit/utils/createURL-test.js b/ui-v2/tests/unit/utils/http/create-url-test.js similarity index 71% rename from ui-v2/tests/unit/utils/createURL-test.js rename to ui-v2/tests/unit/utils/http/create-url-test.js index 54e31f65f3..e996bb3462 100644 --- a/ui-v2/tests/unit/utils/createURL-test.js +++ b/ui-v2/tests/unit/utils/http/create-url-test.js @@ -1,37 +1,43 @@ import { module, skip } from 'qunit'; import test from 'ember-sinon-qunit/test-support/test'; -import createURL from 'consul-ui/utils/createURL'; +import createURL from 'consul-ui/utils/http/create-url'; +import createQueryParams from 'consul-ui/utils/http/create-query-params'; -module('Unit | Utils | createURL', function() { +module('Unit | Utils | http/create-url', function() { skip("it isn't isolated enough, mock encodeURIComponent"); + const url = createURL(encodeURIComponent, createQueryParams(encodeURIComponent)); test('it passes the values to encode', function(assert) { - const url = createURL(encodeURIComponent); const actual = url`/v1/url?${{ query: 'to encode', 'key with': ' spaces ' }}`; const expected = '/v1/url?query=to%20encode&key%20with=%20spaces%20'; assert.equal(actual, expected); }); test('it adds a query string key without an `=` if the query value is `null`', function(assert) { - const url = createURL(encodeURIComponent); const actual = url`/v1/url?${{ 'key with space': null }}`; const expected = '/v1/url?key%20with%20space'; assert.equal(actual, expected); }); test('it returns a string when passing an array', function(assert) { - const url = createURL(encodeURIComponent); const actual = url`/v1/url/${['raw values', 'to', 'encode']}`; const expected = '/v1/url/raw%20values/to/encode'; assert.equal(actual, expected); }); test('it returns a string when passing a string', function(assert) { - const url = createURL(encodeURIComponent); const actual = url`/v1/url/${'raw values to encode'}`; const expected = '/v1/url/raw%20values%20to%20encode'; assert.equal(actual, expected); }); test("it doesn't add a query string prop/value is the value is undefined", function(assert) { - const url = createURL(encodeURIComponent); const actual = url`/v1/url?${{ key: undefined }}`; const expected = '/v1/url?'; assert.equal(actual, expected); }); + test("it doesn't encode headers", function(assert) { + const actual = url` + /v1/url/${'raw values to encode'} + Header: %value + `; + const expected = `/v1/url/raw%20values%20to%20encode + Header: %value`; + assert.equal(actual, expected); + }); }); diff --git a/ui-v2/tests/unit/utils/http/error-test.js b/ui-v2/tests/unit/utils/http/error-test.js new file mode 100644 index 0000000000..6be94e14ac --- /dev/null +++ b/ui-v2/tests/unit/utils/http/error-test.js @@ -0,0 +1,10 @@ +import HttpError from 'consul-ui/utils/http/error'; +import { module, test } from 'qunit'; + +module('Unit | Utility | http/error', function() { + // Replace this with your real tests. + test('it works', function(assert) { + const result = new HttpError(); + assert.ok(result); + }); +}); diff --git a/ui-v2/tests/unit/utils/http/xhr-test.js b/ui-v2/tests/unit/utils/http/xhr-test.js new file mode 100644 index 0000000000..7eae710402 --- /dev/null +++ b/ui-v2/tests/unit/utils/http/xhr-test.js @@ -0,0 +1,10 @@ +import httpXhr from 'consul-ui/utils/http/xhr'; +import { module, test } from 'qunit'; + +module('Unit | Utility | http/xhr', function() { + // Replace this with your real tests. + test('it works', function(assert) { + let result = httpXhr(); + assert.ok(result); + }); +}); diff --git a/ui-v2/yarn.lock b/ui-v2/yarn.lock index a41d778810..e042746cab 100644 --- a/ui-v2/yarn.lock +++ b/ui-v2/yarn.lock @@ -1026,18 +1026,6 @@ resolved "https://registry.yarnpkg.com/@ember/edition-utils/-/edition-utils-1.2.0.tgz#a039f542dc14c8e8299c81cd5abba95e2459cfa6" integrity sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog== -"@ember/jquery@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@ember/jquery/-/jquery-1.1.0.tgz#33d062610a5ceaa5c5c8a3187f870d47d6595940" - integrity sha512-zePT3LiK4/2bS4xafrbOlwoLJrDFseOZ95OOuVDyswv8RjFL+9lar+uxX6+jxRb0w900BcQSWP/4nuFSK6HXXw== - dependencies: - broccoli-funnel "^2.0.2" - broccoli-merge-trees "^3.0.2" - ember-cli-babel "^7.11.1" - ember-cli-version-checker "^3.1.3" - jquery "^3.4.1" - resolve "^1.11.1" - "@ember/optional-features@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ember/optional-features/-/optional-features-1.3.0.tgz#d7da860417b85a56cec88419f30da5ee1dde2756" @@ -5100,7 +5088,7 @@ ember-cli-babel@^7.1.0, ember-cli-babel@^7.1.2, ember-cli-babel@^7.1.3, ember-cl ensure-posix-path "^1.0.2" semver "^5.5.0" -ember-cli-babel@^7.11.1, ember-cli-babel@^7.12.0, ember-cli-babel@^7.13.0, ember-cli-babel@^7.13.2, ember-cli-babel@^7.17.2, ember-cli-babel@^7.18.0, ember-cli-babel@^7.8.0: +ember-cli-babel@^7.12.0, ember-cli-babel@^7.13.0, ember-cli-babel@^7.13.2, ember-cli-babel@^7.17.2, ember-cli-babel@^7.18.0, ember-cli-babel@^7.8.0: version "7.18.0" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.18.0.tgz#e979b73eee00cd93f63452c6170d045e8832f29c" integrity sha512-OLPfYD8wSfCrmGHcUf8zEfySSvbAL+5Qp2RWLycJIMaBZhg+SncKj5kVkL3cPJR5n2hVHPdfmKTQIYjOYl6FnQ==