From fdb01d39f1e8b90505a0423afd743cb8893e6485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Mon, 27 Aug 2018 22:27:27 +0100 Subject: [PATCH 01/20] fix(ui): fix typos and make code more testable --- ui/src/Components/SilenceModal/SilenceSubmitProgress.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/Components/SilenceModal/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmitProgress.js index 693291b3b..a19fd2fa3 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmitProgress.js @@ -51,6 +51,8 @@ const SilenceSubmitProgress = observer( submitState = observable( { + // store fetch result here, useful for testing + fetch: null, value: SubmitState.InProgress, result: null, markDone(result) { @@ -68,7 +70,7 @@ const SilenceSubmitProgress = observer( handleAlertmanagerRequest = () => { const { uri, payload } = this.props; - fetch(`${uri}/api/v1/silences`, { + this.submitState.fetch = fetch(`${uri}/api/v1/silences`, { method: "POST", body: JSON.stringify(payload), headers: { @@ -91,8 +93,11 @@ const SilenceSubmitProgress = observer( } else if (response.status === "error") { this.submitState.markFailed(response.error); } else { - this.submitState.markFailed(JSON.strigify(response)); + this.submitState.markFailed(JSON.stringify(response)); } + + // return status so we can assert it in tests + return response.status; }; componentDidMount() { From dcff54f1aef1b69a4331e923956fb757484d284f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Mon, 27 Aug 2018 22:27:46 +0100 Subject: [PATCH 02/20] feat(tests): add tests for SilenceSubmitProgress --- .../SilenceSubmitProgress.test.js | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 ui/src/Components/SilenceModal/SilenceSubmitProgress.test.js diff --git a/ui/src/Components/SilenceModal/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmitProgress.test.js new file mode 100644 index 000000000..eb64be239 --- /dev/null +++ b/ui/src/Components/SilenceModal/SilenceSubmitProgress.test.js @@ -0,0 +1,88 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import { SilenceSubmitProgress } from "./SilenceSubmitProgress"; + +const MountedSilenceSubmitProgress = () => { + return mount( + + ); +}; + +describe("", () => { + it("sends a request on mount", () => { + MountedSilenceSubmitProgress(); + expect(fetch.mock.calls).toHaveLength(1); + }); + + it("appends /api/v1/silences to the passed URI", () => { + MountedSilenceSubmitProgress(); + const uri = fetch.mock.calls[0][0]; + expect(uri).toBe("http://localhost/mock/api/v1/silences"); + }); + + it("sends correct JSON payload", () => { + MountedSilenceSubmitProgress(); + const payload = fetch.mock.calls[0][1]; + expect(payload).toMatchObject({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ foo: "bar" }) + }); + }); + + it("renders returned silence ID on successful fetch", async () => { + fetch.mockResponseOnce( + JSON.stringify({ status: "success", data: { silenceId: "123456789" } }) + ); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe("success"); + // force re-render + tree.update(); + const silenceLink = tree.find("a"); + expect(silenceLink).toHaveLength(1); + expect(silenceLink.text()).toBe("123456789"); + }); + + it("renders returned error message on failed fetch", async () => { + fetch.mockRejectOnce(new Error("mock error message")); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBeUndefined(); + expect(tree.text()).toBe("mockAlertmanagermock error message"); + }); + + it("renders success icon on successful fetch", async () => { + fetch.mockResponseOnce( + JSON.stringify({ status: "success", data: { silenceId: "123" } }) + ); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe("success"); + tree.update(); + expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(1); + expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(0); + }); + + it("renders error icon on failed fetch", async () => { + fetch.mockResponseOnce(JSON.stringify({ status: "error" })); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe("error"); + tree.update(); + expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(0); + expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(1); + }); + + it("renders unhandled 'status' values in the response as error", async () => { + fetch.mockResponseOnce(JSON.stringify({ status: "unhandled" })); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe("unhandled"); + tree.update(); + expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(0); + expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(1); + expect(tree.text()).toBe('mockAlertmanager{"status":"unhandled"}'); + }); +}); From 241cd2fa087d31128a24f94b27f048322f1620ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Mon, 27 Aug 2018 23:22:49 +0100 Subject: [PATCH 03/20] feat(tests): add test coverage for AlertManagerInput --- .../SilenceModal/AlertManagerInput.test.js | 110 +++++++++++++ .../AlertManagerInput.test.js.snap | 155 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 ui/src/Components/SilenceModal/AlertManagerInput.test.js create mode 100644 ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap diff --git a/ui/src/Components/SilenceModal/AlertManagerInput.test.js b/ui/src/Components/SilenceModal/AlertManagerInput.test.js new file mode 100644 index 000000000..1e7257fb9 --- /dev/null +++ b/ui/src/Components/SilenceModal/AlertManagerInput.test.js @@ -0,0 +1,110 @@ +import React from "react"; + +import { shallow, mount } from "enzyme"; + +import { AlertStore } from "Stores/AlertStore"; +import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { AlertManagerInput } from "./AlertManagerInput"; + +let alertStore; +let silenceFormStore; + +const AlertmanagerOption = index => ({ + label: `am${index}`, + value: `http://am${index}.example.com` +}); + +beforeEach(() => { + alertStore = new AlertStore([]); + alertStore.data.upstreams.instances = [ + { name: "am1", uri: "http://am1.example.com", error: "" }, + { name: "am2", uri: "http://am2.example.com", error: "" }, + { name: "am3", uri: "http://am3.example.com", error: "" } + ]; + silenceFormStore = new SilenceFormStore(); +}); + +const ShallowAlertManagerInput = () => { + return shallow( + + ); +}; + +const MountedAlertManagerInput = () => { + return mount( + + ); +}; + +const ValidateSuggestions = () => { + const tree = MountedAlertManagerInput(); + // clear all selected instances, they are selected by default + const clear = tree.find("ClearIndicator"); + // https://github.com/JedWatson/react-select/blob/c22d296d50917e210836fb011ae3e565895e6440/src/__tests__/Select.test.js#L1873 + clear.simulate("mousedown", { button: 0 }); + // click on the react-select component doesn't seem to trigger options + // rendering in tests, so change the input instead + tree.find("input").simulate("change", { target: { value: "am" } }); + return tree; +}; + +describe("", () => { + it("matches snapshot", () => { + const tree = ShallowAlertManagerInput(); + expect(tree).toMatchSnapshot(); + }); + + it("all available Alertmanager instances are selected by default", () => { + ShallowAlertManagerInput(); + expect(silenceFormStore.data.alertmanagers).toHaveLength(3); + for (let i = 1; i <= 3; i++) { + expect(silenceFormStore.data.alertmanagers).toContainEqual( + AlertmanagerOption(i) + ); + } + }); + + it("renders all 3 suggestions", () => { + const tree = ValidateSuggestions(); + const options = tree.find("[role='option']"); + expect(options).toHaveLength(3); + expect(options.at(0).text()).toBe("am1"); + expect(options.at(1).text()).toBe("am2"); + expect(options.at(2).text()).toBe("am3"); + }); + + it("clicking on options appends them to silenceFormStore.data.alertmanagers", () => { + const tree = ValidateSuggestions(); + const options = tree.find("[role='option']"); + options.at(0).simulate("click"); + options.at(2).simulate("click"); + expect(silenceFormStore.data.alertmanagers).toHaveLength(2); + expect(silenceFormStore.data.alertmanagers).toContainEqual( + AlertmanagerOption(1) + ); + expect(silenceFormStore.data.alertmanagers).toContainEqual( + AlertmanagerOption(3) + ); + }); + + it("silenceFormStore.data.alertmanagers gets updated from alertStore.data.upstreams.instances on mismatch", () => { + const tree = ShallowAlertManagerInput(); + alertStore.data.upstreams.instances[0] = { + name: "am1", + uri: "http://am1.example.com/new", + error: "" + }; + // force update since this is where the mismatch check lives + tree.instance().componentDidUpdate(); + expect(silenceFormStore.data.alertmanagers).toContainEqual({ + label: "am1", + value: "http://am1.example.com/new" + }); + }); +}); diff --git a/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap b/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap new file mode 100644 index 000000000..0a6937c7b --- /dev/null +++ b/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap @@ -0,0 +1,155 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` + false, + "nodeType" => false, + "$$typeof" => false, + "@@__IMMUTABLE_LIST__@@" => false, + "@@__IMMUTABLE_SET__@@" => false, + "@@__IMMUTABLE_MAP__@@" => false, + "@@__IMMUTABLE_STACK__@@" => false, + "toJSON" => false, + }, + "proxy": [Circular], + "target": Object { + "label": "am1", + "value": "http://am1.example.com", + Symbol(mobx administration): [Circular], + }, + "values": Map { + "label" => "am1", + "value" => "http://am1.example.com", + }, + }, + }, + Object { + "label": "am2", + "value": "http://am2.example.com", + Symbol(mobx administration): ObservableObjectAdministration$$1 { + "defaultEnhancer": [Function], + "keysAtom": Atom$$1 { + "diffValue": 0, + "isBeingObserved": false, + "isPendingUnobservation": false, + "lastAccessedBy": 0, + "lowestObserverState": 2, + "name": "Silence form store.alertmanagers[..].keys", + "observers": Set {}, + }, + "name": "Silence form store.alertmanagers[..]", + "pendingKeys": Map { + "cheerio" => false, + "nodeType" => false, + "$$typeof" => false, + "@@__IMMUTABLE_LIST__@@" => false, + "@@__IMMUTABLE_SET__@@" => false, + "@@__IMMUTABLE_MAP__@@" => false, + "@@__IMMUTABLE_STACK__@@" => false, + "toJSON" => false, + }, + "proxy": [Circular], + "target": Object { + "label": "am2", + "value": "http://am2.example.com", + Symbol(mobx administration): [Circular], + }, + "values": Map { + "label" => "am2", + "value" => "http://am2.example.com", + }, + }, + }, + Object { + "label": "am3", + "value": "http://am3.example.com", + Symbol(mobx administration): ObservableObjectAdministration$$1 { + "defaultEnhancer": [Function], + "keysAtom": Atom$$1 { + "diffValue": 0, + "isBeingObserved": false, + "isPendingUnobservation": false, + "lastAccessedBy": 0, + "lowestObserverState": 2, + "name": "Silence form store.alertmanagers[..].keys", + "observers": Set {}, + }, + "name": "Silence form store.alertmanagers[..]", + "pendingKeys": Map { + "cheerio" => false, + "nodeType" => false, + "$$typeof" => false, + "@@__IMMUTABLE_LIST__@@" => false, + "@@__IMMUTABLE_SET__@@" => false, + "@@__IMMUTABLE_MAP__@@" => false, + "@@__IMMUTABLE_STACK__@@" => false, + "toJSON" => false, + }, + "proxy": [Circular], + "target": Object { + "label": "am3", + "value": "http://am3.example.com", + Symbol(mobx administration): [Circular], + }, + "values": Map { + "label" => "am3", + "value" => "http://am3.example.com", + }, + }, + }, + ] + } + instanceId="silence-input-alertmanagers" + isMulti={true} + onChange={[Function]} + options={ + Array [ + Object { + "label": "am1", + "value": "http://am1.example.com", + }, + Object { + "label": "am2", + "value": "http://am2.example.com", + }, + Object { + "label": "am3", + "value": "http://am3.example.com", + }, + ] + } + placeholder="Alertmanager" + styles={ + Object { + "control": [Function], + "indicatorsContainer": [Function], + "multiValue": [Function], + "multiValueLabel": [Function], + "multiValueRemove": [Function], + "option": [Function], + "valueContainer": [Function], + "valueLabel": [Function], + } + } +/> +`; From edba052ecda735d04528a6d250611bc94485c508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 29 Aug 2018 19:05:53 +0100 Subject: [PATCH 04/20] feat(tests): add basic tests for DateTimeSelect --- .../SilenceModal/DateTimeSelect/index.test.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 ui/src/Components/SilenceModal/DateTimeSelect/index.test.js diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js b/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js new file mode 100644 index 000000000..030b41091 --- /dev/null +++ b/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js @@ -0,0 +1,25 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { DateTimeSelect } from "."; + +let silenceFormStore; + +beforeEach(() => { + silenceFormStore = new SilenceFormStore(); +}); + +const MountedDateTimeSelect = () => { + return mount(); +}; + +describe("", () => { + it("renders 'Duration' tab by default", () => { + const tree = MountedDateTimeSelect(); + const tab = tree.find(".nav-link.active"); + expect(tab).toHaveLength(1); + expect(tab.text()).toMatch(/Duration/); + }); +}); From 3b680d9217411680711dac8eea8e42d8aacfa1a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 29 Aug 2018 22:32:14 +0100 Subject: [PATCH 05/20] feat(tests): export internal components so we can unit test them directly --- ui/src/Components/SilenceModal/DateTimeSelect/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/index.js b/ui/src/Components/SilenceModal/DateTimeSelect/index.js index 65f653a9f..6867d867a 100644 --- a/ui/src/Components/SilenceModal/DateTimeSelect/index.js +++ b/ui/src/Components/SilenceModal/DateTimeSelect/index.js @@ -243,4 +243,4 @@ const DateTimeSelect = observer( } ); -export { DateTimeSelect }; +export { DateTimeSelect, TabContentStart, TabContentEnd, TabContentDuration }; From e7b17f53268dc4902f1019500000830dcaf7b6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 29 Aug 2018 22:32:37 +0100 Subject: [PATCH 06/20] feat(tests): add more tests for DateTimeSelect --- .../SilenceModal/DateTimeSelect/index.test.js | 197 +++++++++++++++++- 1 file changed, 195 insertions(+), 2 deletions(-) diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js b/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js index 030b41091..878512442 100644 --- a/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js +++ b/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js @@ -1,25 +1,218 @@ import React from "react"; -import { mount } from "enzyme"; +import { mount, shallow } from "enzyme"; + +import moment from "moment"; import { SilenceFormStore } from "Stores/SilenceFormStore"; -import { DateTimeSelect } from "."; +import { + DateTimeSelect, + TabContentStart, + TabContentEnd, + TabContentDuration +} from "."; let silenceFormStore; beforeEach(() => { silenceFormStore = new SilenceFormStore(); + silenceFormStore.data.startsAt = moment([2060, 1, 1, 0, 0, 0]); + silenceFormStore.data.endsAt = moment([2061, 1, 1, 0, 0, 0]); }); +const ShallowDateTimeSelect = () => { + return shallow(); +}; + const MountedDateTimeSelect = () => { return mount(); }; describe("", () => { + it("renders 3 tabs", () => { + const tree = ShallowDateTimeSelect(); + const tabs = tree.find("Tab"); + expect(tabs).toHaveLength(3); + }); + it("renders 'Duration' tab by default", () => { const tree = MountedDateTimeSelect(); const tab = tree.find(".nav-link.active"); expect(tab).toHaveLength(1); + // check tab title expect(tab.text()).toMatch(/Duration/); + // check tab content + expect(tree.find(".tab-content").text()).toBe("366days0hours0minutes"); + }); + + it("clicking on the 'Starts' tab switches content to 'startsAt' selection", () => { + const tree = MountedDateTimeSelect(); + const tab = tree.find(".nav-link").at(0); + expect(tab.text()).toMatch(/Starts/); + tab.simulate("click"); + expect(tree.find(".tab-content").text()).toMatch(/2060/); + }); + + it("clicking on the 'Ends' tab switches content to 'endsAt' selection", () => { + const tree = MountedDateTimeSelect(); + const tab = tree.find(".nav-link").at(1); + expect(tab.text()).toMatch(/Ends/); + tab.simulate("click"); + expect(tree.find(".tab-content").text()).toMatch(/2061/); + }); + + it("clicking on the 'Duration' tabs switches content to duration selection", () => { + const tree = MountedDateTimeSelect(); + // first switch to 'Starts' + tree + .find(".nav-link") + .at(0) + .simulate("click"); + // then switch back to 'Duration' + const tab = tree.find(".nav-link").at(2); + expect(tab.text()).toMatch(/Duration/); + tab.simulate("click"); + expect(tree.find(".tab-content").text()).toBe("366days0hours0minutes"); + }); +}); + +const ValidateTimeButton = ( + tab, + storeKey, + elemIndex, + iconMatch, + expectedDiff +) => { + const button = tab.find("td > span").at(elemIndex); + expect(button.html()).toMatch(iconMatch); + + const oldTimeValue = moment(silenceFormStore.data[storeKey]); + button.simulate("click"); + expect(silenceFormStore.data[storeKey].toISOString()).not.toBe( + oldTimeValue.toISOString() + ); + const diffMS = silenceFormStore.data[storeKey].diff(oldTimeValue); + expect(diffMS).toBe(expectedDiff); +}; + +const ShallowTabContentStart = () => { + return shallow(); +}; + +const MountedTabContentStart = () => { + return mount(); +}; + +describe("", () => { + it("selecting date on DatePicker updates startsAt", () => { + const tree = ShallowTabContentStart(); + const picker = tree.find("DatePicker"); + const startsAt = moment([2063, 10, 10, 0, 1, 2]); + picker.simulate("change", startsAt); + expect(silenceFormStore.data.startsAt.toISOString()).toBe( + startsAt.toISOString() + ); + }); + + it("clicking on the hour inc button adds 1h to startsAt", () => { + const tree = MountedTabContentStart(); + ValidateTimeButton(tree, "startsAt", 0, /angle-up/, 3600 * 1000); + }); + + it("clicking on the minute inc button adds 1m to startsAt", () => { + const tree = MountedTabContentStart(); + ValidateTimeButton(tree, "startsAt", 1, /angle-up/, 60 * 1000); + }); + + it("clicking on the hour dec button subtracts 1h from startsAt", () => { + const tree = MountedTabContentStart(); + ValidateTimeButton(tree, "startsAt", 2, /angle-down/, -1 * 3600 * 1000); + }); + + it("clicking on the minute dec button subtracts 1m from startsAt", () => { + const tree = MountedTabContentStart(); + ValidateTimeButton(tree, "startsAt", 3, /angle-down/, -1 * 60 * 1000); + }); +}); + +const ShallowTabContentEnd = () => { + return shallow(); +}; + +const MountedTabContentEnd = () => { + return mount(); +}; + +describe("", () => { + it("Selecting date on DatePicker updates endsAt", () => { + const tree = ShallowTabContentEnd(); + const picker = tree.find("DatePicker"); + const endsAt = moment([2063, 11, 5, 1, 3, 2]); + picker.simulate("change", endsAt); + expect(silenceFormStore.data.endsAt.toISOString()).toBe( + endsAt.toISOString() + ); + }); + + it("clicking on the hour inc button adds 1h to endsAt", () => { + const tree = MountedTabContentEnd(); + ValidateTimeButton(tree, "endsAt", 0, /angle-up/, 3600 * 1000); + }); + + it("clicking on the minute inc button adds 1m to endsAt", () => { + const tree = MountedTabContentEnd(); + ValidateTimeButton(tree, "endsAt", 1, /angle-up/, 60 * 1000); + }); + + it("clicking on the hour dec button subtracts 1h from endsAt", () => { + const tree = MountedTabContentEnd(); + ValidateTimeButton(tree, "endsAt", 2, /angle-down/, -1 * 3600 * 1000); + }); + + it("clicking on the minute dec button subtracts 1m from endsAt", () => { + const tree = MountedTabContentEnd(); + ValidateTimeButton(tree, "endsAt", 3, /angle-down/, -1 * 60 * 1000); + }); +}); + +const ValidateDurationButton = (elemIndex, iconMatch, expectedDiff) => { + const tree = mount( + + ); + const button = tree.find("td > span").at(elemIndex); + expect(button.html()).toMatch(iconMatch); + + const oldEndsAt = moment(silenceFormStore.data.endsAt); + button.simulate("click"); + expect(silenceFormStore.data.endsAt.toISOString()).not.toBe( + oldEndsAt.toISOString() + ); + const diffMS = silenceFormStore.data.endsAt.diff(oldEndsAt); + expect(diffMS).toBe(expectedDiff); +}; + +describe("", () => { + it("clicking on the day inc button adds 1d to endsAt", () => { + ValidateDurationButton(0, /angle-up/, 24 * 3600 * 1000); + }); + + it("clicking on the day dec button subtracts 1d from endsAt", () => { + ValidateDurationButton(2, /angle-down/, -1 * 24 * 3600 * 1000); + }); + + it("clicking on the hour inc button adds 1h to endsAt", () => { + ValidateDurationButton(3, /angle-up/, 3600 * 1000); + }); + + it("clicking on the hour dec button subtracts 1h from endsAt", () => { + ValidateDurationButton(5, /angle-down/, -1 * 3600 * 1000); + }); + + it("clicking on the minute inc button adds 5m to endsAt", () => { + ValidateDurationButton(6, /angle-up/, 5 * 60 * 1000); + }); + + it("clicking on the minute dec button subtracts 5m from endsAt", () => { + ValidateDurationButton(8, /angle-down/, -1 * 5 * 60 * 1000); }); }); From ec16a1cbce139728646a97ed193abed54b8bcbdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 29 Aug 2018 23:38:14 +0100 Subject: [PATCH 07/20] fix(ui): fix duration minute decrease handling Negative adjustment needs a different logic than positive one --- .../SilenceModal/DateTimeSelect/index.js | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/index.js b/ui/src/Components/SilenceModal/DateTimeSelect/index.js index 6867d867a..650f0192c 100644 --- a/ui/src/Components/SilenceModal/DateTimeSelect/index.js +++ b/ui/src/Components/SilenceModal/DateTimeSelect/index.js @@ -107,8 +107,8 @@ const TabContentEnd = observer(({ silenceFormStore }) => { ); }); -// calculate value for duration increase and decrease buttons using a goal step -const CalculateChangeValue = (currentValue, step) => { +// calculate value for duration increase button using a goal step +const CalculateChangeValueUp = (currentValue, step) => { // if current value is less than step (but >0) then use 1 if (currentValue > 0 && currentValue < step) { return 1; @@ -117,6 +117,16 @@ const CalculateChangeValue = (currentValue, step) => { return step - (currentValue % step) || step; }; +// calculate value for duration decrease button using a goal step +const CalculateChangeValueDown = (currentValue, step) => { + // if current value is less than step (but >0) then use 1 + if (currentValue > 0 && currentValue < step) { + return 1; + } + // otherwise use step or a value that moves current value to the next step + return currentValue % step || step; +}; + const TabContentDuration = observer(({ silenceFormStore }) => { return (
@@ -137,12 +147,15 @@ const TabContentDuration = observer(({ silenceFormStore }) => { value={silenceFormStore.data.toDuration.minutes} onInc={() => silenceFormStore.data.incEnd( - CalculateChangeValue(silenceFormStore.data.toDuration.minutes, 5) + CalculateChangeValueUp(silenceFormStore.data.toDuration.minutes, 5) ) } onDec={() => silenceFormStore.data.decEnd( - CalculateChangeValue(silenceFormStore.data.toDuration.minutes, 5) + CalculateChangeValueDown( + silenceFormStore.data.toDuration.minutes, + 5 + ) ) } /> From c6acd70f0d2a11fa3e4204aeba1efbfefb6e4fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 29 Aug 2018 23:38:40 +0100 Subject: [PATCH 08/20] feat(tests): add tests covering special minute value adjustement logic --- .../SilenceModal/DateTimeSelect/index.test.js | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js b/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js index 878512442..ec4a59d76 100644 --- a/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js +++ b/ui/src/Components/SilenceModal/DateTimeSelect/index.test.js @@ -216,3 +216,91 @@ describe("", () => { ValidateDurationButton(8, /angle-down/, -1 * 5 * 60 * 1000); }); }); + +const SetDurationTo = (hours, minutes) => { + const startsAt = moment([2060, 1, 1, 0, 0, 0]); + const endsAt = moment(startsAt) + .add(hours, "hours") + .add(minutes, "minutes"); + silenceFormStore.data.startsAt = startsAt; + silenceFormStore.data.endsAt = endsAt; +}; + +describe(" inc minute CalculateChangeValue", () => { + it("inc on 0:1:0 duration sets 0:1:5", () => { + SetDurationTo(1, 0); + ValidateDurationButton(6, /angle-up/, 5 * 60 * 1000); + }); + + it("inc on 0:1:1 duration sets 0:1:2", () => { + SetDurationTo(1, 1); + ValidateDurationButton(6, /angle-up/, 60 * 1000); + }); + + it("inc on 0:1:4 duration sets 0:1:5", () => { + SetDurationTo(1, 4); + ValidateDurationButton(6, /angle-up/, 60 * 1000); + }); + + it("inc on 0:1:5 duration sets 0:1:10", () => { + SetDurationTo(1, 5); + ValidateDurationButton(6, /angle-up/, 5 * 60 * 1000); + }); + + it("inc on 0:1:6 duration sets 0:1:10", () => { + SetDurationTo(1, 6); + ValidateDurationButton(6, /angle-up/, 4 * 60 * 1000); + }); + + it("inc on 0:0:55 duration sets 0:1:0", () => { + SetDurationTo(0, 55); + ValidateDurationButton(6, /angle-up/, 5 * 60 * 1000); + }); +}); + +describe(" dec minute CalculateChangeValue", () => { + it("inc on 0:1:0 duration sets 0:0:55", () => { + SetDurationTo(1, 0); + ValidateDurationButton(8, /angle-down/, -5 * 60 * 1000); + }); + + it("inc on 0:0:59 duration sets 0:0:55", () => { + SetDurationTo(0, 59); + ValidateDurationButton(8, /angle-down/, -4 * 60 * 1000); + }); + + it("inc on 0:0:56 duration sets 0:0:55", () => { + SetDurationTo(0, 56); + ValidateDurationButton(8, /angle-down/, -1 * 60 * 1000); + }); + + it("inc on 0:0:55 duration sets 0:0:50", () => { + SetDurationTo(1, 0); + ValidateDurationButton(8, /angle-down/, -5 * 60 * 1000); + }); + + it("inc on 0:1:10 duration sets 0:1:5", () => { + SetDurationTo(1, 10); + ValidateDurationButton(8, /angle-down/, -5 * 60 * 1000); + }); + + it("inc on 0:1:6 duration sets 0:1:5", () => { + SetDurationTo(1, 6); + ValidateDurationButton(8, /angle-down/, -1 * 60 * 1000); + }); + + it("inc on 0:1:5 duration sets 0:1:0", () => { + SetDurationTo(1, 5); + ValidateDurationButton(8, /angle-down/, -5 * 60 * 1000); + }); + + it("inc on 0:1:4 duration sets 0:1:3", () => { + SetDurationTo(1, 4); + ValidateDurationButton(8, /angle-down/, -1 * 60 * 1000); + }); + + it("inc on 0:1:1 duration sets 0:1:0", () => { + SetDurationTo(1, 1); + ValidateDurationButton(8, /angle-down/, -1 * 60 * 1000); + }); +}); From 24097ce04166d932c6852b305db30333b800f263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 29 Aug 2018 23:52:43 +0100 Subject: [PATCH 09/20] fix(ui): always require a label All code using Duration component is passing label prop, no need to make it optional --- ui/src/Components/SilenceModal/DateTimeSelect/Duration.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/Duration.js b/ui/src/Components/SilenceModal/DateTimeSelect/Duration.js index c98c57e7c..40362413b 100644 --- a/ui/src/Components/SilenceModal/DateTimeSelect/Duration.js +++ b/ui/src/Components/SilenceModal/DateTimeSelect/Duration.js @@ -11,7 +11,7 @@ const Duration = observer( class Duration extends Component { static propTypes = { value: PropTypes.number.isRequired, - label: PropTypes.string, + label: PropTypes.string.isRequired, onInc: PropTypes.func.isRequired, onDec: PropTypes.func.isRequired }; @@ -40,9 +40,7 @@ const Duration = observer(

{value}

- {label ? ( - {label} - ) : null} + {label} From c0edb03bb4bd9380be4b0272ef7b056aaa9e5554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 20:03:22 +0100 Subject: [PATCH 10/20] Revert "fix(ui): remove dead code" This reverts commit e795cbf3e9f758f694c33013547dec1c510e4d38. This code updates filters, so it's needed, it's just the final check that's not. --- ui/src/Stores/AlertStore.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 6d0a4c6e0..9166d85db 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -238,6 +238,32 @@ class AlertStore { return; } + for (const filter of result.filters) { + const storedIndex = this.filters.values.findIndex( + f => f.raw === filter.text + ); + if (storedIndex >= 0) { + this.filters.values[storedIndex] = Object.assign( + this.filters.values[storedIndex], + { + applied: true, + isValid: filter.isValid, + hits: filter.hits, + name: filter.name, + matcher: filter.matcher, + value: filter.value + } + ); + } else { + console.warn( + `Got response with filter ${ + filter.text + } which isn't one of applied filters, ignoring` + ); + return; + } + } + let updates = {}; // update data dicts if they changed for (const key of [ From c0131758d2ca119a051bf9e427a327040b4c2f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 20:09:43 +0100 Subject: [PATCH 11/20] fix(ui): drop check for storedIndex since we check for filter mismatch before reaching there Add a check to validate that filters are marked as applied after fetch --- ui/src/Stores/AlertStore.js | 31 +++++++++++-------------------- ui/src/Stores/AlertStore.test.js | 1 + 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 9166d85db..21e03792d 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -242,26 +242,17 @@ class AlertStore { const storedIndex = this.filters.values.findIndex( f => f.raw === filter.text ); - if (storedIndex >= 0) { - this.filters.values[storedIndex] = Object.assign( - this.filters.values[storedIndex], - { - applied: true, - isValid: filter.isValid, - hits: filter.hits, - name: filter.name, - matcher: filter.matcher, - value: filter.value - } - ); - } else { - console.warn( - `Got response with filter ${ - filter.text - } which isn't one of applied filters, ignoring` - ); - return; - } + this.filters.values[storedIndex] = Object.assign( + this.filters.values[storedIndex], + { + applied: true, + isValid: filter.isValid, + hits: filter.hits, + name: filter.name, + matcher: filter.matcher, + value: filter.value + } + ); } let updates = {}; diff --git a/ui/src/Stores/AlertStore.test.js b/ui/src/Stores/AlertStore.test.js index fb55d512e..b8958bd1e 100644 --- a/ui/src/Stores/AlertStore.test.js +++ b/ui/src/Stores/AlertStore.test.js @@ -204,6 +204,7 @@ describe("AlertStore.fetch", () => { expect(store.status.value).toEqual(AlertStoreStatuses.Idle); expect(store.info.version).toBe("fakeVersion"); + expect(store.filters.values[0].applied).toBe(true); }); it("fetch() works with valid response", async () => { From 268a5bbcc1f927a838fbddeb7dde8cffef9abdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 20:15:19 +0100 Subject: [PATCH 12/20] fix(ui): drop useless check This check doesn't seem to get trigger, can't simulate 'enter' method in any way --- ui/src/Components/NavBar/FilterInput/index.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/src/Components/NavBar/FilterInput/index.js b/ui/src/Components/NavBar/FilterInput/index.js index c8c691688..fb075f9ec 100644 --- a/ui/src/Components/NavBar/FilterInput/index.js +++ b/ui/src/Components/NavBar/FilterInput/index.js @@ -49,11 +49,10 @@ const FilterInput = observer( } onChange = action((event, { newValue, method }) => { - if (method === "enter") { - event.preventDefault(); - } else { - this.inputStore.value = newValue; - } + // onChange here handles change for the user input in the filter bar + // we need to update inputStore.value every time user types in something + event.preventDefault(); + this.inputStore.value = newValue; }); onSubmit = action(event => { From 13130f187a53f263082d0a8903f91c57fa6987fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 21:09:10 +0100 Subject: [PATCH 13/20] fix(ui): override styles used by new react-autosuggest --- ui/src/Components/NavBar/FilterInput/index.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/src/Components/NavBar/FilterInput/index.css b/ui/src/Components/NavBar/FilterInput/index.css index 69c13cbbd..988b724a4 100644 --- a/ui/src/Components/NavBar/FilterInput/index.css +++ b/ui/src/Components/NavBar/FilterInput/index.css @@ -19,3 +19,9 @@ input.components-filterinput-wrapper { input.components-filterinput-wrapper:focus { width: auto; } + +/* highlighted part of the suggestion - phrase in the input that matches it */ +mark.highlight { + padding: 0; + background-color: inherit; +} From e1bb3d65480bd9758439fa4fabb6ef7a0f405a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 21:09:43 +0100 Subject: [PATCH 14/20] refactor(ui): store suggestion fetch result so we can use it in tests --- ui/src/Components/NavBar/FilterInput/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/Components/NavBar/FilterInput/index.js b/ui/src/Components/NavBar/FilterInput/index.js index fb075f9ec..430c8ab98 100644 --- a/ui/src/Components/NavBar/FilterInput/index.js +++ b/ui/src/Components/NavBar/FilterInput/index.js @@ -31,6 +31,7 @@ const FilterInput = observer( { ref: null, suggestions: [], + suggestionsFetch: null, value: "", storeInputReference(ref) { this.ref = ref; @@ -70,7 +71,9 @@ const FilterInput = observer( onSuggestionsFetchRequested = debounce( action(({ value }) => { if (value !== "") { - fetch(FormatUnseeBackendURI(`autocomplete.json?term=${value}`)) + this.inputStore.suggestionsFetch = fetch( + FormatUnseeBackendURI(`autocomplete.json?term=${value}`) + ) .then( result => result.json(), err => { From 2d64feed172eb78f27c0a1ac34a753ffe8c3eb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 21:23:40 +0100 Subject: [PATCH 15/20] fix(tests): add missing test coverage for FilterInput --- .../NavBar/FilterInput/index.test.js | 70 ++++++++++++++----- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/ui/src/Components/NavBar/FilterInput/index.test.js b/ui/src/Components/NavBar/FilterInput/index.test.js index f06b0dd73..996e56193 100644 --- a/ui/src/Components/NavBar/FilterInput/index.test.js +++ b/ui/src/Components/NavBar/FilterInput/index.test.js @@ -22,6 +22,12 @@ const MountedInput = () => { ); }; +const WaitForFetch = tree => { + return expect( + tree.instance().inputStore.suggestionsFetch + ).resolves.toBeUndefined(); +}; + describe("", () => { it("matches snapshot on default render", () => { const tree = render( @@ -68,38 +74,66 @@ describe("", () => { }); describe("", () => { - it("fetches suggestions on input change", done => { + it("fetches suggestions on input change", async () => { fetch.mockResponseOnce(JSON.stringify(["foo=bar", "foo=~bar"])); const tree = MountedInput(); const instance = tree.instance(); tree.find("input").simulate("change", { target: { value: "foo" } }); + await WaitForFetch(tree); - // need to wait on fetch to resolve, but can't find any better way here - setTimeout(() => { - expect(fetch.mock.calls).toHaveLength(1); - expect(fetch.mock.calls[0]).toContain("./autocomplete.json?term=foo"); - expect(instance.inputStore.suggestions).toHaveLength(2); - expect(instance.inputStore.suggestions).toContain("foo=bar"); - expect(instance.inputStore.suggestions).toContain("foo=~bar"); - done(); - }, 1000); + expect(fetch.mock.calls).toHaveLength(1); + expect(fetch.mock.calls[0]).toContain("./autocomplete.json?term=foo"); + expect(instance.inputStore.suggestions).toHaveLength(2); + expect(instance.inputStore.suggestions).toContain("foo=bar"); + expect(instance.inputStore.suggestions).toContain("foo=~bar"); }); - it("handles failed suggestion fetches", done => { + it("clicking on a suggestion adds it to filters", async () => { + fetch.mockResponse(JSON.stringify(["foo=bar", "foo=~bar"])); + + const tree = MountedInput(); + tree.find("input").simulate("change", { target: { value: "foo" } }); + // suggestions are rendered only when input is focused + tree.find("input").simulate("focus"); + await WaitForFetch(tree); + + // find() doesn't pick up suggestions even when tree.html() shows them + // forcing update seems to solve it + // https://github.com/airbnb/enzyme/issues/1233#issuecomment-343449560 + tree.update(); + // not sure why but suggestions are being found twice + const suggestion = tree.find(".dropdown-item").at(2); + expect(suggestion.text()).toBe("foo=~bar"); + suggestion.simulate("click"); + expect(alertStore.filters.values).toHaveLength(1); + expect(alertStore.filters.values[0]).toMatchObject({ raw: "foo=~bar" }); + }); + + it("handles failed suggestion fetches", async () => { fetch.mockRejectOnce("Fetch error"); const tree = MountedInput(); const instance = tree.instance(); tree.find("input").simulate("change", { target: { value: "bar" } }); + await WaitForFetch(tree); - // need to wait on fetch to resolve, but can't find any better way here - setTimeout(() => { - expect(fetch.mock.calls).toHaveLength(1); - expect(fetch.mock.calls[0]).toContain("./autocomplete.json?term=bar"); - expect(instance.inputStore.suggestions).toHaveLength(0); - done(); - }, 1000); + expect(fetch.mock.calls).toHaveLength(1); + expect(fetch.mock.calls[0]).toContain("./autocomplete.json?term=bar"); + expect(instance.inputStore.suggestions).toHaveLength(0); + }); + + it("handles invalid JSON in suggestion fetches", async () => { + fetch.mockResponseOnce("this is not JSON"); + + const tree = MountedInput(); + const instance = tree.instance(); + tree.find("input").simulate("change", { target: { value: "bar" } }); + await WaitForFetch(tree); + + expect(fetch.mock.calls).toHaveLength(1); + expect(fetch.mock.calls[0]).toContain("./autocomplete.json?term=bar"); + expect(instance.inputStore.suggestions).toHaveLength(0); }); it("clearing input clears suggestions", () => { From d703fb66aa3940f8c75dc74d82e1a79e47bf5e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 21:56:22 +0100 Subject: [PATCH 16/20] fix(ui): remove dead code This function isn't used by react-style, it doesn't match any valid function names, there's singleValue but we don't want break-word on that, it default to truncating too long text --- ui/src/Components/MultiSelect/index.js | 5 ----- .../__snapshots__/AlertManagerInput.test.js.snap | 1 - .../SilenceModal/__snapshots__/LabelNameInput.test.js.snap | 1 - .../SilenceModal/__snapshots__/LabelValueInput.test.js.snap | 1 - 4 files changed, 8 deletions(-) diff --git a/ui/src/Components/MultiSelect/index.js b/ui/src/Components/MultiSelect/index.js index 3ee134f4d..d8a528d52 100644 --- a/ui/src/Components/MultiSelect/index.js +++ b/ui/src/Components/MultiSelect/index.js @@ -41,11 +41,6 @@ const ReactSelectStyles = { borderRadius: "0.25rem", backgroundColor: "#fff" }, - valueLabel: (base, state) => ({ - ...base, - whiteSpace: "normal", - wordWrap: "break-word" - }), multiValue: (base, state) => ({ ...base, borderRadius: "4px", diff --git a/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap b/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap index 0a6937c7b..50da3b856 100644 --- a/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap +++ b/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap @@ -148,7 +148,6 @@ exports[` matches snapshot 1`] = ` "multiValueRemove": [Function], "option": [Function], "valueContainer": [Function], - "valueLabel": [Function], } } /> diff --git a/ui/src/Components/SilenceModal/__snapshots__/LabelNameInput.test.js.snap b/ui/src/Components/SilenceModal/__snapshots__/LabelNameInput.test.js.snap index 402cf5405..332b0ac3d 100644 --- a/ui/src/Components/SilenceModal/__snapshots__/LabelNameInput.test.js.snap +++ b/ui/src/Components/SilenceModal/__snapshots__/LabelNameInput.test.js.snap @@ -34,7 +34,6 @@ exports[` matches snapshot 1`] = ` "multiValueRemove": [Function], "option": [Function], "valueContainer": [Function], - "valueLabel": [Function], } } /> diff --git a/ui/src/Components/SilenceModal/__snapshots__/LabelValueInput.test.js.snap b/ui/src/Components/SilenceModal/__snapshots__/LabelValueInput.test.js.snap index bd95bf6d4..269f13542 100644 --- a/ui/src/Components/SilenceModal/__snapshots__/LabelValueInput.test.js.snap +++ b/ui/src/Components/SilenceModal/__snapshots__/LabelValueInput.test.js.snap @@ -30,7 +30,6 @@ exports[` matches snapshot 1`] = ` "multiValueRemove": [Function], "option": [Function], "valueContainer": [Function], - "valueLabel": [Function], } } /> From a7e900bd63d12fb84a0a4a5a20b3bb97bc1ab1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 21:57:54 +0100 Subject: [PATCH 17/20] feat(tests): more test coverage for MultiSelect --- .../__snapshots__/index.test.js.snap | 105 ++++++++++++++++++ ui/src/Components/MultiSelect/index.test.js | 49 ++++++++ 2 files changed, 154 insertions(+) create mode 100644 ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap create mode 100644 ui/src/Components/MultiSelect/index.test.js diff --git a/ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap b/ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..90a223121 --- /dev/null +++ b/ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot with a value 1`] = ` + +`; + +exports[` matches snapshot with defaults 1`] = ` + +`; + +exports[` matches snapshot with isMulti=true 1`] = ` + +`; + +exports[` matches snapshot with isMulti=true and a value 1`] = ` + +`; diff --git a/ui/src/Components/MultiSelect/index.test.js b/ui/src/Components/MultiSelect/index.test.js new file mode 100644 index 000000000..9858134f6 --- /dev/null +++ b/ui/src/Components/MultiSelect/index.test.js @@ -0,0 +1,49 @@ +import React from "react"; + +import { shallow } from "enzyme"; + +import { MultiSelect } from "."; + +const Option = value => ({ label: value, value: value }); + +class CustomMultiSelect extends MultiSelect { + constructor(props) { + super(props); + this.extraProps = props; + } + + renderProps = () => this.extraProps; +} + +describe("", () => { + it("matches snapshot with defaults", () => { + const tree = shallow(); + expect(tree).toMatchSnapshot(); + }); + + it("matches snapshot with isMulti=true", () => { + const tree = shallow(); + expect(tree).toMatchSnapshot(); + }); + + it("matches snapshot with a value", () => { + const tree = shallow( + + ); + expect(tree).toMatchSnapshot(); + }); + + it("matches snapshot with isMulti=true and a value", () => { + const tree = shallow( + + ); + expect(tree).toMatchSnapshot(); + }); +}); From 2858e54663788ae2d2e6557efb8354f7470021be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 22:11:01 +0100 Subject: [PATCH 18/20] fix(ui): fix favico.js import src/Components/FaviconBadge/index.js It was failed in tests with *, works well without it --- ui/src/Components/FaviconBadge/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/Components/FaviconBadge/index.js b/ui/src/Components/FaviconBadge/index.js index 086e5f362..7b26a2e91 100644 --- a/ui/src/Components/FaviconBadge/index.js +++ b/ui/src/Components/FaviconBadge/index.js @@ -3,7 +3,7 @@ import PropTypes from "prop-types"; import { observer } from "mobx-react"; -import * as Favico from "favico.js"; +import Favico from "favico.js"; const FaviconBadge = observer( class FaviconBadge extends Component { From 95de9e3cf920059e418402fe0a73230985ca20b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 22:11:21 +0100 Subject: [PATCH 19/20] feat(tests): add canvas mocks, needed for favico.js --- ui/package-lock.json | 6 ++++++ ui/package.json | 1 + ui/src/setupTests.js | 3 +++ 3 files changed, 10 insertions(+) diff --git a/ui/package-lock.json b/ui/package-lock.json index 47f8681f3..f07dec4d3 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -6506,6 +6506,12 @@ } } }, + "jest-canvas-mock": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-1.1.0.tgz", + "integrity": "sha512-D2VoKl+L6r9VpqTPygXKvIOQ1aou7gz3PvstlWDZqPT7EVYcSz0Nj+yjJ9G+Y9EqJd2X95f3dzcmmXb2dvQ1DQ==", + "dev": true + }, "jest-changed-files": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-20.0.3.tgz", diff --git a/ui/package.json b/ui/package.json index a61692a47..e0b6b4b1a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -55,6 +55,7 @@ "enzyme-adapter-react-16": "1.3.0", "enzyme-to-json": "3.3.4", "eslint-plugin-react": "7.11.1", + "jest-canvas-mock": "1.1.0", "jest-fetch-mock": "1.6.5", "jest-localstorage-mock": "2.2.0", "jest-mock-console": "0.4.0", diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index 776ea72d2..a18bc9688 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -12,6 +12,9 @@ mockConsole(["error", "warn", "info", "log", "trace"]); // localStorage is used for Settings store require("jest-localstorage-mock"); +// favico.js needs canvas +require("jest-canvas-mock"); + // fetch is used in multiple places to interact with Go backend // or upstream Alertmanager API global.fetch = require("jest-fetch-mock"); From 6952650eeb6d9ebaa369df68f432b3ff8b7d7485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 30 Aug 2018 22:15:42 +0100 Subject: [PATCH 20/20] feat(tests): add test coverage for FaviconBadge --- ui/src/Components/FaviconBadge/index.test.js | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 ui/src/Components/FaviconBadge/index.test.js diff --git a/ui/src/Components/FaviconBadge/index.test.js b/ui/src/Components/FaviconBadge/index.test.js new file mode 100644 index 000000000..79a946669 --- /dev/null +++ b/ui/src/Components/FaviconBadge/index.test.js @@ -0,0 +1,40 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import { AlertStore } from "Stores/AlertStore"; +import { FaviconBadge } from "."; + +let alertStore; + +beforeEach(() => { + alertStore = new AlertStore([]); +}); + +const MountedFaviconBadge = () => { + return mount(); +}; + +describe("", () => { + it("creates Favico instance on mount", () => { + const tree = MountedFaviconBadge(); + const instance = tree.instance(); + expect(instance.favicon).toBeInstanceOf(Object); + }); + + it("updateBadge is called when alertStore.info.totalAlerts changes", () => { + const tree = MountedFaviconBadge(); + const instance = tree.instance(); + const updateSpy = jest.spyOn(instance, "updateBadge"); + alertStore.info.totalAlerts = 99; + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it("updateBadge is called when alertStore.status.error changes", () => { + const tree = MountedFaviconBadge(); + const instance = tree.instance(); + const updateSpy = jest.spyOn(instance, "updateBadge"); + alertStore.status.error = "foo"; + expect(updateSpy).toHaveBeenCalledTimes(1); + }); +});