mirror of
https://github.com/prymitive/karma
synced 2026-05-09 03:36:44 +00:00
Merge pull request #905 from prymitive/storybook
feat(ci): use percy for visual testing
This commit is contained in:
@@ -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
|
||||
|
||||
4
Makefile
4
Makefile
@@ -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
16
ui/.storybook/config.js
Normal 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
2558
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
170
ui/src/Components/Grid/index.stories.js
Normal file
170
ui/src/Components/Grid/index.stories.js
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
43
ui/src/Components/MainModal/index.stories.js
Normal file
43
ui/src/Components/MainModal/index.stories.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
75
ui/src/Components/NavBar/index.stories.js
Normal file
75
ui/src/Components/NavBar/index.stories.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
64
ui/src/Components/SilenceModal/index.stories.js
Normal file
64
ui/src/Components/SilenceModal/index.stories.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user