feat(ci): add e2e visual tests
31
.github/workflows/test.yml
vendored
@@ -116,6 +116,37 @@ jobs:
|
||||
directory: ./ui/coverage
|
||||
token: c77c73a6-81e1-4fa1-8372-9170d7953d41
|
||||
|
||||
test-e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node JS
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 25.7.0
|
||||
cache: "npm"
|
||||
cache-dependency-path: "ui/package-lock.json"
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: ui
|
||||
|
||||
- name: Visual tests
|
||||
run: make -C ui e2e
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Upload visual diff artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: visual-regression-diffs
|
||||
path: ui/src/e2e/results/
|
||||
retention-days: 30
|
||||
|
||||
lint-js:
|
||||
name: Lint JS code
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
1
.gitignore
vendored
@@ -5,5 +5,6 @@
|
||||
/ui/coverage
|
||||
/ui/node_modules
|
||||
/ui/.eslintcache
|
||||
/ui/src/e2e/results
|
||||
/TODO.md
|
||||
/.vscode
|
||||
|
||||
@@ -18,4 +18,5 @@ ignores:
|
||||
- fetch-mock
|
||||
- identity-obj-proxy
|
||||
- jest
|
||||
- "@playwright/test"
|
||||
- terser
|
||||
|
||||
@@ -2,6 +2,7 @@ const path = require("path");
|
||||
|
||||
const config = {
|
||||
root: true,
|
||||
ignorePatterns: ["playwright.config.ts", "src/e2e/"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
|
||||
@@ -34,9 +34,14 @@ build: dist/index.html
|
||||
test-js: $(NODE_PATH)/vite $(NODE_PATH)/jest
|
||||
CI=true npm test -- --coverage
|
||||
|
||||
.PHONY: e2e
|
||||
e2e: $(NODE_PATH)/vite $(NODE_PATH)/playwright
|
||||
npx playwright test
|
||||
|
||||
.PHONY: update-snapshots
|
||||
update-snapshots: $(NODE_PATH)/vite $(NODE_PATH)/jest
|
||||
CI=true npm test -- -u
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
.PHONY: lint-js
|
||||
lint-js: $(NODE_PATH)/eslint
|
||||
|
||||
64
ui/package-lock.json
generated
@@ -53,6 +53,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fetch-mock/jest": "0.2.20",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
@@ -3971,6 +3972,22 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -10722,6 +10739,53 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fetch-mock/jest": "0.2.20",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
@@ -98,6 +99,7 @@
|
||||
"jest": {
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,ts,tsx}",
|
||||
"!src/e2e/**",
|
||||
"!src/**/*.stories.{js,ts,tsx}",
|
||||
"!src/__fixtures__/Stories.{js,ts,tsx}",
|
||||
"!src/react-app-env.d.ts",
|
||||
@@ -112,6 +114,9 @@
|
||||
"moduleNameMapper": {
|
||||
"\\.(css|less|scss)$": "identity-obj-proxy"
|
||||
},
|
||||
"testPathIgnorePatterns": [
|
||||
"<rootDir>/src/e2e/"
|
||||
],
|
||||
"modulePaths": [
|
||||
"<rootDir>/src"
|
||||
],
|
||||
|
||||
38
ui/playwright.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./src/e2e",
|
||||
outputDir: "./src/e2e/results",
|
||||
snapshotPathTemplate: "{testDir}/snapshots/{arg}{ext}",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3123",
|
||||
screenshot: "off",
|
||||
video: "off",
|
||||
trace: "off",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npx vite --port 3123 --strictPort",
|
||||
port: 3123,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
BIN
ui/src/e2e/snapshots/EmptyGrid.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
ui/src/e2e/snapshots/FatalError.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
ui/src/e2e/snapshots/Grid.png
Normal file
|
After Width: | Height: | Size: 661 KiB |
BIN
ui/src/e2e/snapshots/InternalError.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
ui/src/e2e/snapshots/MainModal.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
ui/src/e2e/snapshots/ManagedSilence.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
ui/src/e2e/snapshots/NavBar.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
ui/src/e2e/snapshots/NoUpstream.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
ui/src/e2e/snapshots/OverviewModal.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
ui/src/e2e/snapshots/ReloadNeeded.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
ui/src/e2e/snapshots/SilenceModalBrowser.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
ui/src/e2e/snapshots/SilenceModalEditor.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
ui/src/e2e/snapshots/SilenceModalEditorReadOnly.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
ui/src/e2e/snapshots/SilenceModalEmptyBrowser.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
ui/src/e2e/snapshots/Toast.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
ui/src/e2e/snapshots/UpgradeNeeded.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
15
ui/src/e2e/stories.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<title>Visual Regression Tests</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/e2e/stories.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
604
ui/src/e2e/stories.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { addHours } from "date-fns/addHours";
|
||||
import { addDays } from "date-fns/addDays";
|
||||
|
||||
import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
|
||||
import { faExclamation } from "@fortawesome/free-solid-svg-icons/faExclamation";
|
||||
|
||||
import { ThemeContext } from "Components/Theme";
|
||||
import type { ThemeCtx } from "Components/Theme";
|
||||
import {
|
||||
ReactSelectColors,
|
||||
ReactSelectStyles,
|
||||
} from "Components/Theme/ReactSelect";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { Settings } from "Stores/Settings";
|
||||
import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
|
||||
import { MockGrid } from "__fixtures__/Stories";
|
||||
import { MockSilence } from "__fixtures__/Alerts";
|
||||
|
||||
import { FatalError } from "Components/Grid/FatalError";
|
||||
import { UpgradeNeeded } from "Components/Grid/UpgradeNeeded";
|
||||
import { ReloadNeeded } from "Components/Grid/ReloadNeeded";
|
||||
import { EmptyGrid } from "Components/Grid/EmptyGrid";
|
||||
import { NoUpstream } from "Components/Grid/NoUpstream";
|
||||
import Grid from "Components/Grid";
|
||||
import NavBar from "Components/NavBar";
|
||||
import { InternalError } from "ErrorBoundary";
|
||||
import { MainModalContent } from "Components/MainModal/MainModalContent";
|
||||
import { OverviewModalContent } from "Components/OverviewModal/OverviewModalContent";
|
||||
import { ManagedSilence } from "Components/ManagedSilence";
|
||||
import { SilenceModalContent } from "Components/SilenceModal/SilenceModalContent";
|
||||
import { Toast } from "Components/Toast";
|
||||
import {
|
||||
ToastMessage,
|
||||
UpgradeToastMessage,
|
||||
} from "Components/Toast/ToastMessages";
|
||||
|
||||
import "Styles/Percy.scss";
|
||||
|
||||
const jsonResponse = (data: unknown): Response =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const mockSilenceEntry = (index: number) => {
|
||||
const s = MockSilence();
|
||||
s.startsAt = "2018-08-14T16:00:00Z";
|
||||
s.endsAt = `2018-08-14T18:${index < 10 ? "0" + index : index}:00Z`;
|
||||
s.matchers.push({
|
||||
name: "thisIsAveryLongNameToTestMatcherWrapping",
|
||||
value: "valueIsAlsoAbitLong",
|
||||
isRegex: false,
|
||||
isEqual: true,
|
||||
});
|
||||
s.matchers.push({
|
||||
name: "alertname",
|
||||
value: "(foo1|foo2|foo3|foo4)",
|
||||
isRegex: true,
|
||||
isEqual: true,
|
||||
});
|
||||
s.id = `silence${index}`;
|
||||
return { cluster: "am", alertCount: (index - 1) * 9, silence: s };
|
||||
};
|
||||
|
||||
const countersData = {
|
||||
total: 90,
|
||||
counters: [
|
||||
{
|
||||
name: "@receiver",
|
||||
hits: 2,
|
||||
values: [
|
||||
{
|
||||
value: "by-cluster-service",
|
||||
raw: "@receiver=by-cluster-service",
|
||||
hits: 2,
|
||||
percent: 100,
|
||||
offset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "alertname",
|
||||
hits: 90,
|
||||
values: [
|
||||
{
|
||||
value: "Fake Alert",
|
||||
raw: "alertname=Fake Alert",
|
||||
hits: 45,
|
||||
percent: 50,
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
value: "Second Fake Alert",
|
||||
raw: "alertname=Second Fake Alert",
|
||||
hits: 45,
|
||||
percent: 50,
|
||||
offset: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "group",
|
||||
hits: 100,
|
||||
values: [
|
||||
{
|
||||
value: "group1",
|
||||
raw: "group=group1",
|
||||
hits: 25,
|
||||
percent: 25,
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
value: "group2",
|
||||
raw: "group=group2",
|
||||
hits: 70,
|
||||
percent: 70,
|
||||
offset: 25,
|
||||
},
|
||||
{
|
||||
value: "group3",
|
||||
raw: "group=group3",
|
||||
hits: 4,
|
||||
percent: 4,
|
||||
offset: 95,
|
||||
},
|
||||
{
|
||||
value: "group4",
|
||||
raw: "group=group4",
|
||||
hits: 1,
|
||||
percent: 1,
|
||||
offset: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
const story = window.location.hash.replace("#", "").split("?")[0];
|
||||
|
||||
if (url.includes("labelNames.json"))
|
||||
return jsonResponse(["cluster", "job", "instance"]);
|
||||
if (url.includes("labelValues.json"))
|
||||
return jsonResponse(["dev", "staging", "prod"]);
|
||||
if (url.includes("alertList.json")) return jsonResponse({ alerts: [] });
|
||||
if (url.includes("autocomplete.json")) return jsonResponse([]);
|
||||
if (url.includes("alerts.json"))
|
||||
return jsonResponse({ alerts: [], totalAlerts: 0 });
|
||||
|
||||
if (url.includes("counters.json")) {
|
||||
if (story === "OverviewModal") return jsonResponse(countersData);
|
||||
return jsonResponse({ total: 0, counters: [] });
|
||||
}
|
||||
|
||||
if (url.includes("silences.json")) {
|
||||
if (story === "SilenceModalBrowser") {
|
||||
const silences = Array.from({ length: 18 }, (_, i) =>
|
||||
mockSilenceEntry(i + 1),
|
||||
);
|
||||
return jsonResponse(silences);
|
||||
}
|
||||
return jsonResponse([]);
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
type StoryMap = Record<string, () => React.ReactNode>;
|
||||
|
||||
const Modal: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="modal d-block" style={{ position: "relative" }}>
|
||||
<div className="modal-dialog modal-lg" role="document">
|
||||
<div className="modal-content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const makeAlertStore = (): AlertStore => {
|
||||
const alertStore = new AlertStore([]);
|
||||
alertStore.data.setUpstreams({
|
||||
counters: { total: 1, healthy: 1, failed: 0 },
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.24.0",
|
||||
headers: {},
|
||||
corsCredentials: "include",
|
||||
},
|
||||
],
|
||||
clusters: { am: ["am1"] },
|
||||
});
|
||||
return alertStore;
|
||||
};
|
||||
|
||||
const makeReadOnlyAlertStore = (): AlertStore => {
|
||||
const alertStore = new AlertStore([]);
|
||||
alertStore.data.setUpstreams({
|
||||
counters: { healthy: 1, failed: 0, total: 1 },
|
||||
clusters: { ro: ["readonly"] },
|
||||
instances: [
|
||||
{
|
||||
name: "readonly",
|
||||
uri: "http://localhost:8080",
|
||||
publicURI: "http://example.com",
|
||||
readonly: true,
|
||||
headers: {},
|
||||
corsCredentials: "include",
|
||||
error: "",
|
||||
version: "0.24.0",
|
||||
cluster: "ro",
|
||||
clusterMembers: ["readonly"],
|
||||
},
|
||||
],
|
||||
});
|
||||
return alertStore;
|
||||
};
|
||||
|
||||
const gridStory = (): React.ReactNode => {
|
||||
const alertStore = new AlertStore([]);
|
||||
const settingsStore = new Settings(null);
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
MockGrid(alertStore);
|
||||
return (
|
||||
<Grid
|
||||
alertStore={alertStore}
|
||||
settingsStore={settingsStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const navBarStory = (): React.ReactNode => {
|
||||
const alertStore = new AlertStore([]);
|
||||
const settingsStore = new Settings(null);
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
alertStore.data.setUpstreams({
|
||||
counters: { total: 1, healthy: 1, failed: 0 },
|
||||
instances: [
|
||||
{
|
||||
name: "dev",
|
||||
cluster: "dev",
|
||||
clusterMembers: ["dev"],
|
||||
uri: "https://am.example.com",
|
||||
publicURI: "https://am.example.com",
|
||||
error: "",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
corsCredentials: "include",
|
||||
version: "",
|
||||
},
|
||||
],
|
||||
clusters: { dev: ["dev"] },
|
||||
});
|
||||
alertStore.info.setTotalAlerts(197);
|
||||
settingsStore.filterBarConfig.setAutohide(false);
|
||||
return (
|
||||
<NavBar
|
||||
alertStore={alertStore}
|
||||
settingsStore={settingsStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
fixedTop={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const fatalErrorStory = (): React.ReactNode => (
|
||||
<FatalError message="Something failed with a veryLongStringToTestTextWrappingveryLongStringToTestTextWrappingveryLongStringToTestTextWrapping" />
|
||||
);
|
||||
|
||||
const upgradeNeededStory = (): React.ReactNode => (
|
||||
<UpgradeNeeded newVersion="1.2.3" reloadAfter={100000000} />
|
||||
);
|
||||
|
||||
const reloadNeededStory = (): React.ReactNode => (
|
||||
<ReloadNeeded reloadAfter={100000000} />
|
||||
);
|
||||
|
||||
const emptyGridStory = (): React.ReactNode => (
|
||||
<div className="text-center">
|
||||
<EmptyGrid />
|
||||
</div>
|
||||
);
|
||||
|
||||
const noUpstreamStory = (): React.ReactNode => <NoUpstream />;
|
||||
|
||||
const internalErrorStory = (): React.ReactNode => (
|
||||
<InternalError
|
||||
message="React error boundary message with a veryLongStringToTestTextWrappingveryLongStringToTestTextWrappingveryLongStringToTestTextWrapping"
|
||||
secondsLeft={45}
|
||||
progressLeft={66}
|
||||
/>
|
||||
);
|
||||
|
||||
const mainModalStory = (): React.ReactNode => {
|
||||
const alertStore = makeAlertStore();
|
||||
const settingsStore = new Settings(null);
|
||||
alertStore.info.setVersion("1.2.3");
|
||||
return (
|
||||
<Modal>
|
||||
<MainModalContent
|
||||
alertStore={alertStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
expandAllOptions={true}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const overviewModalStory = (): React.ReactNode => {
|
||||
const alertStore = new AlertStore(["full"]);
|
||||
MockGrid(alertStore);
|
||||
return (
|
||||
<Modal>
|
||||
<OverviewModalContent alertStore={alertStore} onHide={() => {}} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const managedSilenceStory = (): React.ReactNode => {
|
||||
const alertStore = makeAlertStore();
|
||||
const alertStoreReadOnly = makeReadOnlyAlertStore();
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
|
||||
const silence = MockSilence();
|
||||
silence.startsAt = "2018-08-14T16:00:00Z";
|
||||
silence.endsAt = "2018-08-14T18:00:00Z";
|
||||
|
||||
const expiredSilence = MockSilence();
|
||||
expiredSilence.startsAt = "2018-08-14T10:00:00Z";
|
||||
expiredSilence.endsAt = "2018-08-14T11:00:00Z";
|
||||
|
||||
return (
|
||||
<div className="modal-dialog modal-lg" role="document">
|
||||
<div className="modal-content p-2">
|
||||
<ManagedSilence
|
||||
cluster="am"
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={silence}
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster="am"
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={silence}
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster="ro"
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={silence}
|
||||
alertStore={alertStoreReadOnly}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster="am"
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={expiredSilence}
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster="am"
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={expiredSilence}
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster="ro"
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={expiredSilence}
|
||||
alertStore={alertStoreReadOnly}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const silenceModalEditorReadOnlyStory = (): React.ReactNode => {
|
||||
const alertStore = makeReadOnlyAlertStore();
|
||||
const settingsStore = new Settings(null);
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
silenceFormStore.tab.setTab("editor");
|
||||
return (
|
||||
<Modal>
|
||||
<SilenceModalContent
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const silenceModalEditorStory = (): React.ReactNode => {
|
||||
const alertStore = makeAlertStore();
|
||||
const settingsStore = new Settings(null);
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
|
||||
silenceFormStore.data.setAuthor("me@example.com");
|
||||
silenceFormStore.data.setComment("This is a test silence");
|
||||
silenceFormStore.data.setStart(new Date("2018-08-14T17:36:40"));
|
||||
silenceFormStore.data.setEnd(
|
||||
addDays(addHours(new Date("2018-08-14T17:36:40"), 2), 10),
|
||||
);
|
||||
silenceFormStore.tab.setTab("editor");
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<SilenceModalContent
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
previewOpen={true}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const silenceModalBrowserStory = (): React.ReactNode => {
|
||||
const alertStore = makeAlertStore();
|
||||
const settingsStore = new Settings(null);
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
silenceFormStore.tab.setTab("browser");
|
||||
return (
|
||||
<Modal>
|
||||
<SilenceModalContent
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const silenceModalEmptyBrowserStory = (): React.ReactNode => {
|
||||
const alertStore = makeAlertStore();
|
||||
const settingsStore = new Settings(null);
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
silenceFormStore.tab.setTab("browser");
|
||||
return (
|
||||
<Modal>
|
||||
<SilenceModalContent
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const toastStory = (): React.ReactNode => {
|
||||
const alertStore = new AlertStore([]);
|
||||
alertStore.info.setVersion("999.99.0");
|
||||
return (
|
||||
<div className="d-flex flex-column">
|
||||
<Toast
|
||||
icon={faExclamation}
|
||||
iconClass="text-danger"
|
||||
message={
|
||||
<ToastMessage
|
||||
title="Alertmanager am1 raised an error"
|
||||
message="connection refused"
|
||||
/>
|
||||
}
|
||||
hasClose={true}
|
||||
/>
|
||||
<Toast
|
||||
icon={faArrowUp}
|
||||
iconClass="text-success"
|
||||
message={<UpgradeToastMessage alertStore={alertStore} />}
|
||||
hasClose={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const stories: StoryMap = {
|
||||
Grid: gridStory,
|
||||
NavBar: navBarStory,
|
||||
FatalError: fatalErrorStory,
|
||||
UpgradeNeeded: upgradeNeededStory,
|
||||
ReloadNeeded: reloadNeededStory,
|
||||
EmptyGrid: emptyGridStory,
|
||||
NoUpstream: noUpstreamStory,
|
||||
InternalError: internalErrorStory,
|
||||
MainModal: mainModalStory,
|
||||
OverviewModal: overviewModalStory,
|
||||
ManagedSilence: managedSilenceStory,
|
||||
SilenceModalEditorReadOnly: silenceModalEditorReadOnlyStory,
|
||||
SilenceModalEditor: silenceModalEditorStory,
|
||||
SilenceModalBrowser: silenceModalBrowserStory,
|
||||
SilenceModalEmptyBrowser: silenceModalEmptyBrowserStory,
|
||||
Toast: toastStory,
|
||||
};
|
||||
|
||||
const lightTheme: ThemeCtx = {
|
||||
isDark: false,
|
||||
reactSelectStyles: ReactSelectStyles(ReactSelectColors.Light),
|
||||
animations: { duration: 0 },
|
||||
};
|
||||
|
||||
const darkTheme: ThemeCtx = {
|
||||
isDark: true,
|
||||
reactSelectStyles: ReactSelectStyles(ReactSelectColors.Dark),
|
||||
animations: { duration: 0 },
|
||||
};
|
||||
|
||||
const StoryRenderer = ({ storyFn }: { storyFn: () => React.ReactNode }) => (
|
||||
<div>
|
||||
<div className="theme-light">
|
||||
<ThemeContext.Provider value={lightTheme}>
|
||||
{storyFn()}
|
||||
</ThemeContext.Provider>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: "16px",
|
||||
width: "100%",
|
||||
backgroundColor: "#eee",
|
||||
marginTop: "4px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
/>
|
||||
<div className="theme-dark">
|
||||
<ThemeContext.Provider value={darkTheme}>
|
||||
{storyFn()}
|
||||
</ThemeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const VisualTestApp = () => {
|
||||
const storyName = window.location.hash.replace("#", "") || "";
|
||||
const storyFn = stories[storyName];
|
||||
|
||||
if (!storyFn) {
|
||||
return (
|
||||
<div style={{ padding: "2rem" }}>
|
||||
<h1>Visual Regression Test Stories</h1>
|
||||
<ul>
|
||||
{Object.keys(stories).map((name) => (
|
||||
<li key={name}>
|
||||
<a href={`#${name}`}>{name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StoryRenderer storyFn={storyFn} />;
|
||||
};
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (rootEl) {
|
||||
const root = createRoot(rootEl);
|
||||
root.render(<VisualTestApp />);
|
||||
}
|
||||
31
ui/src/e2e/visual.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const stories: { name: string; waitFor: string }[] = [
|
||||
{ name: "Grid", waitFor: "#root > div" },
|
||||
{ name: "NavBar", waitFor: "#root > div" },
|
||||
{ name: "FatalError", waitFor: "#root > div" },
|
||||
{ name: "UpgradeNeeded", waitFor: "#root > div" },
|
||||
{ name: "ReloadNeeded", waitFor: "#root > div" },
|
||||
{ name: "EmptyGrid", waitFor: "#root > div" },
|
||||
{ name: "NoUpstream", waitFor: "#root > div" },
|
||||
{ name: "InternalError", waitFor: "#root > div" },
|
||||
{ name: "MainModal", waitFor: ".modal-content" },
|
||||
{ name: "OverviewModal", waitFor: ".modal-content" },
|
||||
{ name: "ManagedSilence", waitFor: ".components-managed-silence" },
|
||||
{ name: "SilenceModalEditorReadOnly", waitFor: ".modal-content" },
|
||||
{ name: "SilenceModalEditor", waitFor: ".modal-content" },
|
||||
{ name: "SilenceModalBrowser", waitFor: ".components-managed-silence" },
|
||||
{ name: "SilenceModalEmptyBrowser", waitFor: ".modal-content" },
|
||||
{ name: "Toast", waitFor: "#root > div" },
|
||||
];
|
||||
|
||||
for (const story of stories) {
|
||||
test(story.name, async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2018-08-14T17:00:00Z") });
|
||||
await page.goto(`/src/e2e/stories.html#${story.name}`);
|
||||
await page.locator(story.waitFor).first().waitFor({ timeout: 10000 });
|
||||
await expect(page).toHaveScreenshot(`${story.name}.png`, {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,20 @@ export default defineConfig({
|
||||
open: true,
|
||||
port: 3000,
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
silenceDeprecations: [
|
||||
"abs-percent",
|
||||
"import",
|
||||
"if-function",
|
||||
"duplicate-var-flags",
|
||||
"global-builtin",
|
||||
"color-functions",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
|
||||