Commit 198cf31f authored by Miguel Rincon's avatar Miguel Rincon

Add infinite scrolling to env logs

Integrates logs infinite scroll using pagination.
parent c9f50b1d
......@@ -492,41 +492,6 @@ const Api = {
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
/**
* Returns pods logs for an environment with an optional pod and container
*
* @param {Object} params
* @param {Object} param.environment - Environment object
* @param {string=} params.podName - Pod name, if not set the backend assumes a default one
* @param {string=} params.containerName - Container name, if not set the backend assumes a default one
* @param {string=} params.start - Starting date to query the logs in ISO format
* @param {string=} params.end - Ending date to query the logs in ISO format
* @returns {Promise} Axios promise for the result of a GET request of logs
*/
getPodLogs({ environment, podName, containerName, search, start, end }) {
const url = this.buildUrl(environment.logs_api_path);
const params = {};
if (podName) {
params.pod_name = podName;
}
if (containerName) {
params.container_name = containerName;
}
if (search) {
params.search = search;
}
if (start) {
params.start = start;
}
if (end) {
params.end = end;
}
return axios.get(url, { params });
},
};
export default Api;
<script>
import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlDropdown, GlDropdownItem, GlFormGroup, GlSearchBoxByClick, GlAlert } from '@gitlab/ui';
import {
GlSprintf,
GlAlert,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlSearchBoxByClick,
GlInfiniteScroll,
} from '@gitlab/ui';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { scrollDown } from '~/lib/utils/scroll_utils';
import LogControlButtons from './log_control_buttons.vue';
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
import { timeRangeFromUrl } from '~/monitoring/utils';
import { formatDate } from '../utils';
export default {
components: {
GlSprintf,
GlAlert,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlSearchBoxByClick,
GlInfiniteScroll,
DateTimePicker,
LogControlButtons,
},
filters: {
formatDate,
},
props: {
environmentName: {
type: String,
......@@ -39,11 +53,13 @@ export default {
required: true,
},
},
traceHeight: 600,
data() {
return {
searchQuery: '',
timeRanges,
isElasticStackCalloutDismissed: false,
scrollDownButtonDisabled: true,
};
},
computed: {
......@@ -52,7 +68,7 @@ export default {
timeRangeModel: {
get() {
return this.timeRange.current;
return this.timeRange.selected;
},
set(val) {
this.setTimeRange(val);
......@@ -60,7 +76,7 @@ export default {
},
showLoader() {
return this.logs.isLoading || !this.logs.isComplete;
return this.logs.isLoading;
},
advancedFeaturesEnabled() {
const environment = this.environments.options.find(
......@@ -75,16 +91,6 @@ export default {
return !this.isElasticStackCalloutDismissed && this.disableAdvancedControls;
},
},
watch: {
trace(val) {
this.$nextTick(() => {
if (val) {
scrollDown();
}
this.$refs.scrollButtons.update();
});
},
},
mounted() {
this.setInitData({
timeRange: timeRangeFromUrl() || defaultTimeRange,
......@@ -102,12 +108,26 @@ export default {
'showPodLogs',
'showEnvironment',
'fetchEnvironments',
'fetchMoreLogsPrepend',
]),
topReached() {
if (!this.logs.isLoading) {
this.fetchMoreLogsPrepend();
}
},
scrollDown() {
this.$refs.infiniteScroll.scrollDown();
},
scroll: throttle(function scrollThrottled({ target = {} }) {
const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
}, 200),
},
};
</script>
<template>
<div class="build-page-pod-logs mt-3">
<div class="environment-logs-viewer mt-3">
<gl-alert
v-if="shouldShowElasticStackCallout"
class="mb-3 js-elasticsearch-alert"
......@@ -209,14 +229,50 @@ export default {
<log-control-buttons
ref="scrollButtons"
class="controllers align-self-end mb-1"
:scroll-down-button-disabled="scrollDownButtonDisabled"
@refresh="showPodLogs(pods.current)"
@scrollDown="scrollDown"
/>
</div>
<pre class="build-trace js-log-trace"><code class="bash js-build-output">{{trace}}
<div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<gl-infinite-scroll
ref="infiniteScroll"
class="log-lines"
:style="{ height: `${$options.traceHeight}px` }"
:max-list-height="$options.traceHeight"
:fetched-items="logs.lines.length"
@topReached="topReached"
@scroll="scroll"
>
<template #items>
<pre
class="build-trace js-log-trace"
><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div></code></pre>
</div>{{trace}}
</code></pre>
</template>
<template #default
><div></div
></template>
</gl-infinite-scroll>
<div ref="logFooter" class="log-footer py-2 px-3">
<gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
<template #start>{{ timeRange.current.start | formatDate }}</template>
<template #end>{{ timeRange.current.end | formatDate }}</template>
</gl-sprintf>
<gl-sprintf
v-if="!logs.isComplete"
:message="s__('Environments|Currently showing %{fetched} results.')"
>
<template #fetched>{{ logs.lines.length }}</template>
</gl-sprintf>
<template v-else>
{{ s__('Environments|Currently showing all results.') }}</template
>
</div>
</div>
</template>
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
import Icon from '~/vue_shared/components/icon.vue';
export default {
......@@ -17,32 +10,34 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
scrollUpButtonDisabled: {
type: Boolean,
required: false,
default: false,
},
scrollDownButtonDisabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
scrollToTopEnabled: false,
scrollToBottomEnabled: false,
scrollUpAvailable: Boolean(this.$listeners.scrollUp),
scrollDownAvailable: Boolean(this.$listeners.scrollDown),
};
},
created() {
window.addEventListener('scroll', this.update);
},
destroyed() {
window.removeEventListener('scroll', this.update);
},
methods: {
/**
* Checks if page can be scrolled and updates
* enabled/disabled state of buttons accordingly
*/
update() {
this.scrollToTopEnabled = canScroll() && !isScrolledToTop();
this.scrollToBottomEnabled = canScroll() && !isScrolledToBottom();
},
handleRefreshClick() {
this.$emit('refresh');
},
scrollUp,
scrollDown,
handleScrollUp() {
this.$emit('scrollUp');
},
handleScrollDown() {
this.$emit('scrollDown');
},
},
};
</script>
......@@ -50,6 +45,7 @@ export default {
<template>
<div>
<div
v-if="scrollUpAvailable"
v-gl-tooltip
class="controllers-buttons"
:title="__('Scroll to top')"
......@@ -59,13 +55,15 @@ export default {
id="scroll-to-top"
class="btn-blank js-scroll-to-top"
:aria-label="__('Scroll to top')"
:disabled="!scrollToTopEnabled"
@click="scrollUp()"
:disabled="scrollUpButtonDisabled"
@click="handleScrollUp()"
><icon name="scroll_up"
/></gl-button>
</div>
<div
v-if="scrollDownAvailable"
v-gl-tooltip
:disabled="scrollUpButtonDisabled"
class="controllers-buttons"
:title="__('Scroll to bottom')"
aria-labelledby="scroll-to-bottom"
......@@ -74,8 +72,9 @@ export default {
id="scroll-to-bottom"
class="btn-blank js-scroll-to-bottom"
:aria-label="__('Scroll to bottom')"
:disabled="!scrollToBottomEnabled"
@click="scrollDown()"
:v-if="scrollDownAvailable"
:disabled="scrollDownButtonDisabled"
@click="handleScrollDown()"
><icon name="scroll_down"
/></gl-button>
</div>
......
import Api from '~/api';
import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
......@@ -16,9 +15,10 @@ const flashLogsError = () => {
flash(s__('Metrics|There was an error fetching the logs, please try again'));
};
const requestLogsUntilData = params =>
const requestUntilData = (url, params) =>
backOff((next, stop) => {
Api.getPodLogs(params)
axios
.get(url, { params })
.then(res => {
if (res.status === httpStatusCodes.ACCEPTED) {
next();
......@@ -31,10 +31,36 @@ const requestLogsUntilData = params =>
});
});
const requestLogsUntilData = state => {
const params = {};
const { logs_api_path } = state.environments.options.find(
({ name }) => name === state.environments.current,
);
if (state.pods.current) {
params.pod_name = state.pods.current;
}
if (state.search) {
params.search = state.search;
}
if (state.timeRange.current) {
try {
const { start, end } = convertToFixedRange(state.timeRange.current);
params.start = start;
params.end = end;
} catch {
flashTimeRangeWarning();
}
}
if (state.logs.cursor) {
params.cursor = state.logs.cursor;
}
return requestUntilData(logs_api_path, params);
};
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
if (timeRange) {
commit(types.SET_TIME_RANGE, timeRange);
}
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, podName);
};
......@@ -60,10 +86,15 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => {
dispatch('fetchLogs');
};
/**
* Fetch environments data and initial logs
* @param {Object} store
* @param {String} environmentsPath
*/
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
commit(types.REQUEST_ENVIRONMENTS_DATA);
axios
return axios
.get(environmentsPath)
.then(({ data }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
......@@ -76,32 +107,16 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
};
export const fetchLogs = ({ commit, state }) => {
const params = {
environment: state.environments.options.find(({ name }) => name === state.environments.current),
podName: state.pods.current,
search: state.search,
};
if (state.timeRange.current) {
try {
const { start, end } = convertToFixedRange(state.timeRange.current);
params.start = start;
params.end = end;
} catch {
flashTimeRangeWarning();
}
}
commit(types.REQUEST_PODS_DATA);
commit(types.REQUEST_LOGS_DATA);
return requestLogsUntilData(params)
return requestLogsUntilData(state)
.then(({ data }) => {
const { pod_name, pods, logs } = data;
const { pod_name, pods, logs, cursor } = data;
commit(types.SET_CURRENT_POD_NAME, pod_name);
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
commit(types.RECEIVE_LOGS_DATA_SUCCESS, logs);
commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
})
.catch(() => {
commit(types.RECEIVE_PODS_DATA_ERROR);
......@@ -110,5 +125,24 @@ export const fetchLogs = ({ commit, state }) => {
});
};
export const fetchMoreLogsPrepend = ({ commit, state }) => {
if (state.logs.isComplete) {
// return when all logs are loaded
return Promise.resolve();
}
commit(types.REQUEST_LOGS_DATA_PREPEND);
return requestLogsUntilData(state)
.then(({ data }) => {
const { logs, cursor } = data;
commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
})
.catch(() => {
commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
flashLogsError();
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import dateFormat from 'dateformat';
import { formatDate } from '../utils';
export const trace = state =>
state.logs.lines
.map(item => [dateFormat(item.timestamp, 'UTC:mmm dd HH:MM:ss.l"Z"'), item.message].join(' | '))
.join('\n');
const mapTrace = ({ timestamp = null, message = '' }) =>
[timestamp ? formatDate(timestamp) : '', message].join(' | ');
export const trace = state => state.logs.lines.map(mapTrace).join('\n');
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -10,6 +10,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'
export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND';
export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS';
export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR';
export const REQUEST_PODS_DATA = 'REQUEST_PODS_DATA';
export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
......
import * as types from './mutation_types';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
const mapLine = ({ timestamp, message }) => ({
timestamp,
message,
});
export default {
/** Search data */
// Search Data
[types.SET_SEARCH](state, searchQuery) {
state.search = searchQuery;
},
/** Time Range data */
// Time Range Data
[types.SET_TIME_RANGE](state, timeRange) {
state.timeRange.current = timeRange;
state.timeRange.selected = timeRange;
state.timeRange.current = convertToFixedRange(timeRange);
},
/** Environments data */
// Environments Data
[types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
state.environments.current = environmentName;
},
......@@ -28,24 +35,49 @@ export default {
state.environments.isLoading = false;
},
/** Logs data */
// Logs data
[types.REQUEST_LOGS_DATA](state) {
state.timeRange.current = convertToFixedRange(state.timeRange.selected);
state.logs.lines = [];
state.logs.isLoading = true;
// start pagination from the beginning
state.logs.cursor = null;
state.logs.isComplete = false;
},
[types.RECEIVE_LOGS_DATA_SUCCESS](state, lines) {
state.logs.lines = lines;
[types.RECEIVE_LOGS_DATA_SUCCESS](state, { logs = [], cursor }) {
state.logs.lines = logs.map(mapLine);
state.logs.isLoading = false;
state.logs.cursor = cursor;
if (!cursor) {
state.logs.isComplete = true;
}
},
[types.RECEIVE_LOGS_DATA_ERROR](state) {
state.logs.lines = [];
state.logs.isLoading = false;
},
[types.REQUEST_LOGS_DATA_PREPEND](state) {
state.logs.isLoading = true;
},
[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { logs = [], cursor }) {
const lines = logs.map(mapLine);
state.logs.lines = lines.concat(state.logs.lines);
state.logs.isLoading = false;
state.logs.cursor = cursor;
if (!cursor) {
state.logs.isComplete = true;
}
},
[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state) {
state.logs.isLoading = false;
},
/** Pods data */
// Pods data
[types.SET_CURRENT_POD_NAME](state, podName) {
state.pods.current = podName;
},
......
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
export default () => ({
/**
......@@ -11,7 +12,10 @@ export default () => ({
*/
timeRange: {
options: timeRanges,
current: defaultTimeRange,
// Selected time range, can be fixed or relative
selected: defaultTimeRange,
// Current time range, must be fixed
current: convertToFixedRange(defaultTimeRange),
},
/**
......@@ -29,7 +33,12 @@ export default () => ({
logs: {
lines: [],
isLoading: false,
isComplete: true,
/**
* Logs `cursor` represents the current pagination position,
* Should be sent in next batch (page) of logs to be fetched
*/
cursor: null,
isComplete: false,
},
/**
......
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import dateFormat from 'dateformat';
const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
/**
* Returns a time range (`start`, `end`) where `start` is the
......@@ -20,4 +23,6 @@ export const getTimeRange = (seconds = 0) => {
};
};
export const formatDate = timestamp => dateFormat(timestamp, dateFormatMask);
export default {};
......@@ -257,7 +257,6 @@
width: 15px;
height: 15px;
display: $svg-display;
fill: $gl-text-color;
top: $svg-top;
}
......
......@@ -358,17 +358,30 @@
}
}
.build-page-pod-logs {
.environment-logs-viewer {
.build-trace-container {
position: relative;
}
.log-lines,
.gl-infinite-scroll-container {
// makes scrollbar visible by creating contrast
background: $black;
}
.gl-infinite-scroll-legend {
margin: 0;
}
.build-trace {
@include build-trace();
margin: 0;
}
.top-bar {
@include build-trace-top-bar($gl-line-height * 5);
position: relative;
top: 0;
.dropdown-menu-toggle {
width: 200px;
......@@ -395,4 +408,9 @@
.build-loader-animation {
@include build-loader-animation;
}
.log-footer {
color: $white-normal;
background-color: $gray-900;
}
}
---
title: More logs entries are loaded when logs are scrolled to the top
merge_request: 26254
author:
type: added
......@@ -46,13 +46,15 @@ Logs can be displayed by clicking on a specific pod from [Deploy Boards](../depl
### Logs view
The logs view will contain the last 500 lines for a pod, and has control to filter through:
The logs view lets you filter the logs by:
- Pods.
- [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments.
- [From GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/21656), [full text search](#full-text-search).
- [From GitLab 12.8](https://gitlab.com/gitlab-org/gitlab/issues/197879), dates.
Loading more than 500 log lines is possible from [GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/198050) onwards.
Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/13404).
Support for historical data is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/196191).
......
......@@ -5,8 +5,6 @@ require 'spec_helper'
describe 'Environment > Pod Logs', :js do
include KubernetesHelpers
SCROLL_DISTANCE = 400
let(:pod_names) { %w(kube-pod) }
let(:pod_name) { pod_names.first }
let(:project) { create(:project, :repository) }
......@@ -62,49 +60,4 @@ describe 'Environment > Pod Logs', :js do
expect(page).to have_content("Dec 13 14:04:22.123Z | Log 1 Dec 13 14:04:23.123Z | Log 2 Dec 13 14:04:24.123Z | Log 3")
end
end
context 'with perf bar enabled' do
before do
allow(Gitlab::PerformanceBar).to receive(:enabled_for_request?).and_return(true)
end
it 'log header sticks to top' do
load_and_scroll_down
expect(log_header_top).to eq(navbar_height + perf_bar_height)
end
end
context 'with perf bar disabled' do
it 'log header sticks to top' do
load_and_scroll_down
expect(log_header_top).to eq(navbar_height)
end
end
def load_and_scroll_down
visit project_logs_path(environment.project, environment_name: environment.name, pod_name: pod_name)
wait_for_requests
scroll_down_build_log
end
def scroll_down_build_log
page.execute_script("$('.js-build-output').height('200vh')")
page.execute_script("window.scrollTo(0, #{SCROLL_DISTANCE})")
end
def perf_bar_height
page.evaluate_script("$('#js-peek').height()").to_i
end
def navbar_height
page.evaluate_script("$('.js-navbar').height()").to_i
end
def log_header_top
page.evaluate_script("$('.js-top-bar').offset().top") - SCROLL_DISTANCE
end
end
......@@ -165,77 +165,6 @@ describe('Api', () => {
});
});
describe('getPodLogs', () => {
const projectPath = '/root/test-project';
const podName = 'pod';
const containerName = 'container';
const search = 'foo +bar';
const expectedUrl = '/gitlab/dummy_api_path.json';
const environment = {
enable_advanced_logs_querying: false,
project_path: projectPath,
logs_api_path: '/dummy_api_path.json',
};
const getRequest = () => mock.history.get[0];
beforeEach(() => {
mock.onAny().reply(200);
});
afterEach(() => {
mock.reset();
});
it('calls `axios.get` with pod_name and container_name', done => {
Api.getPodLogs({ environment, podName, containerName })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({
pod_name: podName,
container_name: containerName,
});
})
.then(done)
.catch(done.fail);
});
it('calls `axios.get` without pod_name and container_name', done => {
Api.getPodLogs({ environment })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({});
})
.then(done)
.catch(done.fail);
});
it('calls `axios.get` with pod_name', done => {
Api.getPodLogs({ environment, podName })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({
pod_name: podName,
});
})
.then(done)
.catch(done.fail);
});
it('calls `axios.get` with pod_name and search', done => {
Api.getPodLogs({ environment, podName, search })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({
pod_name: podName,
search,
});
})
.then(done)
.catch(done.fail);
});
});
describe('packages', () => {
const projectId = 'project_a';
const packageId = 'package_b';
......
......@@ -7648,6 +7648,12 @@ msgstr ""
msgid "Environments|Commit"
msgstr ""
msgid "Environments|Currently showing %{fetched} results."
msgstr ""
msgid "Environments|Currently showing all results."
msgstr ""
msgid "Environments|Deploy to..."
msgstr ""
......@@ -7681,6 +7687,9 @@ msgstr ""
msgid "Environments|Logs from"
msgstr ""
msgid "Environments|Logs from %{start} to %{end}."
msgstr ""
msgid "Environments|New environment"
msgstr ""
......
import Vue from 'vue';
import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
import { GlSprintf, GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import EnvironmentLogs from '~/logs/components/environment_logs.vue';
......@@ -20,9 +20,18 @@ import {
jest.mock('~/lib/utils/scroll_utils');
const module = 'environmentLogs';
jest.mock('lodash/throttle', () =>
jest.fn(func => {
return func;
}),
);
describe('EnvironmentLogs', () => {
let EnvironmentLogsComponent;
let store;
let dispatch;
let wrapper;
let state;
......@@ -32,14 +41,6 @@ describe('EnvironmentLogs', () => {
clusterApplicationsDocumentationPath: mockDocumentationPath,
};
const actionMocks = {
setInitData: jest.fn(),
setSearch: jest.fn(),
showPodLogs: jest.fn(),
showEnvironment: jest.fn(),
fetchEnvironments: jest.fn(),
};
const updateControlBtnsMock = jest.fn();
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
......@@ -47,24 +48,25 @@ describe('EnvironmentLogs', () => {
const findSearchBar = () => wrapper.find('.js-logs-search');
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
const findInfoAlert = () => wrapper.find('.js-elasticsearch-alert');
const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' });
const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' });
const findLogTrace = () => wrapper.find('.js-log-trace');
const findLogFooter = () => wrapper.find({ ref: 'logFooter' });
const getInfiniteScrollAttr = attr => parseInt(findInfiniteScroll().attributes(attr), 10);
const mockSetInitData = () => {
state.pods.options = mockPods;
state.environments.current = mockEnvName;
[state.pods.current] = state.pods.options;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
state.logs.lines = [];
};
const mockShowPodLogs = podName => {
const mockShowPodLogs = () => {
state.pods.options = mockPods;
[state.pods.current] = podName;
[state.pods.current] = mockPods;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
};
......@@ -83,10 +85,21 @@ describe('EnvironmentLogs', () => {
methods: {
update: updateControlBtnsMock,
},
props: {
scrollDownButtonDisabled: false,
},
},
methods: {
...actionMocks,
GlInfiniteScroll: {
name: 'gl-infinite-scroll',
template: `
<div>
<slot name="header"></slot>
<slot name="items"></slot>
<slot></slot>
</div>
`,
},
GlSprintf,
},
});
};
......@@ -95,12 +108,14 @@ describe('EnvironmentLogs', () => {
store = createStore();
state = store.state.environmentLogs;
EnvironmentLogsComponent = Vue.extend(EnvironmentLogs);
jest.spyOn(store, 'dispatch').mockResolvedValue();
dispatch = store.dispatch;
});
afterEach(() => {
actionMocks.setInitData.mockReset();
actionMocks.showPodLogs.mockReset();
actionMocks.fetchEnvironments.mockReset();
store.dispatch.mockReset();
if (wrapper) {
wrapper.destroy();
......@@ -124,14 +139,14 @@ describe('EnvironmentLogs', () => {
expect(findTimeRangePicker().is(DateTimePicker)).toBe(true);
// log trace
expect(findLogTrace().isEmpty()).toBe(false);
expect(findInfiniteScroll().exists()).toBe(true);
expect(findLogTrace().exists()).toBe(true);
});
it('mounted inits data', () => {
initWrapper();
expect(actionMocks.setInitData).toHaveBeenCalledTimes(1);
expect(actionMocks.setInitData).toHaveBeenLastCalledWith({
expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, {
timeRange: expect.objectContaining({
default: true,
}),
......@@ -139,18 +154,15 @@ describe('EnvironmentLogs', () => {
podName: null,
});
expect(actionMocks.fetchEnvironments).toHaveBeenCalledTimes(1);
expect(actionMocks.fetchEnvironments).toHaveBeenLastCalledWith(mockEnvironmentsEndpoint);
expect(dispatch).toHaveBeenCalledWith(`${module}/fetchEnvironments`, mockEnvironmentsEndpoint);
});
describe('loading state', () => {
beforeEach(() => {
state.pods.options = [];
state.logs = {
lines: [],
isLoading: true,
};
state.logs.lines = [];
state.logs.isLoading = true;
state.environments = {
options: [],
......@@ -183,6 +195,18 @@ describe('EnvironmentLogs', () => {
expect(updateControlBtnsMock).not.toHaveBeenCalled();
});
it('shows an infinite scroll with height and no content', () => {
expect(getInfiniteScrollAttr('max-list-height')).toBeGreaterThan(0);
expect(getInfiniteScrollAttr('fetched-items')).toBe(0);
});
it('shows an infinite scroll container with equal height and max-height ', () => {
const height = getInfiniteScrollAttr('max-list-height');
expect(height).toEqual(expect.any(Number));
expect(findInfiniteScroll().attributes('style')).toMatch(`height: ${height}px;`);
});
it('shows a logs trace', () => {
expect(findLogTrace().text()).toBe('');
expect(
......@@ -193,14 +217,12 @@ describe('EnvironmentLogs', () => {
});
});
describe('legacy environment', () => {
describe('k8s environment', () => {
beforeEach(() => {
state.pods.options = [];
state.logs = {
lines: [],
isLoading: false,
};
state.logs.lines = [];
state.logs.isLoading = false;
state.environments = {
options: mockEnvironments,
......@@ -226,9 +248,16 @@ describe('EnvironmentLogs', () => {
describe('state with data', () => {
beforeEach(() => {
actionMocks.setInitData.mockImplementation(mockSetInitData);
actionMocks.showPodLogs.mockImplementation(mockShowPodLogs);
actionMocks.fetchEnvironments.mockImplementation(mockFetchEnvs);
dispatch.mockImplementation(actionName => {
if (actionName === `${module}/setInitData`) {
mockSetInitData();
} else if (actionName === `${module}/showPodLogs`) {
mockShowPodLogs();
} else if (actionName === `${module}/fetchEnvironments`) {
mockFetchEnvs();
mockShowPodLogs();
}
});
initWrapper();
});
......@@ -236,10 +265,6 @@ describe('EnvironmentLogs', () => {
afterEach(() => {
scrollDown.mockReset();
updateControlBtnsMock.mockReset();
actionMocks.setInitData.mockReset();
actionMocks.showPodLogs.mockReset();
actionMocks.fetchEnvironments.mockReset();
});
it('displays an enabled search bar', () => {
......@@ -249,8 +274,8 @@ describe('EnvironmentLogs', () => {
findSearchBar().vm.$emit('input', mockSearch);
findSearchBar().vm.$emit('submit');
expect(actionMocks.setSearch).toHaveBeenCalledTimes(1);
expect(actionMocks.setSearch).toHaveBeenCalledWith(mockSearch);
expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, expect.any(Object));
expect(dispatch).toHaveBeenCalledWith(`${module}/setSearch`, mockSearch);
});
it('displays an enabled time window dropdown', () => {
......@@ -282,18 +307,21 @@ describe('EnvironmentLogs', () => {
});
});
it('shows infinite scroll with height and no content', () => {
expect(getInfiniteScrollAttr('max-list-height')).toBeGreaterThan(0);
expect(getInfiniteScrollAttr('fetched-items')).toBe(mockTrace.length);
});
it('populates logs trace', () => {
const trace = findLogTrace();
expect(trace.text().split('\n').length).toBe(mockTrace.length);
expect(trace.text().split('\n')).toEqual(mockTrace);
});
it('update control buttons state', () => {
expect(updateControlBtnsMock).toHaveBeenCalledTimes(1);
});
it('populates footer', () => {
const footer = findLogFooter().text();
it('scrolls to bottom when loaded', () => {
expect(scrollDown).toHaveBeenCalledTimes(1);
expect(footer).toContain(`${mockLogsResult.length} results`);
});
describe('when user clicks', () => {
......@@ -301,33 +329,99 @@ describe('EnvironmentLogs', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
const index = 1; // any env
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(0);
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything());
items.at(index).vm.$emit('click');
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(1);
expect(actionMocks.showEnvironment).toHaveBeenLastCalledWith(mockEnvironments[index].name);
expect(dispatch).toHaveBeenCalledWith(
`${module}/showEnvironment`,
mockEnvironments[index].name,
);
});
it('pod name, trace is refreshed', () => {
const items = findPodsDropdown().findAll(GlDropdownItem);
const index = 2; // any pod
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0);
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
items.at(index).vm.$emit('click');
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1);
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPods[index]);
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
});
it('refresh button, trace is refreshed', () => {
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0);
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
findLogControlButtons().vm.$emit('refresh');
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1);
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPodName);
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPodName);
});
});
});
describe('listeners', () => {
beforeEach(() => {
initWrapper();
});
it('attaches listeners in components', () => {
expect(findInfiniteScroll().vm.$listeners).toEqual({
topReached: expect.any(Function),
scroll: expect.any(Function),
});
});
it('`topReached` when not loading', () => {
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`topReached` does not fetches more logs when already loading', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`topReached` fetches more logs', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`scroll` on a scrollable target results in enabled scroll buttons', () => {
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 21 };
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target });
return wrapper.vm.$nextTick(() => {
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(false);
});
});
it('`scroll` on a non-scrollable target in disabled scroll buttons', () => {
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 20 };
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target });
return wrapper.vm.$nextTick(() => {
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
});
});
it('`scroll` on no target results in disabled scroll buttons', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target: undefined });
return wrapper.vm.$nextTick(() => {
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
});
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import LogControlButtons from '~/logs/components/log_control_buttons.vue';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
jest.mock('~/lib/utils/scroll_utils');
describe('LogControlButtons', () => {
let wrapper;
......@@ -18,8 +9,14 @@ describe('LogControlButtons', () => {
const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom');
const findRefreshBtn = () => wrapper.find('.js-refresh-log');
const initWrapper = () => {
wrapper = shallowMount(LogControlButtons);
const initWrapper = opts => {
wrapper = shallowMount(LogControlButtons, {
listeners: {
scrollUp: () => {},
scrollDown: () => {},
},
...opts,
});
};
afterEach(() => {
......@@ -55,27 +52,16 @@ describe('LogControlButtons', () => {
describe('when scrolling actions are enabled', () => {
beforeEach(() => {
// mock scrolled to the middle of a long page
canScroll.mockReturnValue(true);
isScrolledToBottom.mockReturnValue(false);
isScrolledToTop.mockReturnValue(false);
initWrapper();
wrapper.vm.update();
return wrapper.vm.$nextTick();
});
afterEach(() => {
canScroll.mockReset();
isScrolledToTop.mockReset();
isScrolledToBottom.mockReset();
});
it('click on "scroll to top" scrolls up', () => {
expect(findScrollToTop().is('[disabled]')).toBe(false);
findScrollToTop().vm.$emit('click');
expect(scrollUp).toHaveBeenCalledTimes(1);
expect(wrapper.emitted('scrollUp')).toHaveLength(1);
});
it('click on "scroll to bottom" scrolls down', () => {
......@@ -83,25 +69,23 @@ describe('LogControlButtons', () => {
findScrollToBottom().vm.$emit('click');
expect(scrollDown).toHaveBeenCalledTimes(1); // plus one time when trace was loaded
expect(wrapper.emitted('scrollDown')).toHaveLength(1);
});
});
describe('when scrolling actions are disabled', () => {
beforeEach(() => {
// mock a short page without a scrollbar
canScroll.mockReturnValue(false);
isScrolledToBottom.mockReturnValue(true);
isScrolledToTop.mockReturnValue(true);
initWrapper();
initWrapper({ listeners: {} });
return wrapper.vm.$nextTick();
});
it('buttons are disabled', () => {
wrapper.vm.update();
return wrapper.vm.$nextTick(() => {
expect(findScrollToTop().is('[disabled]')).toBe(true);
expect(findScrollToBottom().is('[disabled]')).toBe(true);
expect(findScrollToTop().exists()).toBe(false);
expect(findScrollToBottom().exists()).toBe(false);
// This should be enabled when gitlab-ui contains:
// https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1149
// expect(findScrollToBottom().is('[disabled]')).toBe(true);
});
});
});
......
export const mockProjectPath = 'root/autodevops-deploy';
const mockProjectPath = 'root/autodevops-deploy';
export const mockEnvName = 'production';
export const mockEnvironmentsEndpoint = `${mockProjectPath}/environments.json`;
export const mockEnvId = '99';
export const mockDocumentationPath = '/documentation.md';
export const mockLogsEndpoint = '/dummy_logs_path.json';
export const mockCursor = 'MOCK_CURSOR';
export const mockNextCursor = 'MOCK_NEXT_CURSOR';
const makeMockEnvironment = (id, name, advancedQuerying) => ({
id,
project_path: mockProjectPath,
name,
logs_api_path: '/dummy_logs_path.json',
logs_api_path: mockLogsEndpoint,
enable_advanced_logs_querying: advancedQuerying,
});
......@@ -28,58 +32,22 @@ export const mockPods = [
];
export const mockLogsResult = [
{
timestamp: '2019-12-13T13:43:18.2760123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:26.8420123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:26.8420123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:28.3710123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:28.3710123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:36.8860123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:36.8860123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:38.4000123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:38.4000123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:46.8420123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:46.8430123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:48.3240123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:48.3250123Z', message: '- -> /' },
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: 'Log 1' },
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: 'Log 2' },
{ timestamp: '2019-12-13T13:43:26.8420123Z', message: 'Log 3' },
];
export const mockTrace = [
'Dec 13 13:43:18.276Z | 10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:18.276Z | - -> /',
'Dec 13 13:43:26.842Z | 10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:26.842Z | - -> /',
'Dec 13 13:43:28.371Z | 10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:28.371Z | - -> /',
'Dec 13 13:43:36.886Z | 10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:36.886Z | - -> /',
'Dec 13 13:43:38.400Z | 10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:38.400Z | - -> /',
'Dec 13 13:43:46.842Z | 10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:46.843Z | - -> /',
'Dec 13 13:43:48.324Z | 10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:48.325Z | - -> /',
'Dec 13 13:43:18.276Z | Log 1',
'Dec 13 13:43:18.276Z | Log 2',
'Dec 13 13:43:26.842Z | Log 3',
];
export const mockResponse = {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
cursor: mockNextCursor,
};
export const mockSearch = 'foo +bar';
......@@ -10,6 +10,7 @@ import {
showPodLogs,
fetchEnvironments,
fetchLogs,
fetchMoreLogsPrepend,
} from '~/logs/stores/actions';
import { defaultTimeRange } from '~/monitoring/constants';
......@@ -18,7 +19,6 @@ import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import {
mockProjectPath,
mockPodName,
mockEnvironmentsEndpoint,
mockEnvironments,
......@@ -26,6 +26,10 @@ import {
mockLogsResult,
mockEnvName,
mockSearch,
mockLogsEndpoint,
mockResponse,
mockCursor,
mockNextCursor,
} from '../mock_data';
jest.mock('~/flash');
......@@ -52,6 +56,8 @@ describe('Logs Store actions', () => {
let state;
let mock;
const latestGetParams = () => mock.history.get[mock.history.get.length - 1].params;
convertToFixedRange.mockImplementation(range => {
if (range === defaultTimeRange) {
return { ...mockDefaultRange };
......@@ -75,10 +81,16 @@ describe('Logs Store actions', () => {
describe('setInitData', () => {
it('should commit environment and pod name mutation', () =>
testAction(setInitData, { environmentName: mockEnvName, podName: mockPodName }, state, [
testAction(
setInitData,
{ timeRange: mockFixedRange, environmentName: mockEnvName, podName: mockPodName },
state,
[
{ type: types.SET_TIME_RANGE, payload: mockFixedRange },
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
]));
],
));
});
describe('setSearch', () => {
......@@ -140,183 +152,245 @@ describe('Logs Store actions', () => {
});
});
describe('fetchLogs', () => {
describe('when the backend responds succesfully', () => {
let expectedMutations;
let expectedActions;
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
});
afterEach(() => {
mock.reset();
});
describe('fetchLogs', () => {
beforeEach(() => {
expectedMutations = [
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{
type: types.RECEIVE_LOGS_DATA_SUCCESS,
payload: { logs: mockLogsResult, cursor: mockNextCursor },
},
];
expectedActions = [];
});
it('should commit logs and pod data when there is pod name defined', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
const endpoint = '/dummy_logs_path.json';
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toMatchObject({
pod_name: mockPodName,
});
});
});
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
state.logs.cursor = mockCursor;
mock
.onGet(endpoint, {
params: {
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
...mockDefaultRange,
},
})
.reply(200, {
start: mockFixedRange.start,
end: mockFixedRange.end,
cursor: mockCursor,
});
});
});
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
search: mockSearch,
});
// Warning about time ranges was issued
expect(flash).toHaveBeenCalledTimes(1);
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
});
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
it('should commit logs and pod data when no pod name defined', () => {
state.timeRange.current = mockDefaultRange;
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toEqual({});
});
});
});
describe('fetchMoreLogsPrepend', () => {
beforeEach(() => {
expectedMutations = [
{ type: types.REQUEST_LOGS_DATA_PREPEND },
{
type: types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS,
payload: { logs: mockLogsResult, cursor: mockNextCursor },
},
];
expectedActions = [];
});
it('should commit logs and pod data when there is pod name defined', () => {
state.pods.current = mockPodName;
expectedActions = [];
return testAction(
fetchLogs,
fetchMoreLogsPrepend,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toMatchObject({
pod_name: mockPodName,
});
},
);
});
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
state.projectPath = mockProjectPath;
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
state.logs.cursor = mockCursor;
const endpoint = '/dummy_logs_path.json';
mock
.onGet(endpoint, {
params: {
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
start: mockFixedRange.start,
end: mockFixedRange.end,
},
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
cursor: mockCursor,
});
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
},
);
});
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
const endpoint = '/dummy_logs_path.json';
mock
.onGet(endpoint, {
params: {
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
search: mockSearch,
});
// Warning about time ranges was issued
expect(flash).toHaveBeenCalledTimes(1);
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
},
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
);
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
it('should commit logs and pod data when no pod name defined', () => {
state.timeRange.current = mockDefaultRange;
return testAction(
fetchLogs,
fetchMoreLogsPrepend,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
expectedMutations,
expectedActions,
() => {
// Warning about time ranges was issued
expect(flash).toHaveBeenCalledTimes(1);
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
expect(latestGetParams()).toEqual({});
},
);
});
it('should commit logs and pod data when no pod name defined', done => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
it('should not commit logs or pod data when it has reached the end', () => {
state.logs.isComplete = true;
state.logs.cursor = null;
const endpoint = '/dummy_logs_path.json';
return testAction(
fetchMoreLogsPrepend,
null,
state,
[], // no mutations done
[], // no actions dispatched
() => {
expect(mock.history.get).toHaveLength(0);
},
);
});
});
});
mock.onGet(endpoint, { params: { ...mockDefaultRange } }).reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
describe('when the backend responds with an error', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(mockLogsEndpoint).reply(500);
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
testAction(
afterEach(() => {
mock.reset();
});
it('fetchLogs should commit logs and pod errors', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
{ type: types.RECEIVE_PODS_DATA_ERROR },
{ type: types.RECEIVE_LOGS_DATA_ERROR },
],
[],
done,
() => {
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
},
);
});
it('should commit logs and pod errors when backend fails', () => {
it('fetchMoreLogsPrepend should commit logs and pod errors', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
const endpoint = `/${mockProjectPath}/-/logs/elasticsearch.json?environment_name=${mockEnvName}`;
mock.onGet(endpoint).replyOnce(500);
return testAction(
fetchLogs,
fetchMoreLogsPrepend,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.RECEIVE_PODS_DATA_ERROR },
{ type: types.RECEIVE_LOGS_DATA_ERROR },
{ type: types.REQUEST_LOGS_DATA_PREPEND },
{ type: types.RECEIVE_LOGS_DATA_PREPEND_ERROR },
],
[],
() => {
expect(flash).toHaveBeenCalledTimes(1);
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
},
);
});
......
......@@ -9,6 +9,8 @@ import {
mockPodName,
mockLogsResult,
mockSearch,
mockCursor,
mockNextCursor,
} from '../mock_data';
describe('Logs Store Mutations', () => {
......@@ -73,27 +75,47 @@ describe('Logs Store Mutations', () => {
it('starts loading for logs', () => {
mutations[types.REQUEST_LOGS_DATA](state);
expect(state.logs).toEqual(
expect.objectContaining({
expect(state.timeRange.current).toEqual({
start: expect.any(String),
end: expect.any(String),
});
expect(state.logs).toEqual({
lines: [],
cursor: null,
isLoading: true,
isComplete: false,
}),
);
});
});
});
describe('RECEIVE_LOGS_DATA_SUCCESS', () => {
it('receives logs lines', () => {
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, mockLogsResult);
it('receives logs lines and cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockCursor,
});
expect(state.logs).toEqual(
expect.objectContaining({
expect(state.logs).toEqual({
lines: mockLogsResult,
isLoading: false,
cursor: mockCursor,
isComplete: false,
});
});
it('receives logs lines and a null cursor to indicate the end', () => {
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
logs: mockLogsResult,
cursor: null,
});
expect(state.logs).toEqual({
lines: mockLogsResult,
isLoading: false,
cursor: null,
isComplete: true,
}),
);
});
});
});
......@@ -101,13 +123,77 @@ describe('Logs Store Mutations', () => {
it('receives log data error and stops loading', () => {
mutations[types.RECEIVE_LOGS_DATA_ERROR](state);
expect(state.logs).toEqual(
expect.objectContaining({
expect(state.logs).toEqual({
lines: [],
isLoading: false,
cursor: null,
isComplete: false,
});
});
});
describe('REQUEST_LOGS_DATA_PREPEND', () => {
it('receives logs lines and cursor', () => {
mutations[types.REQUEST_LOGS_DATA_PREPEND](state);
expect(state.logs.isLoading).toBe(true);
});
});
describe('RECEIVE_LOGS_DATA_PREPEND_SUCCESS', () => {
it('receives logs lines and cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockCursor,
});
expect(state.logs).toEqual({
lines: mockLogsResult,
isLoading: false,
cursor: mockCursor,
isComplete: false,
});
});
it('receives additional logs lines and a new cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockCursor,
});
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockNextCursor,
});
expect(state.logs).toEqual({
lines: [...mockLogsResult, ...mockLogsResult],
isLoading: false,
cursor: mockNextCursor,
isComplete: false,
});
});
it('receives logs lines and a null cursor to indicate is complete', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: null,
});
expect(state.logs).toEqual({
lines: mockLogsResult,
isLoading: false,
cursor: null,
isComplete: true,
}),
);
});
});
});
describe('RECEIVE_LOGS_DATA_PREPEND_ERROR', () => {
it('receives logs lines and cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state);
expect(state.logs.isLoading).toBe(false);
});
});
......@@ -121,6 +207,7 @@ describe('Logs Store Mutations', () => {
describe('SET_TIME_RANGE', () => {
it('sets a default range', () => {
expect(state.timeRange.selected).toEqual(expect.any(Object));
expect(state.timeRange.current).toEqual(expect.any(Object));
});
......@@ -131,12 +218,13 @@ describe('Logs Store Mutations', () => {
};
mutations[types.SET_TIME_RANGE](state, mockRange);
expect(state.timeRange.selected).toEqual(mockRange);
expect(state.timeRange.current).toEqual(mockRange);
});
});
describe('REQUEST_PODS_DATA', () => {
it('receives log data error and stops loading', () => {
it('receives pods data', () => {
mutations[types.REQUEST_PODS_DATA](state);
expect(state.pods).toEqual(
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment