Merge pull request #905 from prymitive/storybook

feat(ci): use percy for visual testing
This commit is contained in:
Łukasz Mierzwa
2019-09-10 11:25:46 +01:00
committed by GitHub
14 changed files with 2993 additions and 27 deletions

View File

@@ -50,6 +50,15 @@ jobs:
after_success:
- travis_retry curl -s --connect-timeout 30 --fail https://codecov.io/bash | bash -s -- -F ui
- stage: Test
name: Test Percy UI snapshots
if: repo = prymitive/karma AND type != pull_request
<<: *DEFAULTS_JS
env:
- NODE_ENV=test
- secure: "DTDy4as3DV3QUw6LWNInEh2iFXrsMuMnb+WRNSwORu8OcgyLKVNrQ5SwQLV1lm0RFTCEN+sSxjOJwQp5PXEgLXcT/MP5xfg2p3HDEj7k7GqJLI4OykYpdh7YHGaX+cAGsrjPfuWAf7pdBlYplEDdGHGkK9BLkBIx6owkzvw0Z8Je3+kTxRAae8vIXpzmgiN+NGzP14UF92tky+/ZS2aLrhqVbTpWEP5j0mEhOpy6Ebh31nCTuW2FA+8oD0HVckC/JTLbIGPQgpzLrdEEE/imjZB9Gx4022lkcuZjf8u+hRytgqKp93l01MPxHrGCZ9V18r3QFZCAXGtFh8dg8xSAvk1cvFfJUDHkW1XhaUdsLubGI7zDw111N+5Do9L3MjJ2jd1x7ZPUSJwKUGPeRw/7CsNDPtC2Pcmkdb3D0SNeH4ia/L43A9+e3nuJ6vthAkEd7zBIcp9diVJ2nyry0d5YdFQStezksJgFADOO/OleMyMhLTdqBUE7sFf7QtD6R9nhZuIe//3UGVRuTJJmDU8wZEzK8CUyhPjbnpMTMbyq8bIYIk96E5Nrxp65RDOv9pPpvPfHf0WvALn/fmwa79AUafugYDoAXokv1RqrU0L977MRwEDDkGOuO1civoudfNQ2sAh6SR1eaSp9AygJgvpodLy1lEJZm3VxffjrSNdoejU="
script: make test-percy
- stage: Lint
name: Lint git commit
<<: *DEFAULTS_JS

View File

@@ -145,6 +145,10 @@ test-go:
test-js: .build/deps-build-node.ok
cd ui && CI=true npm test -- --coverage
.PHONY: test-percy
test-percy: .build/deps-build-node.ok
cd ui && CI=true npm run snapshot
.PHONY: test
test: lint test-go test-js

16
ui/.storybook/config.js Normal file
View File

@@ -0,0 +1,16 @@
import { configure, getStorybook, setAddon } from "@storybook/react";
import createPercyAddon from "@percy-io/percy-storybook";
const { percyAddon, serializeStories } = createPercyAddon();
setAddon(percyAddon);
const req = require.context("../src/Components", true, /\.stories\.js$/);
function loadStories() {
req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);
serializeStories(getStorybook);

