mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): update and refactor all custom hook tests that rely on React Router (#12219)
* chore: rename useTab to useSearchParamsKey and add test * chore: mark old renderHookWithAuth as deprecated (temp) * fix: update imports for useResourcesNav * refactor: change API for useSearchParamsKey * chore: let user pass in their own URLSearchParams value * refactor: clean up comments for clarity * fix: update import * wip: commit progress on useWorkspaceDuplication revamp * chore: migrate duplication test to new helper * refactor: update code for clarity * refactor: reorder test cases for clarity * refactor: split off hook helper into separate file * refactor: remove reliance on internal React Router state property * refactor: move variables around for more clarity * refactor: more updates for clarity * refactor: reorganize test cases for clarity * refactor: clean up test cases for useWorkspaceDupe * refactor: clean up test cases for useWorkspaceDupe
This commit is contained in:
@@ -2,4 +2,3 @@ export * from "./useClickable";
|
||||
export * from "./useClickableTableRow";
|
||||
export * from "./useClipboard";
|
||||
export * from "./usePagination";
|
||||
export * from "./useTab";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { renderHookWithAuth } from "testHelpers/renderHelpers";
|
||||
import { renderHookWithAuth } from "testHelpers/hooks";
|
||||
import {
|
||||
type PaginatedData,
|
||||
type UsePaginatedQueryOptions,
|
||||
@@ -23,9 +23,13 @@ function render<
|
||||
route?: `/?page=${string}`,
|
||||
) {
|
||||
return renderHookWithAuth(({ options }) => usePaginatedQuery(options), {
|
||||
route,
|
||||
path: "/",
|
||||
initialProps: { options },
|
||||
routingOptions: {
|
||||
route,
|
||||
path: "/",
|
||||
},
|
||||
renderOptions: {
|
||||
initialProps: { options },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { act, waitFor } from "@testing-library/react";
|
||||
import { renderHookWithAuth } from "testHelpers/hooks";
|
||||
import { useSearchParamsKey } from "./useSearchParamsKey";
|
||||
|
||||
/**
|
||||
* Tried to extract the setup logic into one place, but it got surprisingly
|
||||
* messy. Went with straightforward approach of calling things individually
|
||||
*
|
||||
* @todo See if there's a way to test the interaction with the history object
|
||||
* (particularly, for replace behavior). It's traditionally very locked off, and
|
||||
* React Router gives you no way of interacting with it directly.
|
||||
*/
|
||||
describe(useSearchParamsKey.name, () => {
|
||||
describe("Render behavior", () => {
|
||||
it("Returns empty string if hook key does not exist in URL, and there is no default value", async () => {
|
||||
const { result } = await renderHookWithAuth(
|
||||
() => useSearchParamsKey({ key: "blah" }),
|
||||
{ routingOptions: { route: `/` } },
|
||||
);
|
||||
|
||||
expect(result.current.value).toEqual("");
|
||||
});
|
||||
|
||||
it("Returns out 'defaultValue' property if defined while hook key does not exist in URL", async () => {
|
||||
const defaultValue = "dogs";
|
||||
const { result } = await renderHookWithAuth(
|
||||
() => useSearchParamsKey({ key: "blah", defaultValue }),
|
||||
{ routingOptions: { route: `/` } },
|
||||
);
|
||||
|
||||
expect(result.current.value).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it("Returns out URL value if key exists in URL (always ignoring default value)", async () => {
|
||||
const key = "blah";
|
||||
const value = "cats";
|
||||
|
||||
const { result } = await renderHookWithAuth(
|
||||
() => useSearchParamsKey({ key, defaultValue: "I don't matter" }),
|
||||
{ routingOptions: { route: `/?${key}=${value}` } },
|
||||
);
|
||||
|
||||
expect(result.current.value).toEqual(value);
|
||||
});
|
||||
|
||||
it("Does not have methods change previous values if 'key' argument changes during re-renders", async () => {
|
||||
const readonlyKey = "readonlyKey";
|
||||
const mutableKey = "mutableKey";
|
||||
const initialReadonlyValue = "readonly";
|
||||
const initialMutableValue = "mutable";
|
||||
|
||||
const { result, rerender, getLocationSnapshot } =
|
||||
await renderHookWithAuth(({ key }) => useSearchParamsKey({ key }), {
|
||||
routingOptions: {
|
||||
route: `/?${readonlyKey}=${initialReadonlyValue}&${mutableKey}=${initialMutableValue}`,
|
||||
},
|
||||
renderOptions: { initialProps: { key: readonlyKey } },
|
||||
});
|
||||
|
||||
const swapValue = "dogs";
|
||||
await rerender({ key: mutableKey });
|
||||
act(() => result.current.setValue(swapValue));
|
||||
await waitFor(() => expect(result.current.value).toEqual(swapValue));
|
||||
|
||||
const snapshot1 = getLocationSnapshot();
|
||||
expect(snapshot1.search.get(readonlyKey)).toEqual(initialReadonlyValue);
|
||||
expect(snapshot1.search.get(mutableKey)).toEqual(swapValue);
|
||||
|
||||
act(() => result.current.deleteValue());
|
||||
await waitFor(() => expect(result.current.value).toEqual(""));
|
||||
|
||||
const snapshot2 = getLocationSnapshot();
|
||||
expect(snapshot2.search.get(readonlyKey)).toEqual(initialReadonlyValue);
|
||||
expect(snapshot2.search.get(mutableKey)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setValue method", () => {
|
||||
it("Updates state and URL when called with a new value", async () => {
|
||||
const key = "blah";
|
||||
const initialValue = "cats";
|
||||
|
||||
const { result, getLocationSnapshot } = await renderHookWithAuth(
|
||||
() => useSearchParamsKey({ key }),
|
||||
{ routingOptions: { route: `/?${key}=${initialValue}` } },
|
||||
);
|
||||
|
||||
const newValue = "dogs";
|
||||
act(() => result.current.setValue(newValue));
|
||||
await waitFor(() => expect(result.current.value).toEqual(newValue));
|
||||
|
||||
const { search } = getLocationSnapshot();
|
||||
expect(search.get(key)).toEqual(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteValue method", () => {
|
||||
it("Clears value for the given key from the state and URL when called", async () => {
|
||||
const key = "blah";
|
||||
const initialValue = "cats";
|
||||
|
||||
const { result, getLocationSnapshot } = await renderHookWithAuth(
|
||||
() => useSearchParamsKey({ key }),
|
||||
{ routingOptions: { route: `/?${key}=${initialValue}` } },
|
||||
);
|
||||
|
||||
act(() => result.current.deleteValue());
|
||||
await waitFor(() => expect(result.current.value).toEqual(""));
|
||||
|
||||
const { search } = getLocationSnapshot();
|
||||
expect(search.get(key)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Override behavior", () => {
|
||||
it("Will dispatch state changes through custom URLSearchParams value if provided", async () => {
|
||||
const key = "love";
|
||||
const initialValue = "dogs";
|
||||
const customParams = new URLSearchParams({ [key]: initialValue });
|
||||
|
||||
const { result } = await renderHookWithAuth(
|
||||
({ key }) => useSearchParamsKey({ key, searchParams: customParams }),
|
||||
{
|
||||
routingOptions: { route: `/?=${key}=${initialValue}` },
|
||||
renderOptions: { initialProps: { key } },
|
||||
},
|
||||
);
|
||||
|
||||
const newValue = "all animals";
|
||||
act(() => result.current.setValue(newValue));
|
||||
await waitFor(() => expect(customParams.get(key)).toEqual(newValue));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export type UseSearchParamsKeyConfig = Readonly<{
|
||||
key: string;
|
||||
searchParams?: URLSearchParams;
|
||||
defaultValue?: string;
|
||||
replace?: boolean;
|
||||
}>;
|
||||
|
||||
export type UseSearchParamKeyResult = Readonly<{
|
||||
value: string;
|
||||
setValue: (newValue: string) => void;
|
||||
deleteValue: () => void;
|
||||
}>;
|
||||
|
||||
export const useSearchParamsKey = (
|
||||
config: UseSearchParamsKeyConfig,
|
||||
): UseSearchParamKeyResult => {
|
||||
// Cannot use function update form for setSearchParams, because by default, it
|
||||
// will always be linked to innerSearchParams, ignoring the config's params
|
||||
const [innerSearchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const {
|
||||
key,
|
||||
searchParams = innerSearchParams,
|
||||
defaultValue = "",
|
||||
replace = true,
|
||||
} = config;
|
||||
|
||||
return {
|
||||
value: searchParams.get(key) ?? defaultValue,
|
||||
setValue: (newValue) => {
|
||||
searchParams.set(key, newValue);
|
||||
setSearchParams(searchParams, { replace });
|
||||
},
|
||||
deleteValue: () => {
|
||||
searchParams.delete(key);
|
||||
setSearchParams(searchParams, { replace });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export interface UseTabResult {
|
||||
value: string;
|
||||
set: (value: string) => void;
|
||||
}
|
||||
|
||||
export const useTab = (tabKey: string, defaultValue: string): UseTabResult => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const value = searchParams.get(tabKey) ?? defaultValue;
|
||||
|
||||
return {
|
||||
value,
|
||||
set: (value: string) => {
|
||||
searchParams.set(tabKey, value);
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,40 +1,45 @@
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { act, waitFor } from "@testing-library/react";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import * as M from "testHelpers/entities";
|
||||
import { renderHookWithAuth } from "testHelpers/renderHelpers";
|
||||
import { MockWorkspace } from "testHelpers/entities";
|
||||
import {
|
||||
type GetLocationSnapshot,
|
||||
renderHookWithAuth,
|
||||
} from "testHelpers/hooks";
|
||||
import * as M from "../../testHelpers/entities";
|
||||
import CreateWorkspacePage from "./CreateWorkspacePage";
|
||||
import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
|
||||
|
||||
function render(workspace?: Workspace) {
|
||||
return renderHookWithAuth(
|
||||
({ workspace }) => {
|
||||
return useWorkspaceDuplication(workspace);
|
||||
},
|
||||
({ workspace }) => useWorkspaceDuplication(workspace),
|
||||
{
|
||||
initialProps: { workspace },
|
||||
extraRoutes: [
|
||||
{
|
||||
path: "/templates/:template/workspace",
|
||||
element: <CreateWorkspacePage />,
|
||||
},
|
||||
],
|
||||
renderOptions: { initialProps: { workspace } },
|
||||
routingOptions: {
|
||||
extraRoutes: [
|
||||
{
|
||||
path: "/templates/:template/workspace",
|
||||
element: <CreateWorkspacePage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type RenderResult = Awaited<ReturnType<typeof render>>;
|
||||
type RenderHookResult = RenderResult["result"];
|
||||
|
||||
async function performNavigation(
|
||||
result: RenderResult["result"],
|
||||
router: RenderResult["router"],
|
||||
result: RenderHookResult,
|
||||
getLocationSnapshot: GetLocationSnapshot,
|
||||
) {
|
||||
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
|
||||
result.current.duplicateWorkspace();
|
||||
act(() => result.current.duplicateWorkspace());
|
||||
|
||||
const templateName = MockWorkspace.template_name;
|
||||
return waitFor(() => {
|
||||
expect(router.state.location.pathname).toEqual(
|
||||
`/templates/${M.MockWorkspace.template_name}/workspace`,
|
||||
);
|
||||
const { pathname } = getLocationSnapshot();
|
||||
expect(pathname).toEqual(`/templates/${templateName}/workspace`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,28 +49,23 @@ describe(`${useWorkspaceDuplication.name}`, () => {
|
||||
expect(result.current.isDuplicationReady).toBe(false);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rerender({ workspace: undefined });
|
||||
await rerender({ workspace: undefined });
|
||||
expect(result.current.isDuplicationReady).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("Will become ready when workspace is provided and build params are successfully fetched", async () => {
|
||||
const { result } = await render(M.MockWorkspace);
|
||||
|
||||
const { result } = await render(MockWorkspace);
|
||||
expect(result.current.isDuplicationReady).toBe(false);
|
||||
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
|
||||
});
|
||||
|
||||
it("Is able to navigate the user to the workspace creation page", async () => {
|
||||
const { result, router } = await render(M.MockWorkspace);
|
||||
await performNavigation(result, router);
|
||||
const { result, getLocationSnapshot } = await render(MockWorkspace);
|
||||
await performNavigation(result, getLocationSnapshot);
|
||||
});
|
||||
|
||||
test("Navigating populates the URL search params with the workspace's build params", async () => {
|
||||
const { result, router } = await render(M.MockWorkspace);
|
||||
await performNavigation(result, router);
|
||||
|
||||
const parsedParams = new URLSearchParams(router.state.location.search);
|
||||
const mockBuildParams = [
|
||||
M.MockWorkspaceBuildParameter1,
|
||||
M.MockWorkspaceBuildParameter2,
|
||||
@@ -74,25 +74,29 @@ describe(`${useWorkspaceDuplication.name}`, () => {
|
||||
M.MockWorkspaceBuildParameter5,
|
||||
];
|
||||
|
||||
const { result, getLocationSnapshot } = await render(MockWorkspace);
|
||||
await performNavigation(result, getLocationSnapshot);
|
||||
|
||||
const { search } = getLocationSnapshot();
|
||||
for (const { name, value } of mockBuildParams) {
|
||||
const key = `param.${name}`;
|
||||
expect(parsedParams.get(key)).toEqual(value);
|
||||
expect(search.get(key)).toEqual(value);
|
||||
}
|
||||
});
|
||||
|
||||
test("Navigating appends other necessary metadata to the search params", async () => {
|
||||
const { result, router } = await render(M.MockWorkspace);
|
||||
await performNavigation(result, router);
|
||||
|
||||
const parsedParams = new URLSearchParams(router.state.location.search);
|
||||
const extraMetadataEntries = [
|
||||
const extraMetadataEntries: readonly [string, string][] = [
|
||||
["mode", "duplicate"],
|
||||
["name", `${M.MockWorkspace.name}-copy`],
|
||||
["version", M.MockWorkspace.template_active_version_id],
|
||||
] as const;
|
||||
["name", `${MockWorkspace.name}-copy`],
|
||||
["version", MockWorkspace.template_active_version_id],
|
||||
];
|
||||
|
||||
const { result, getLocationSnapshot } = await render(MockWorkspace);
|
||||
await performNavigation(result, getLocationSnapshot);
|
||||
|
||||
const { search } = getLocationSnapshot();
|
||||
for (const [key, value] of extraMetadataEntries) {
|
||||
expect(parsedParams.get(key)).toBe(value);
|
||||
expect(search.get(key)).toBe(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Stats, StatsItem } from "components/Stats/Stats";
|
||||
import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
|
||||
import { useTab } from "hooks";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { DashboardFullPage } from "modules/dashboard/DashboardLayout";
|
||||
import { AgentLogs, useAgentLogs } from "modules/resources/AgentLogs";
|
||||
import {
|
||||
@@ -51,14 +51,17 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
activeBuildNumber,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const tab = useTab(LOGS_TAB_KEY, "build");
|
||||
const tabState = useSearchParamsKey({
|
||||
key: LOGS_TAB_KEY,
|
||||
defaultValue: "build",
|
||||
});
|
||||
|
||||
if (!build) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const agents = build.resources.flatMap((r) => r.agents ?? []);
|
||||
const selectedAgent = agents.find((a) => a.id === tab.value);
|
||||
const selectedAgent = agents.find((a) => a.id === tabState.value);
|
||||
|
||||
return (
|
||||
<DashboardFullPage>
|
||||
@@ -141,7 +144,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
</Sidebar>
|
||||
|
||||
<div css={{ height: "100%", overflowY: "auto", width: "100%" }}>
|
||||
<Tabs active={tab.value}>
|
||||
<Tabs active={tabState.value}>
|
||||
<TabsList>
|
||||
<TabLink to={`?${LOGS_TAB_KEY}=build`} value="build">
|
||||
Build
|
||||
@@ -187,7 +190,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tab.value === "build" ? (
|
||||
{tabState.value === "build" ? (
|
||||
<BuildLogsContent logs={logs} />
|
||||
) : (
|
||||
<AgentLogsContent agent={selectedAgent!} />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
|
||||
import { useTab } from "hooks";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { AgentRow } from "modules/resources/AgentRow";
|
||||
import { HistorySidebar } from "./HistorySidebar";
|
||||
import type { WorkspacePermissions } from "./permissions";
|
||||
@@ -88,13 +88,12 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
const transitionStats =
|
||||
template !== undefined ? ActiveTransition(template, workspace) : undefined;
|
||||
|
||||
const sidebarOption = useTab("sidebar", "");
|
||||
const sidebarOption = useSearchParamsKey({ key: "sidebar" });
|
||||
const setSidebarOption = (newOption: string) => {
|
||||
const { set, value } = sidebarOption;
|
||||
if (value === newOption) {
|
||||
set("");
|
||||
if (sidebarOption.value === newOption) {
|
||||
sidebarOption.deleteValue();
|
||||
} else {
|
||||
set(newOption);
|
||||
sidebarOption.setValue(newOption);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { WorkspaceResource } from "api/typesGenerated";
|
||||
import { useTab } from "hooks";
|
||||
import { useEffectEvent } from "hooks/hookPolyfills";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
|
||||
export const resourceOptionValue = (resource: WorkspaceResource) => {
|
||||
return `${resource.type}_${resource.name}`;
|
||||
@@ -14,8 +14,7 @@ export const resourceOptionValue = (resource: WorkspaceResource) => {
|
||||
// refactoring. Consider revisiting this solution in the future for a more
|
||||
// robust implementation.
|
||||
export const useResourcesNav = (resources: WorkspaceResource[]) => {
|
||||
const resourcesNav = useTab("resources", "");
|
||||
|
||||
const resourcesNav = useSearchParamsKey({ key: "resources" });
|
||||
const isSelected = useCallback(
|
||||
(resource: WorkspaceResource) => {
|
||||
return resourceOptionValue(resource) === resourcesNav.value;
|
||||
@@ -29,8 +28,9 @@ export const useResourcesNav = (resources: WorkspaceResource[]) => {
|
||||
const hasResources = resources && resources.length > 0;
|
||||
const hasResourcesWithAgents =
|
||||
hasResources && resources[0].agents && resources[0].agents.length > 0;
|
||||
|
||||
if (!hasSelectedResource && hasResourcesWithAgents) {
|
||||
resourcesNav.set(resourceOptionValue(resources[0]));
|
||||
resourcesNav.setValue(resourceOptionValue(resources[0]));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -40,7 +40,7 @@ export const useResourcesNav = (resources: WorkspaceResource[]) => {
|
||||
|
||||
const select = useCallback(
|
||||
(resource: WorkspaceResource) => {
|
||||
resourcesNav.set(resourceOptionValue(resource));
|
||||
resourcesNav.setValue(resourceOptionValue(resource));
|
||||
},
|
||||
[resourcesNav],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
type RenderHookOptions,
|
||||
type RenderHookResult,
|
||||
waitFor,
|
||||
renderHook,
|
||||
act,
|
||||
} from "@testing-library/react";
|
||||
import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useReducer,
|
||||
} from "react";
|
||||
import type { QueryClient } from "react-query";
|
||||
import {
|
||||
type Location,
|
||||
createMemoryRouter,
|
||||
RouterProvider,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { AppProviders } from "App";
|
||||
import { RequireAuth } from "contexts/auth/RequireAuth";
|
||||
import {
|
||||
type RenderWithAuthOptions,
|
||||
createTestQueryClient,
|
||||
} from "./renderHelpers";
|
||||
|
||||
export type RouterLocationSnapshot<TLocationState = unknown> = Readonly<{
|
||||
search: URLSearchParams;
|
||||
pathname: string;
|
||||
state: Location<TLocationState>["state"];
|
||||
}>;
|
||||
|
||||
export type GetLocationSnapshot<TLocationState = unknown> =
|
||||
() => RouterLocationSnapshot<TLocationState>;
|
||||
|
||||
export type RenderHookWithAuthResult<
|
||||
TResult,
|
||||
TProps,
|
||||
TLocationState = unknown,
|
||||
> = Readonly<
|
||||
Omit<RenderHookResult<TResult, TProps>, "rerender"> & {
|
||||
queryClient: QueryClient;
|
||||
rerender: (newProps: TProps) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Gives you back an immutable snapshot of the current location's values.
|
||||
*
|
||||
* As this is a snapshot, its values can quickly become inaccurate - as soon
|
||||
* as a new re-render (even ones you didn't trigger yourself). Keep that in
|
||||
* mind when making assertions.
|
||||
*/
|
||||
getLocationSnapshot: GetLocationSnapshot<TLocationState>;
|
||||
}
|
||||
>;
|
||||
|
||||
export type RenderHookWithAuthConfig<TProps> = Readonly<{
|
||||
routingOptions?: Omit<RenderWithAuthOptions, "children">;
|
||||
renderOptions?: Omit<RenderHookOptions<TProps>, "wrapper">;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Gives you a custom version of renderHook that is aware of all our App
|
||||
* providers (query, routing, etc.).
|
||||
*
|
||||
* Unfortunately, React Router does not make it easy to access the router after
|
||||
* it's been set up, which can lead to some chicken-or-the-egg situations
|
||||
* @see {@link https://github.com/coder/coder/pull/10362#discussion_r1380852725}
|
||||
*/
|
||||
export async function renderHookWithAuth<Result, Props>(
|
||||
render: (initialProps: Props) => Result,
|
||||
config: RenderHookWithAuthConfig<Props>,
|
||||
): Promise<RenderHookWithAuthResult<Result, Props>> {
|
||||
/**
|
||||
* Our setup here is evil, gross, and cursed because of how React Router
|
||||
* itself is set up. We need to go through RouterProvider, so that we have a
|
||||
* Context for calling all the React Router hooks, but that poses two
|
||||
* problems:
|
||||
* 1. <RouterProvider> does not accept children, so there is no easy way to
|
||||
* interface it with renderHook, which uses children as its only tool for
|
||||
* dependency injection
|
||||
* 2. Even after you somehow jam a child value into the router, calling
|
||||
* renderHook's rerender method will not do anything. RouterProvider is
|
||||
* auto-memoized against re-renders, so because it thinks that its only
|
||||
* input (the router object) hasn't changed, it will stop the re-render,
|
||||
* and prevent any children from re-rendering (even if they would have new
|
||||
* values).
|
||||
*
|
||||
* Have to do a lot of work to side-step those issues (best described as a
|
||||
* "Super Mario warp pipe"), and make sure that we're not relying on internal
|
||||
* React Router implementation details that could break at a moment's notice
|
||||
*/
|
||||
// Some of the let variables are defined with definite assignment (! operator)
|
||||
let currentLocation!: Location;
|
||||
const LocationLeaker: FC<PropsWithChildren> = ({ children }) => {
|
||||
currentLocation = useLocation();
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
let forceUpdateRenderHookChildren!: () => void;
|
||||
let currentRenderHookChildren: ReactNode = undefined;
|
||||
|
||||
const InitialRoute: FC = () => {
|
||||
const [, forceRerender] = useReducer((b: boolean) => !b, false);
|
||||
forceUpdateRenderHookChildren = () => act(forceRerender);
|
||||
return <LocationLeaker>{currentRenderHookChildren}</LocationLeaker>;
|
||||
};
|
||||
|
||||
const { routingOptions = {}, renderOptions = {} } = config;
|
||||
const {
|
||||
path = "/",
|
||||
route = "/",
|
||||
extraRoutes = [],
|
||||
nonAuthenticatedRoutes = [],
|
||||
} = routingOptions;
|
||||
|
||||
const wrappedExtraRoutes = extraRoutes.map((route) => ({
|
||||
...route,
|
||||
element: <LocationLeaker>{route.element}</LocationLeaker>,
|
||||
}));
|
||||
|
||||
const wrappedNonAuthRoutes = nonAuthenticatedRoutes.map((route) => ({
|
||||
...route,
|
||||
element: <LocationLeaker>{route.element}</LocationLeaker>,
|
||||
}));
|
||||
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [{ path, element: <InitialRoute /> }, ...wrappedExtraRoutes],
|
||||
},
|
||||
...wrappedNonAuthRoutes,
|
||||
],
|
||||
{ initialEntries: [route], initialIndex: 0 },
|
||||
);
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
const { result, rerender, unmount } = renderHook<Result, Props>(render, {
|
||||
...renderOptions,
|
||||
wrapper: ({ children }) => {
|
||||
currentRenderHookChildren = children;
|
||||
return (
|
||||
<AppProviders queryClient={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</AppProviders>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This is necessary to get around some providers in AppProviders having
|
||||
* conditional rendering and not always rendering their children immediately.
|
||||
*
|
||||
* renderHook's result won't actually exist until the children defined via its
|
||||
* wrapper render in full.
|
||||
*
|
||||
* Ignore result.current's type signature; it lies to you. This is normally a
|
||||
* good thing, because the renderHook result will usually evaluate
|
||||
* synchronously, so by the time you get the result back, you won't have to
|
||||
* worry about null checks. But because we're setting things up async,
|
||||
* result.current will be null for at least some period of time
|
||||
*/
|
||||
await waitFor(() => expect(result.current).not.toBe(null));
|
||||
|
||||
return {
|
||||
result,
|
||||
queryClient,
|
||||
unmount,
|
||||
rerender: async (newProps) => {
|
||||
const currentPathname = currentLocation.pathname;
|
||||
if (currentPathname !== path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultSnapshot = result.current;
|
||||
rerender(newProps);
|
||||
forceUpdateRenderHookChildren();
|
||||
return waitFor(() => expect(result.current).not.toBe(resultSnapshot));
|
||||
},
|
||||
getLocationSnapshot: () => {
|
||||
return {
|
||||
pathname: currentLocation.pathname,
|
||||
search: new URLSearchParams(currentLocation.search),
|
||||
state: currentLocation.state,
|
||||
};
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import {
|
||||
render as tlRender,
|
||||
render as testingLibraryRender,
|
||||
screen,
|
||||
waitFor,
|
||||
renderHook,
|
||||
} from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient } from "react-query";
|
||||
import {
|
||||
createMemoryRouter,
|
||||
@@ -19,7 +18,7 @@ import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSetti
|
||||
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
|
||||
import { MockUser } from "./entities";
|
||||
|
||||
function createTestQueryClient() {
|
||||
export function createTestQueryClient() {
|
||||
// Helps create one query client for each test case, to make sure that tests
|
||||
// are isolated and can't affect each other
|
||||
return new QueryClient({
|
||||
@@ -40,7 +39,7 @@ export const renderWithRouter = (
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return {
|
||||
...tlRender(
|
||||
...testingLibraryRender(
|
||||
<AppProviders queryClient={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</AppProviders>,
|
||||
@@ -105,79 +104,6 @@ export function renderWithAuth(
|
||||
};
|
||||
}
|
||||
|
||||
type RenderHookWithAuthOptions<Props> = Partial<
|
||||
Readonly<
|
||||
Omit<RenderWithAuthOptions, "children"> & {
|
||||
initialProps: Props;
|
||||
}
|
||||
>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Custom version of renderHook that is aware of all our App providers.
|
||||
*
|
||||
* Had to do some nasty, cursed things in the implementation to make sure that
|
||||
* the tests using this function remained simple.
|
||||
*
|
||||
* @see {@link https://github.com/coder/coder/pull/10362#discussion_r1380852725}
|
||||
*/
|
||||
export async function renderHookWithAuth<Result, Props>(
|
||||
render: (initialProps: Props) => Result,
|
||||
options: RenderHookWithAuthOptions<Props> = {},
|
||||
) {
|
||||
const { initialProps, path = "/", route = "/", extraRoutes = [] } = options;
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
// Easy to miss – there's an evil definite assignment via the !
|
||||
let escapedRouter!: ReturnType<typeof createMemoryRouter>;
|
||||
|
||||
const { result, rerender, unmount } = renderHook(render, {
|
||||
initialProps,
|
||||
wrapper: ({ children }) => {
|
||||
/**
|
||||
* Unfortunately, there isn't a way to define the router outside the
|
||||
* wrapper while keeping it aware of children, meaning that we need to
|
||||
* define the router as readonly state in the component instance. This
|
||||
* ensures the value remains stable across all re-renders
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- This is actually processed as a component; the linter just isn't aware of that
|
||||
const [readonlyStatefulRouter] = useState(() => {
|
||||
return createMemoryRouter(
|
||||
[{ path, element: <>{children}</> }, ...extraRoutes],
|
||||
{ initialEntries: [route] },
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Leaks the wrapper component's state outside React's render cycles.
|
||||
*/
|
||||
escapedRouter = readonlyStatefulRouter;
|
||||
|
||||
return (
|
||||
<AppProviders queryClient={queryClient}>
|
||||
<RouterProvider router={readonlyStatefulRouter} />
|
||||
</AppProviders>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This is necessary to get around some providers in AppProviders having
|
||||
* conditional rendering and not always rendering their children immediately.
|
||||
*
|
||||
* The hook result won't actually exist until the children defined via wrapper
|
||||
* render in full.
|
||||
*/
|
||||
await waitFor(() => expect(result.current).not.toBe(null));
|
||||
|
||||
return {
|
||||
result,
|
||||
rerender,
|
||||
unmount,
|
||||
router: escapedRouter,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function renderWithTemplateSettingsLayout(
|
||||
element: JSX.Element,
|
||||
{
|
||||
@@ -264,7 +190,7 @@ export const waitForLoaderToBeRemoved = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
export const renderComponent = (component: React.ReactElement) => {
|
||||
return tlRender(component, {
|
||||
return testingLibraryRender(component, {
|
||||
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user