diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 339ce8309..958db38c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index a5d31d5cb..96556cd93 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ /ui/coverage /ui/node_modules /ui/.eslintcache +/ui/src/e2e/results /TODO.md /.vscode diff --git a/ui/.depcheckrc.yaml b/ui/.depcheckrc.yaml index 2a3b5aba4..6d668cb1d 100644 --- a/ui/.depcheckrc.yaml +++ b/ui/.depcheckrc.yaml @@ -18,4 +18,5 @@ ignores: - fetch-mock - identity-obj-proxy - jest + - "@playwright/test" - terser diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs index d422678a5..dc265f358 100644 --- a/ui/.eslintrc.cjs +++ b/ui/.eslintrc.cjs @@ -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, diff --git a/ui/Makefile b/ui/Makefile index cdc2894be..e6b960b11 100644 --- a/ui/Makefile +++ b/ui/Makefile @@ -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 diff --git a/ui/package-lock.json b/ui/package-lock.json index b2fcb2860..1cd0ab9bb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index d6f91695d..aabf11823 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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": [ + "/src/e2e/" + ], "modulePaths": [ "/src" ], diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 000000000..7dfe0d334 --- /dev/null +++ b/ui/playwright.config.ts @@ -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, + }, + }, +}); diff --git a/ui/src/e2e/snapshots/EmptyGrid.png b/ui/src/e2e/snapshots/EmptyGrid.png new file mode 100644 index 000000000..84715c76b Binary files /dev/null and b/ui/src/e2e/snapshots/EmptyGrid.png differ diff --git a/ui/src/e2e/snapshots/FatalError.png b/ui/src/e2e/snapshots/FatalError.png new file mode 100644 index 000000000..110ca02ff Binary files /dev/null and b/ui/src/e2e/snapshots/FatalError.png differ diff --git a/ui/src/e2e/snapshots/Grid.png b/ui/src/e2e/snapshots/Grid.png new file mode 100644 index 000000000..915b1f4b6 Binary files /dev/null and b/ui/src/e2e/snapshots/Grid.png differ diff --git a/ui/src/e2e/snapshots/InternalError.png b/ui/src/e2e/snapshots/InternalError.png new file mode 100644 index 000000000..c16211c7b Binary files /dev/null and b/ui/src/e2e/snapshots/InternalError.png differ diff --git a/ui/src/e2e/snapshots/MainModal.png b/ui/src/e2e/snapshots/MainModal.png new file mode 100644 index 000000000..6aadf14f6 Binary files /dev/null and b/ui/src/e2e/snapshots/MainModal.png differ diff --git a/ui/src/e2e/snapshots/ManagedSilence.png b/ui/src/e2e/snapshots/ManagedSilence.png new file mode 100644 index 000000000..da891c7f2 Binary files /dev/null and b/ui/src/e2e/snapshots/ManagedSilence.png differ diff --git a/ui/src/e2e/snapshots/NavBar.png b/ui/src/e2e/snapshots/NavBar.png new file mode 100644 index 000000000..d23d5d077 Binary files /dev/null and b/ui/src/e2e/snapshots/NavBar.png differ diff --git a/ui/src/e2e/snapshots/NoUpstream.png b/ui/src/e2e/snapshots/NoUpstream.png new file mode 100644 index 000000000..3c22873b7 Binary files /dev/null and b/ui/src/e2e/snapshots/NoUpstream.png differ diff --git a/ui/src/e2e/snapshots/OverviewModal.png b/ui/src/e2e/snapshots/OverviewModal.png new file mode 100644 index 000000000..711610916 Binary files /dev/null and b/ui/src/e2e/snapshots/OverviewModal.png differ diff --git a/ui/src/e2e/snapshots/ReloadNeeded.png b/ui/src/e2e/snapshots/ReloadNeeded.png new file mode 100644 index 000000000..650107943 Binary files /dev/null and b/ui/src/e2e/snapshots/ReloadNeeded.png differ diff --git a/ui/src/e2e/snapshots/SilenceModalBrowser.png b/ui/src/e2e/snapshots/SilenceModalBrowser.png new file mode 100644 index 000000000..4e9df0117 Binary files /dev/null and b/ui/src/e2e/snapshots/SilenceModalBrowser.png differ diff --git a/ui/src/e2e/snapshots/SilenceModalEditor.png b/ui/src/e2e/snapshots/SilenceModalEditor.png new file mode 100644 index 000000000..3437194f6 Binary files /dev/null and b/ui/src/e2e/snapshots/SilenceModalEditor.png differ diff --git a/ui/src/e2e/snapshots/SilenceModalEditorReadOnly.png b/ui/src/e2e/snapshots/SilenceModalEditorReadOnly.png new file mode 100644 index 000000000..b5c8dc92c Binary files /dev/null and b/ui/src/e2e/snapshots/SilenceModalEditorReadOnly.png differ diff --git a/ui/src/e2e/snapshots/SilenceModalEmptyBrowser.png b/ui/src/e2e/snapshots/SilenceModalEmptyBrowser.png new file mode 100644 index 000000000..9bb2e9bfd Binary files /dev/null and b/ui/src/e2e/snapshots/SilenceModalEmptyBrowser.png differ diff --git a/ui/src/e2e/snapshots/Toast.png b/ui/src/e2e/snapshots/Toast.png new file mode 100644 index 000000000..f00b899eb Binary files /dev/null and b/ui/src/e2e/snapshots/Toast.png differ diff --git a/ui/src/e2e/snapshots/UpgradeNeeded.png b/ui/src/e2e/snapshots/UpgradeNeeded.png new file mode 100644 index 000000000..16dea0d7d Binary files /dev/null and b/ui/src/e2e/snapshots/UpgradeNeeded.png differ diff --git a/ui/src/e2e/stories.html b/ui/src/e2e/stories.html new file mode 100644 index 000000000..5ab05b5dc --- /dev/null +++ b/ui/src/e2e/stories.html @@ -0,0 +1,15 @@ + + + + + + Visual Regression Tests + + +
+ + + diff --git a/ui/src/e2e/stories.tsx b/ui/src/e2e/stories.tsx new file mode 100644 index 000000000..12b637837 --- /dev/null +++ b/ui/src/e2e/stories.tsx @@ -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 => { + 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 React.ReactNode>; + +const Modal: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+
+
{children}
+
+
+); + +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 ( + + ); +}; + +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 ( + + ); +}; + +const fatalErrorStory = (): React.ReactNode => ( + +); + +const upgradeNeededStory = (): React.ReactNode => ( + +); + +const reloadNeededStory = (): React.ReactNode => ( + +); + +const emptyGridStory = (): React.ReactNode => ( +
+ +
+); + +const noUpstreamStory = (): React.ReactNode => ; + +const internalErrorStory = (): React.ReactNode => ( + +); + +const mainModalStory = (): React.ReactNode => { + const alertStore = makeAlertStore(); + const settingsStore = new Settings(null); + alertStore.info.setVersion("1.2.3"); + return ( + + {}} + expandAllOptions={true} + /> + + ); +}; + +const overviewModalStory = (): React.ReactNode => { + const alertStore = new AlertStore(["full"]); + MockGrid(alertStore); + return ( + + {}} /> + + ); +}; + +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 ( +
+
+ {}} + /> + {}} + isOpen={true} + /> + {}} + isOpen={true} + /> + {}} + /> + {}} + isOpen={true} + /> + {}} + isOpen={true} + /> +
+
+ ); +}; + +const silenceModalEditorReadOnlyStory = (): React.ReactNode => { + const alertStore = makeReadOnlyAlertStore(); + const settingsStore = new Settings(null); + const silenceFormStore = new SilenceFormStore(); + silenceFormStore.tab.setTab("editor"); + return ( + + {}} + /> + + ); +}; + +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 ( + + {}} + previewOpen={true} + /> + + ); +}; + +const silenceModalBrowserStory = (): React.ReactNode => { + const alertStore = makeAlertStore(); + const settingsStore = new Settings(null); + const silenceFormStore = new SilenceFormStore(); + silenceFormStore.tab.setTab("browser"); + return ( + + {}} + /> + + ); +}; + +const silenceModalEmptyBrowserStory = (): React.ReactNode => { + const alertStore = makeAlertStore(); + const settingsStore = new Settings(null); + const silenceFormStore = new SilenceFormStore(); + silenceFormStore.tab.setTab("browser"); + return ( + + {}} + /> + + ); +}; + +const toastStory = (): React.ReactNode => { + const alertStore = new AlertStore([]); + alertStore.info.setVersion("999.99.0"); + return ( +
+ + } + hasClose={true} + /> + } + hasClose={false} + /> +
+ ); +}; + +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 }) => ( +
+
+ + {storyFn()} + +
+
+
+ + {storyFn()} + +
+
+); + +const VisualTestApp = () => { + const storyName = window.location.hash.replace("#", "") || ""; + const storyFn = stories[storyName]; + + if (!storyFn) { + return ( +
+

Visual Regression Test Stories

+
    + {Object.keys(stories).map((name) => ( +
  • + {name} +
  • + ))} +
+
+ ); + } + + return ; +}; + +const rootEl = document.getElementById("root"); +if (rootEl) { + const root = createRoot(rootEl); + root.render(); +} diff --git a/ui/src/e2e/visual.spec.ts b/ui/src/e2e/visual.spec.ts new file mode 100644 index 000000000..1b6247ac2 --- /dev/null +++ b/ui/src/e2e/visual.spec.ts @@ -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, + }); + }); +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5dec267d5..64cd91bac 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -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, },