Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit b82db7a

Browse files
committed
frontend: use in-memory history in test environments
This removes the flakiness of location-based tests
1 parent 31c81d0 commit b82db7a

File tree

10 files changed

+73
-80
lines changed

10 files changed

+73
-80
lines changed

frontend/package-lock.json

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"classnames": "^2.3.2",
3131
"date-fns": "^2.30.0",
3232
"graphql": "^16.8.1",
33+
"history": "^5.3.0",
3334
"i18next": "^23.6.0",
3435
"i18next-browser-languagedetector": "^7.1.0",
3536
"i18next-http-backend": "^2.2.2",

frontend/src/components/Layout.test.tsx

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,57 +15,24 @@
1515
// @vitest-environment happy-dom
1616

1717
import { render } from "@testing-library/react";
18-
import { Provider } from "jotai";
19-
import { useHydrateAtoms } from "jotai/utils";
20-
import { Suspense } from "react";
2118
import { describe, expect, it, vi, afterAll, beforeEach } from "vitest";
2219

2320
import { currentUserIdAtom, GqlResult } from "../atoms";
24-
import { appConfigAtom, locationAtom } from "../routing";
21+
import { WithLocation } from "../test-utils/WithLocation";
2522

2623
import Layout from "./Layout";
2724

28-
beforeEach(async () => {
29-
// For some reason, the locationAtom gets updated with `about:black` on render,
30-
// so we need to set a "real" location and wait for the next tick
31-
window.location.assign("https://example.com/");
32-
// Wait the next tick for the location to update
33-
await new Promise((resolve) => setTimeout(resolve, 0));
34-
});
35-
36-
const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
37-
children,
38-
path,
39-
}) => {
40-
useHydrateAtoms([
41-
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
42-
[locationAtom, { pathname: path }],
43-
]);
44-
return <>{children}</>;
45-
};
46-
47-
const WithLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
48-
children,
49-
path,
50-
}) => {
51-
return (
52-
<Provider>
53-
<Suspense>
54-
<HydrateLocation path={path}>{children}</HydrateLocation>
55-
</Suspense>
56-
</Provider>
57-
);
58-
};
59-
6025
describe("<Layout />", () => {
6126
beforeEach(() => {
6227
vi.spyOn(currentUserIdAtom, "read").mockResolvedValue(
6328
"abc123" as unknown as GqlResult<string | null>,
6429
);
6530
});
31+
6632
afterAll(() => {
6733
vi.restoreAllMocks();
6834
});
35+
6936
it("renders app navigation correctly", async () => {
7037
const component = render(
7138
<WithLocation path="/account">

frontend/src/components/NavItem/NavItem.test.tsx

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,47 +14,13 @@
1414

1515
// @vitest-environment happy-dom
1616

17-
import type { IWindow } from "happy-dom";
18-
import { Provider } from "jotai";
19-
import { useHydrateAtoms } from "jotai/utils";
2017
import { create } from "react-test-renderer";
21-
import { beforeEach, describe, expect, it } from "vitest";
18+
import { describe, expect, it } from "vitest";
2219

23-
import { appConfigAtom, locationAtom } from "../../routing";
20+
import { WithLocation } from "../../test-utils/WithLocation";
2421

2522
import NavItem from "./NavItem";
2623

27-
beforeEach(async () => {
28-
const w = window as unknown as IWindow;
29-
30-
// For some reason, the locationAtom gets updated with `about:black` on render,
31-
// so we need to set a "real" location and wait for the next tick
32-
w.happyDOM.setURL("https://example.com/");
33-
await w.happyDOM.whenAsyncComplete();
34-
});
35-
36-
const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
37-
children,
38-
path,
39-
}) => {
40-
useHydrateAtoms([
41-
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
42-
[locationAtom, { pathname: path }],
43-
]);
44-
return <>{children}</>;
45-
};
46-
47-
const WithLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
48-
children,
49-
path,
50-
}) => {
51-
return (
52-
<Provider>
53-
<HydrateLocation path={path}>{children}</HydrateLocation>
54-
</Provider>
55-
);
56-
};
57-
5824
describe("NavItem", () => {
5925
it("render an active <NavItem />", () => {
6026
const component = create(

frontend/src/routing/atoms.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,19 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import { createBrowserHistory, createMemoryHistory } from "history";
1516
import { atom } from "jotai";
1617
import { atomWithLocation } from "jotai-location";
1718

1819
import appConfig, { AppConfig } from "../config";
1920

2021
import { Location, pathToRoute, Route, routeToPath } from "./routes";
2122

23+
/* Use memory history for testing */
24+
export const history = import.meta.vitest
25+
? createMemoryHistory()
26+
: createBrowserHistory();
27+
2228
export const appConfigAtom = atom<AppConfig>(appConfig);
2329

2430
const locationToRoute = (root: string, location: Location): Route => {
@@ -30,7 +36,41 @@ const locationToRoute = (root: string, location: Location): Route => {
3036
return pathToRoute(path);
3137
};
3238

33-
export const locationAtom = atomWithLocation();
39+
const getLocation = (): Location => {
40+
return {
41+
pathname: history.location.pathname,
42+
searchParams: new URLSearchParams(history.location.search),
43+
};
44+
};
45+
46+
const applyLocation = (
47+
location: Location,
48+
options?: { replace?: boolean },
49+
): void => {
50+
const destination = {
51+
pathname: location.pathname,
52+
search: location.searchParams?.toString(),
53+
};
54+
55+
if (options?.replace) {
56+
history.replace(destination);
57+
} else {
58+
history.push(destination);
59+
}
60+
};
61+
62+
type Callback = () => void;
63+
type Unsubscribe = () => void;
64+
const subscribe = (callback: Callback): Unsubscribe =>
65+
history.listen(() => {
66+
callback();
67+
});
68+
69+
export const locationAtom = atomWithLocation({
70+
subscribe,
71+
getLocation,
72+
applyLocation,
73+
});
3474

3575
export const routeAtom = atom(
3676
(get) => {

frontend/src/routing/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@ export { default as Link } from "./Link";
1717
export type { Route, Location } from "./routes";
1818
export { pathToRoute, routeToPath } from "./routes";
1919
export { getRouteActionRedirection } from "./actions";
20-
export { routeAtom, locationAtom, appConfigAtom } from "./atoms";
20+
export { routeAtom, locationAtom, appConfigAtom, history } from "./atoms";
2121
export { useNavigationLink } from "./useNavigationLink";

frontend/src/routing/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// limitations under the License.
1414

1515
export type Location = Readonly<{
16-
pathname?: string;
16+
pathname: string;
1717
searchParams?: URLSearchParams;
1818
}>;
1919

frontend/src/test-utils/WithLocation.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
// @vitest-environment happy-dom
16-
1715
import { Provider } from "jotai";
1816
import { useHydrateAtoms } from "jotai/utils";
17+
import { Suspense, useEffect } from "react";
1918

20-
import { appConfigAtom, locationAtom } from "../routing";
19+
import { appConfigAtom, history, locationAtom } from "../routing";
2120

2221
const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
2322
children,
2423
path,
2524
}) => {
25+
useEffect(() => {
26+
history.replace(path);
27+
}, [path]);
28+
2629
useHydrateAtoms([
2730
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
2831
[locationAtom, { pathname: path }],
@@ -47,7 +50,9 @@ export const WithLocation: React.FC<
4750
> = ({ children, path }) => {
4851
return (
4952
<Provider>
50-
<HydrateLocation path={path || "/"}>{children}</HydrateLocation>
53+
<Suspense>
54+
<HydrateLocation path={path || "/"}>{children}</HydrateLocation>
55+
</Suspense>
5156
</Provider>
5257
);
5358
};

frontend/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"DOM.Iterable",
88
"ESNext"
99
],
10-
"types": ["vite/client"],
10+
"types": ["vite/client", "vitest/importMeta"],
1111
"allowJs": false,
1212
"skipLibCheck": true,
1313
"esModuleInterop": false,

frontend/vite.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function i18nHotReload(): PluginOption {
3636
},
3737
};
3838
}
39+
3940
export default defineConfig((env) => ({
4041
base: "./",
4142

@@ -45,6 +46,10 @@ export default defineConfig((env) => ({
4546
},
4647
},
4748

49+
define: {
50+
"import.meta.vitest": "undefined",
51+
},
52+
4853
build: {
4954
manifest: true,
5055
assetsDir: "",

0 commit comments

Comments
 (0)