2558
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,10 +57,20 @@
"scripts": {
"start": "NODE_ENV=dev REACT_APP_BACKEND_URI=http://localhost:8080 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test"
"test": "react-scripts test",
"storybook": "start-storybook",
"snapshot": "build-storybook --quiet -s public && percy-storybook --widths=700,1280"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!src/**/*.stories.{js,jsx}"
]
},
"devDependencies": {
"@commitlint/travis-cli": "8.1.0",
"@percy-io/percy-storybook": "2.1.0",
"@storybook/react": "5.1.11",
"diffable-html": "4.0.0",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.14.0",

View File

@@ -0,0 +1,170 @@
import React from "react";
import { storiesOf } from "@storybook/react";
import { Provider } from "mobx-react";
import { MockAlert, MockAlertGroup } from "__mocks__/Alerts.js";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { UpstreamError } from "./UpstreamError";
import { FatalError } from "./FatalError";
import { UpgradeNeeded } from "./UpgradeNeeded";
import { AlertGrid } from "./AlertGrid";
import "App.scss";
const MockGroup = (groupName, alertCount, active, suppressed, unprocessed) => {
let alerts = [];
for (let i = 1; i <= alertCount; i++) {
let state;
switch (true) {
case i > active && i <= active + suppressed:
state = "suppressed";
break;
case i > active + suppressed:
state = "unprocessed";
break;
default:
state = "active";
}
alerts.push(
MockAlert(
alertCount < 4
? [
{
name: "dashboard",
value: "http://localhost",
visible: true,
isLink: true
},
{
name: "help",
value: "this is a summary text",
visible: true,
isLink: false
},
{
name: "hidden",
value: "this is hidden by default",
visible: false,
isLink: false
}
]
: [],
{ instance: `instance${i}` },
state
)
);
}
const group = MockAlertGroup(
{ alertname: "Fake Alert", group: groupName },
alerts,
[],
{},
{}
);
return group;
};
storiesOf("Grid", module)
.addDecorator(storyFn => <div className="p-2">{storyFn()}</div>)
.add("UpstreamError", () => {
return <UpstreamError name="am1" message="Something failed" />;
})
.add("FatalError", () => {
return <FatalError message="Something failed" />;
})
.add("UpgradeNeeded", () => {
return <UpgradeNeeded newVersion="1.2.3" />;
})
.add("AlertGrid", () => {
const alertStore = new AlertStore([]);
const settingsStore = new Settings();
const silenceFormStore = new SilenceFormStore();
alertStore.data.colors = {
group: {
group1: {
brightness: 50,
background: { red: 178, green: 55, blue: 247, alpha: 255 }
},
group2: {
brightness: 50,
background: { red: 200, green: 100, blue: 66, alpha: 255 }
},
group3: {
brightness: 205,
background: { red: 246, green: 176, blue: 247, alpha: 255 }
},
group4: {
brightness: 111,
background: { red: 115, green: 101, blue: 152, alpha: 255 }
}
},
instance: {
instance1: {
brightness: 50,
background: { red: 111, green: 65, blue: 40, alpha: 255 }
},
instance2: {
brightness: 50,
background: { red: 66, green: 99, blue: 66, alpha: 255 }
},
instance3: {
brightness: 150,
background: { red: 66, green: 250, blue: 123, alpha: 255 }
}
}
};
let groups = [];
for (let i = 1; i <= 10; i++) {
const active = Math.max(1, Math.ceil(i / 3));
const suppressed = Math.max(0, i - 2 * Math.ceil(i / 3));
const unprocessed = Math.max(0, i - active - suppressed);
const id = `id${i}`;
const hash = `hash${i}`;
const group = MockGroup(`group${i}`, i, active, suppressed, unprocessed);
group.id = id;
group.hash = hash;
group.stateCount.active = active;
group.stateCount.suppressed = suppressed;
group.stateCount.unprocessed = unprocessed;
if (i < 3) {
group.shared.labels = {
cluster: `prod${i}`,
job: "textfile_exporter"
};
}
if (i < 5) {
group.shared.annotations = [
{
name: "summary",
value: "Only 5% free space left on /disk",
visible: true,
isLink: false
}
];
}
groups.push(group);
}
alertStore.data.upstreams = {
counters: { total: 0, healthy: 1, failed: 0 },
instances: [{ name: "am", uri: "http://am", error: "" }],
clusters: { am: ["am"] }
};
alertStore.data.groups = groups;
return (
<Provider alertStore={alertStore}>
<AlertGrid
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
/>
</Provider>
);
});

View File

@@ -24,6 +24,7 @@ const FilterInputLabel = observer(
filter: PropTypes.shape({
raw: PropTypes.string,
applied: PropTypes.bool,
isValid: PropTypes.bool,
hits: PropTypes.number,
name: PropTypes.string,
matcher: PropTypes.string,

View File

@@ -35,18 +35,26 @@ const MainModalContent = observer(
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
settingsStore: PropTypes.instanceOf(Settings).isRequired,
onHide: PropTypes.func.isRequired
onHide: PropTypes.func.isRequired,
openTab: PropTypes.oneOf(Object.values(TabNames))
};
static defaultProps = {
openTab: TabNames.Configuration
};
tab = observable(
{
current: TabNames.Configuration,
setTab(newTab) {
this.current = newTab;
}
},
{ setTab: action.bound }
);
constructor(props) {
super(props);
this.tab = observable(
{
current: props.openTab,
setTab(newTab) {
this.current = newTab;
}
},
{ setTab: action.bound }
);
}
render() {
const { alertStore, settingsStore, onHide } = this.props;
@@ -87,4 +95,4 @@ const MainModalContent = observer(
}
);
export { MainModalContent };
export { MainModalContent, TabNames };

View File

@@ -0,0 +1,43 @@
import React from "react";
import { storiesOf } from "@storybook/react";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { MainModalContent, TabNames } from "./MainModalContent";
import "App.scss";
storiesOf("MainModal", module)
.addDecorator(storyFn => (
<div className="overflow-auto modal d-block" role="dialog">
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">{storyFn()}</div>
</div>
</div>
))
.add("Configuration", () => {
const alertStore = new AlertStore([]);
const settingsStore = new Settings();
return (
<MainModalContent
alertStore={alertStore}
settingsStore={settingsStore}
onHide={() => {}}
isVisible={true}
/>
);
})
.add("Help", () => {
const alertStore = new AlertStore([]);
const settingsStore = new Settings();
return (
<MainModalContent
alertStore={alertStore}
settingsStore={settingsStore}
onHide={() => {}}
isVisible={true}
openTab={TabNames.Help}
/>
);
});

View File

@@ -0,0 +1,75 @@
import React from "react";
import { storiesOf } from "@storybook/react";
import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { NavBar } from ".";
import "App.scss";
const NewFilter = (raw, name, matcher, value, applied, isValid, hits) => {
const filter = NewUnappliedFilter(raw);
filter.name = name;
filter.matcher = matcher;
filter.value = value;
filter.applied = applied;
filter.isValid = isValid;
filter.hits = hits;
return filter;
};
storiesOf("NavBar", module)
.addDecorator(storyFn => (
<div className="overflow-auto modal d-block" role="dialog">
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">{storyFn()}</div>
</div>
</div>
))
.add("NavBar", () => {
const alertStore = new AlertStore([]);
const settingsStore = new Settings();
const silenceFormStore = new SilenceFormStore();
alertStore.info.totalAlerts = 197;
alertStore.data.colors = {
cluster: {
staging: {
brightness: 205,
background: { red: 246, green: 176, blue: 247, alpha: 255 }
}
},
region: {
AF: {
brightness: 111,
background: { red: 115, green: 101, blue: 152, alpha: 255 }
}
}
};
alertStore.filters.values = [
NewFilter("cluster=staging", "cluster", "=", "staging", true, true, 15),
NewFilter("region=AF", "region", "=", "AF", true, true, 180),
NewFilter(
"instance!=server1",
"instance",
"!=",
"server1",
false,
true,
0
),
NewFilter("server!!!=", "", "", "", true, false, 0),
NewFilter("foo", "", "", "", true, true, 2)
];
return (
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
/>
);
});

View File

@@ -61,21 +61,29 @@ const SilenceForm = observer(
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
settingsStore: PropTypes.instanceOf(Settings).isRequired
settingsStore: PropTypes.instanceOf(Settings).isRequired,
previewOpen: PropTypes.bool
};
static defaultProps = {
previewOpen: false
};
// store preview visibility state here, by default preview is collapsed
// and user needs to expand it
previewCollapse = observable(
{
hidden: true,
toggle() {
this.hidden = !this.hidden;
}
},
{ toggle: action.bound },
{ name: "Silence preview collpase toggle" }
);
constructor(props) {
super(props);
// store preview visibility state here, by default preview is collapsed
// and user needs to expand it
this.previewCollapse = observable(
{
hidden: !props.previewOpen,
toggle() {
this.hidden = !this.hidden;
}
},
{ toggle: action.bound },
{ name: "Silence preview collpase toggle" }
);
}
componentDidMount() {
const { silenceFormStore } = this.props;

View File

@@ -10,6 +10,8 @@ import { SilenceForm } from "./SilenceForm";
import { SilencePreview } from "./SilencePreview";
import { SilenceSubmitController } from "./SilenceSubmit/SilenceSubmitController";
import "./index.css";
const SilenceModalContent = observer(
class SilenceModalContent extends Component {
static propTypes = {

View File

@@ -13,8 +13,6 @@ import { Settings } from "Stores/Settings";
import { Modal } from "Components/Modal";
import { TooltipWrapper } from "Components/TooltipWrapper";
import "./index.css";
// https://github.com/facebook/react/issues/14603
const SilenceModalContent = React.lazy(() =>
import("./SilenceModalContent").then(module => ({

View File

@@ -0,0 +1,64 @@
import React from "react";
import { storiesOf } from "@storybook/react";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import {
SilenceFormStore,
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { SilenceModalContent } from "./SilenceModalContent";
import "App.scss";
const MockMatcher = (name, values, isRegex) => {
const matcher = NewEmptyMatcher();
matcher.name = name;
matcher.values = values.map(v => MatcherValueToObject(v));
matcher.isRegex = isRegex;
return matcher;
};
storiesOf("SilenceModalContent", module)
.addDecorator(storyFn => (
<div className="modal d-block overflow-auto" role="dialog">
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">{storyFn()}</div>
</div>
</div>
))
.add("SilenceModalContent", () => {
const alertStore = new AlertStore([]);
const settingsStore = new Settings();
const silenceFormStore = new SilenceFormStore();
silenceFormStore.toggle.visible = true;
silenceFormStore.data.matchers = [
MockMatcher("cluster", ["prod"], false),
MockMatcher("instance", ["server1", "server3"], true),
MockMatcher(
"tooLong",
[
"12345",
"Some Alerts With A Ridiculously Long Name To Test Label Truncation In All The Places We Render Those Alerts"
],
true
)
];
silenceFormStore.data.addEmptyMatcher();
silenceFormStore.data.author = "me@example.com";
silenceFormStore.data.comment = "fake silence";
silenceFormStore.data.resetStartEnd();
return (
<SilenceModalContent
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
onHide={() => {}}
previewOpen={true}
/>
);
});