mirror of
https://github.com/prymitive/karma
synced 2026-05-05 03:16:51 +00:00
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
// Mock react-cool-dimensions to avoid ResizeObserver console.error
|
|
jest.mock("react-cool-dimensions", () => ({
|
|
__esModule: true,
|
|
default: () => ({
|
|
observe: jest.fn(),
|
|
unobserve: jest.fn(),
|
|
width: 1000,
|
|
height: 500,
|
|
entry: undefined,
|
|
}),
|
|
}));
|
|
|
|
import { act } from "react";
|
|
|
|
import { render, within } from "@testing-library/react";
|
|
|
|
import fetchMock from "@fetch-mock/jest";
|
|
|
|
import { mockMatchMedia } from "__fixtures__/matchMedia";
|
|
import { EmptyAPIResponse } from "__fixtures__/Fetch";
|
|
import { inReactiveContext } from "__fixtures__/MobX";
|
|
import type { UIDefaults, ThemeT } from "Models/UI";
|
|
import { SilenceFormStore, NewEmptyMatcher } from "Stores/SilenceFormStore";
|
|
import { StringToOption } from "Common/Select";
|
|
import { App } from "./App";
|
|
|
|
declare let global: any;
|
|
declare let window: any;
|
|
|
|
const uiDefaults: UIDefaults = {
|
|
Refresh: 30 * 1000 * 1000 * 1000,
|
|
HideFiltersWhenIdle: true,
|
|
ColorTitlebar: false,
|
|
MinimalGroupWidth: 420,
|
|
AlertsPerGroup: 5,
|
|
CollapseGroups: "collapsedOnMobile",
|
|
Theme: "auto",
|
|
Animations: true,
|
|
MultiGridLabel: "cluster",
|
|
MultiGridSortReverse: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
// creating App instance will push current filters into window.location
|
|
// ensure it's wiped after each test
|
|
window.history.pushState({}, "App", "/");
|
|
|
|
// matchMedia needs mocking
|
|
window.matchMedia = mockMatchMedia({});
|
|
|
|
global.ResizeObserver = jest.fn(() => ({
|
|
observe: jest.fn(),
|
|
disconnect: jest.fn(),
|
|
}));
|
|
global.ResizeObserverEntry = jest.fn();
|
|
|
|
fetchMock.mockReset();
|
|
fetchMock.route("*", {
|
|
body: JSON.stringify(EmptyAPIResponse()),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
localStorage.setItem("savedFilters", "");
|
|
localStorage.setItem("themeConfig", "");
|
|
jest.restoreAllMocks();
|
|
window.history.pushState({}, "App", "/");
|
|
global.window.location.href = "http://localhost/";
|
|
global.window.location.search = "";
|
|
});
|
|
|
|
describe("<App />", () => {
|
|
it("uses passed default filters if there's no query args or saved filters", async () => {
|
|
// Verifies default filters are applied when no query args or saved filters exist
|
|
expect(window.location.search).toBe("");
|
|
await act(async () => {
|
|
render(<App defaultFilters={["foo=bar"]} uiDefaults={uiDefaults} />);
|
|
});
|
|
expect(window.location.search).toBe("?q=foo%3Dbar");
|
|
});
|
|
|
|
it("uses saved filters if there's no query args (ignoring passed defaults)", async () => {
|
|
// Verifies saved filters take precedence over default filters
|
|
expect(window.location.search).toBe("");
|
|
localStorage.setItem(
|
|
"savedFilters",
|
|
JSON.stringify({
|
|
filters: ["bar=baz", "abc!=cba"],
|
|
present: true,
|
|
}),
|
|
);
|
|
|
|
// https://github.com/facebook/jest/issues/6798#issuecomment-412871616
|
|
const getItemSpy: any = jest.spyOn(Storage.prototype, "getItem");
|
|
|
|
await act(async () => {
|
|
render(
|
|
<App defaultFilters={["ignore=defaults"]} uiDefaults={uiDefaults} />,
|
|
);
|
|
});
|
|
|
|
expect(getItemSpy).toHaveBeenCalledWith("savedFilters");
|
|
expect(window.location.search).toBe("?q=bar%3Dbaz&q=abc%21%3Dcba");
|
|
|
|
getItemSpy.mockRestore();
|
|
});
|
|
|
|
it("ignores saved filters if 'present' key is falsey (use passed defaults)", async () => {
|
|
// Verifies saved filters are ignored when present flag is false
|
|
expect(window.location.search).toBe("");
|
|
localStorage.setItem(
|
|
"savedFilters",
|
|
JSON.stringify({
|
|
filters: ["ignore=saved"],
|
|
present: false,
|
|
}),
|
|
);
|
|
|
|
// https://github.com/facebook/jest/issues/6798#issuecomment-412871616
|
|
const getItemSpy: any = jest.spyOn(Storage.prototype, "getItem");
|
|
|
|
await act(async () => {
|
|
render(<App defaultFilters={["use=defaults"]} uiDefaults={uiDefaults} />);
|
|
});
|
|
|
|
expect(getItemSpy).toHaveBeenCalledWith("savedFilters");
|
|
expect(window.location.search).toBe("?q=use%3Ddefaults");
|
|
|
|
getItemSpy.mockRestore();
|
|
});
|
|
|
|
it("uses filters passed via ?q= query args (ignoring saved filters and passed defaults)", async () => {
|
|
// Verifies query args take precedence over saved filters and defaults
|
|
expect(window.location.search).toBe("");
|
|
localStorage.setItem(
|
|
"savedFilters",
|
|
JSON.stringify({
|
|
filters: ["ignore=saved"],
|
|
present: true,
|
|
}),
|
|
);
|
|
|
|
window.history.pushState({}, "App", "/?q=use%3Dquery");
|
|
|
|
await act(async () => {
|
|
render(
|
|
<App defaultFilters={["ignore=defaults"]} uiDefaults={uiDefaults} />,
|
|
);
|
|
});
|
|
|
|
expect(window.location.search).toBe("?q=use%3Dquery");
|
|
});
|
|
|
|
it("popstate event updates alertStore filters", async () => {
|
|
// Verifies that popstate event triggers filter update from URL
|
|
await act(async () => {
|
|
render(<App defaultFilters={["foo"]} uiDefaults={uiDefaults} />);
|
|
});
|
|
expect(window.location.search).toBe("?q=foo");
|
|
|
|
// Use history.pushState to change URL, then trigger popstate
|
|
window.history.pushState({}, "App", "/?q=bar");
|
|
|
|
await act(async () => {
|
|
const event = new PopStateEvent("popstate");
|
|
window.dispatchEvent(event);
|
|
});
|
|
|
|
expect(window.location.search).toBe("?q=bar");
|
|
});
|
|
|
|
it("unmounts without crashing", async () => {
|
|
// Verifies component unmounts cleanly without errors
|
|
let unmount: () => void;
|
|
await act(async () => {
|
|
const result = render(
|
|
<App defaultFilters={["foo=bar"]} uiDefaults={uiDefaults} />,
|
|
);
|
|
unmount = result.unmount;
|
|
});
|
|
unmount!();
|
|
|
|
const event = new PopStateEvent("popstate");
|
|
window.dispatchEvent(event);
|
|
});
|
|
|
|
it("populates silence from from 'm' query arg", async () => {
|
|
// Verifies silence form is populated from base64 encoded query arg
|
|
const m1 = NewEmptyMatcher();
|
|
m1.name = "foo";
|
|
m1.isRegex = true;
|
|
m1.values = [StringToOption("bar")];
|
|
const m2 = NewEmptyMatcher();
|
|
m2.name = "bar";
|
|
m2.isRegex = false;
|
|
m2.values = [StringToOption("foo"), StringToOption("baz")];
|
|
const store = new SilenceFormStore();
|
|
act(() => {
|
|
store.data.setMatchers([m1, m2]);
|
|
store.data.setComment("base64");
|
|
});
|
|
const m = inReactiveContext(() => store.data.toBase64);
|
|
|
|
// Use history.pushState instead of setting window.location to avoid jsdom navigation error
|
|
window.history.pushState({}, "App", `/?q=bar&m=${m}`);
|
|
|
|
await act(async () => {
|
|
render(<App defaultFilters={[]} uiDefaults={uiDefaults} />);
|
|
});
|
|
});
|
|
|
|
it("doesn't crash on invalid 'm' value", async () => {
|
|
// Verifies app handles invalid base64 silence data gracefully
|
|
const consoleSpy = jest
|
|
.spyOn(console, "error")
|
|
.mockImplementation(jest.fn());
|
|
|
|
global.window.location = {
|
|
href: "http://localhost/?q=bar&m=foo",
|
|
search: "?q=bar&m=foo",
|
|
};
|
|
|
|
await act(async () => {
|
|
render(<App defaultFilters={[]} uiDefaults={uiDefaults} />);
|
|
});
|
|
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("doesn't crash on truncated 'm' value", async () => {
|
|
// Verifies app handles truncated base64 silence data gracefully
|
|
const consoleSpy = jest
|
|
.spyOn(console, "error")
|
|
.mockImplementation(jest.fn());
|
|
|
|
const m1 = NewEmptyMatcher();
|
|
m1.name = "foo";
|
|
m1.isRegex = true;
|
|
m1.values = [StringToOption("bar")];
|
|
const m2 = NewEmptyMatcher();
|
|
m2.name = "bar";
|
|
m2.isRegex = false;
|
|
m2.values = [StringToOption("foo"), StringToOption("baz")];
|
|
const store = new SilenceFormStore();
|
|
store.data.setMatchers([m1, m2]);
|
|
store.data.setComment("base64");
|
|
const m = inReactiveContext(() => store.data.toBase64);
|
|
|
|
global.window.location = {
|
|
href: `http://localhost/?q=bar&m=${m}`,
|
|
search: `?q=bar&m=${m.slice(0, m.length - 2)}`,
|
|
};
|
|
|
|
await act(async () => {
|
|
render(<App defaultFilters={[]} uiDefaults={uiDefaults} />);
|
|
});
|
|
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("<App /> theme", () => {
|
|
const renderApp = async (theme: ThemeT) => {
|
|
let result: ReturnType<typeof render>;
|
|
await act(async () => {
|
|
result = render(
|
|
<App
|
|
defaultFilters={["foo=bar"]}
|
|
uiDefaults={Object.assign({}, uiDefaults, { Theme: theme })}
|
|
/>,
|
|
);
|
|
});
|
|
return result!;
|
|
};
|
|
|
|
it("configures light theme when uiDefaults passes it", async () => {
|
|
// Verifies light theme is configured when passed via uiDefaults
|
|
const { baseElement, unmount } = await renderApp("light");
|
|
const themeSpan = within(baseElement).getByText("", {
|
|
selector: "span[data-theme='light']",
|
|
});
|
|
expect(themeSpan).toBeInTheDocument();
|
|
unmount();
|
|
});
|
|
|
|
it("configures dark theme when uiDefaults passes it", async () => {
|
|
// Verifies dark theme is configured when passed via uiDefaults
|
|
const { baseElement, unmount } = await renderApp("dark");
|
|
const themeSpan = within(baseElement).getByText("", {
|
|
selector: "span[data-theme='dark']",
|
|
});
|
|
expect(themeSpan).toBeInTheDocument();
|
|
unmount();
|
|
});
|
|
|
|
it("configures automatic theme when uiDefaults passes it", async () => {
|
|
// Verifies auto theme is configured when passed via uiDefaults
|
|
const { baseElement, unmount } = await renderApp("auto");
|
|
const themeSpan = within(baseElement).getByText("", {
|
|
selector: "span[data-theme='auto']",
|
|
});
|
|
expect(themeSpan).toBeInTheDocument();
|
|
unmount();
|
|
});
|
|
|
|
it("configures automatic theme when uiDefaults doesn't pass any value", async () => {
|
|
// Verifies auto theme is used as default when no theme specified
|
|
let baseElement: HTMLElement;
|
|
let unmount: () => void;
|
|
await act(async () => {
|
|
const result = render(
|
|
<App defaultFilters={["foo=bar"]} uiDefaults={null} />,
|
|
);
|
|
baseElement = result.baseElement;
|
|
unmount = result.unmount;
|
|
});
|
|
const themeSpan = within(baseElement!).getByText("", {
|
|
selector: "span[data-theme='auto']",
|
|
});
|
|
expect(themeSpan).toBeInTheDocument();
|
|
unmount!();
|
|
});
|
|
|
|
it("applies light theme when theme=auto and browser doesn't support prefers-color-scheme", async () => {
|
|
// Verifies light theme fallback when browser doesn't support prefers-color-scheme
|
|
window.matchMedia = mockMatchMedia({});
|
|
const { unmount } = await renderApp("auto");
|
|
expect(document.body.classList.contains("theme-light")).toBe(true);
|
|
unmount();
|
|
});
|
|
|
|
const lightMatch = () => ({
|
|
"(prefers-color-scheme)": {
|
|
media: "(prefers-color-scheme)",
|
|
matches: true,
|
|
},
|
|
"(prefers-color-scheme: light)": {
|
|
media: "(prefers-color-scheme: light)",
|
|
matches: true,
|
|
},
|
|
"(prefers-color-scheme: dark)": {
|
|
media: "(prefers-color-scheme: dark)",
|
|
matches: false,
|
|
},
|
|
});
|
|
|
|
const darkMatch = () => ({
|
|
"(prefers-color-scheme)": {
|
|
media: "(prefers-color-scheme)",
|
|
matches: true,
|
|
},
|
|
"(prefers-color-scheme: light)": {
|
|
media: "(prefers-color-scheme: light)",
|
|
matches: false,
|
|
},
|
|
"(prefers-color-scheme: dark)": {
|
|
media: "(prefers-color-scheme: dark)",
|
|
matches: true,
|
|
},
|
|
});
|
|
|
|
interface testCaseT {
|
|
name: string;
|
|
settings: ThemeT;
|
|
matchMedia: any;
|
|
theme: string;
|
|
}
|
|
|
|
const testCases: testCaseT[] = [
|
|
{
|
|
name: "applies LightTheme when config=auto and browser doesn't support prefers-color-scheme",
|
|
settings: "auto",
|
|
matchMedia: {},
|
|
theme: "LightTheme",
|
|
},
|
|
{
|
|
name: "applies LightTheme when config=auto and browser prefers-color-scheme:light matches",
|
|
settings: "auto",
|
|
matchMedia: lightMatch(),
|
|
theme: "LightTheme",
|
|
},
|
|
{
|
|
name: "applies DarkTheme when config=auto and browser prefers-color-scheme:dark matches",
|
|
settings: "auto",
|
|
matchMedia: darkMatch(),
|
|
theme: "DarkTheme",
|
|
},
|
|
|
|
{
|
|
name: "applies LightTheme when config=light and browser doesn't support prefers-color-scheme",
|
|
settings: "light",
|
|
matchMedia: {},
|
|
theme: "LightTheme",
|
|
},
|
|
{
|
|
name: "applies LightTheme when config=light and browser prefers-color-scheme:light matches",
|
|
settings: "light",
|
|
matchMedia: lightMatch(),
|
|
theme: "LightTheme",
|
|
},
|
|
|
|
{
|
|
name: "applies DarkTheme when config=dark and browser doesn't support prefers-color-scheme",
|
|
settings: "dark",
|
|
matchMedia: {},
|
|
theme: "DarkTheme",
|
|
},
|
|
{
|
|
name: "applies DarkTheme when config=dark and browser prefers-color-scheme:dark matches",
|
|
settings: "dark",
|
|
matchMedia: darkMatch(),
|
|
theme: "DarkTheme",
|
|
},
|
|
];
|
|
for (const testCase of testCases) {
|
|
it(`${testCase.name}`, async () => {
|
|
window.matchMedia = mockMatchMedia(testCase.matchMedia);
|
|
const { unmount } = await renderApp(testCase.settings);
|
|
const themeClass =
|
|
testCase.theme === "LightTheme" ? "theme-light" : "theme-dark";
|
|
expect(document.body.classList.contains(themeClass)).toBe(true);
|
|
unmount();
|
|
window.matchMedia.mockRestore();
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("<App /> animations", () => {
|
|
const renderApp = async (animations: boolean) => {
|
|
let result: ReturnType<typeof render>;
|
|
await act(async () => {
|
|
result = render(
|
|
<App
|
|
defaultFilters={["foo=bar"]}
|
|
uiDefaults={Object.assign({}, uiDefaults, { Animations: animations })}
|
|
/>,
|
|
);
|
|
});
|
|
return result!;
|
|
};
|
|
|
|
it("enables animations in the context when set via UI defaults", async () => {
|
|
// Verifies animations are enabled when set via UI defaults
|
|
const { unmount } = await renderApp(true);
|
|
unmount();
|
|
});
|
|
|
|
it("disables animations in the context when disabled via UI defaults", async () => {
|
|
// Verifies animations are disabled when set via UI defaults
|
|
const { unmount } = await renderApp(false);
|
|
unmount();
|
|
});
|
|
});
|