Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: testing helpers #4539

Merged
merged 38 commits into from Dec 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
47d00dd
feat: testing helpers
mcansh Nov 2, 2022
3f9e0a6
chore: use experimental
mcansh Nov 4, 2022
1756442
chore: update imports to @remix-run/server-runtime/dist/router
mcansh Nov 4, 2022
0a892ad
Revert "chore: update imports to @remix-run/server-runtime/dist/router"
mcansh Nov 4, 2022
60818b9
Revert "chore: use experimental"
mcansh Nov 4, 2022
2230d59
chore: remove deep imports
mcansh Nov 7, 2022
1de9bb1
chore: move router to deps until remix uses 6.4 internally
mcansh Nov 7, 2022
e44ee01
chore: explicit exports
mcansh Nov 7, 2022
9e6244e
nested route data
mcansh Nov 8, 2022
d071451
chore: import from server-runtime/router
mcansh Nov 8, 2022
52b7d6c
chore: script yalc store publishing
mcansh Nov 8, 2022
bce671f
chore: use experimental, publish all packages to yalc store
mcansh Nov 8, 2022
62cf0ab
imports
mcansh Nov 10, 2022
3d0684f
rebase on document-request-experimental
mcansh Nov 10, 2022
e73427a
chore: remove @remix-run/router
mcansh Nov 14, 2022
f9a005d
chore: yalc use push
mcansh Nov 16, 2022
e621712
chore: update version
mcansh Nov 16, 2022
d9a9ba2
add extended type with element
mcansh Nov 16, 2022
1edd2bf
chore: add "future" key to context
mcansh Nov 16, 2022
ec217ec
chore: convert routes
mcansh Nov 16, 2022
e02e1fe
Revert: chore: convert routes
mcansh Nov 17, 2022
0a1d787
Merge branch 'dev' into logan/testing-helpers
mcansh Nov 17, 2022
1744b2d
Merge branch 'dev' into logan/testing-helpers
mcansh Nov 18, 2022
b262e20
chore: allow setting remix config "future"
mcansh Nov 22, 2022
5ec3e58
fix: enable v5Compat for createMemoryHistory so listener is called
mcansh Nov 22, 2022
e6e1c94
chore: remove log
mcansh Nov 22, 2022
9748dbc
chore: mark unstable
mcansh Nov 22, 2022
b22c8d6
chore: mark unstable
mcansh Nov 22, 2022
11025b9
chore: revert
mcansh Nov 22, 2022
2201c47
chore: make element on RouteObject optional
mcansh Nov 29, 2022
1e5d58e
chore: update export
mcansh Nov 29, 2022
7814544
chore: rename custom RouteObject to StubRouteObject
mcansh Nov 29, 2022
99b5566
chore: update comment
mcansh Nov 29, 2022
fd9e36a
chore: use path.join in rollup config
mcansh Nov 29, 2022
a569840
Create poor-shrimps-boil.md
mcansh Nov 29, 2022
2185293
feat: add installGlobals that adds jsdom's FormData to global
mcansh Nov 29, 2022
657b0ac
chore: add happy-dom as a possible framework, but throw immediately
mcansh Nov 29, 2022
65bda9d
chore: default to jsdom
mcansh Nov 29, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/poor-shrimps-boil.md
@@ -0,0 +1,9 @@
---
"remix": patch
"@remix-run/react": patch
"@remix-run/serve": patch
"@remix-run/server-runtime": patch
"@remix-run/testing": patch
---

adds a new testing package to allow easier testing of components using Remix specific apis like useFetcher, useActionData, etc.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -18,6 +18,7 @@
"packages/remix-react",
"packages/remix-serve",
"packages/remix-server-runtime",
"packages/remix-testing",
"packages/remix-vercel"
],
"scripts": {
Expand Down
12 changes: 11 additions & 1 deletion packages/remix-react/index.tsx
Expand Up @@ -35,6 +35,7 @@ export {
Link,
NavLink,
Form,
RemixEntry,
PrefetchPageLinks,
LiveReload,
useFormAction,
Expand All @@ -54,11 +55,20 @@ export type { ThrownResponse } from "./errors";
export { useCatch } from "./errorBoundaries";

export type { HtmlLinkDescriptor } from "./links";
export type { ShouldReloadFunction, HtmlMetaDescriptor } from "./routeModules";
export type {
ShouldReloadFunction,
HtmlMetaDescriptor,
CatchBoundaryComponent,
RouteModules,
} from "./routeModules";

export { ScrollRestoration } from "./scroll-restoration";

export type { RemixServerProps } from "./server";
export { RemixServer } from "./server";

export type { Fetcher } from "./transition";

export type { AssetsManifest, EntryContext } from "./entry";
export type { RouteData } from "./routeData";
export type { EntryRoute, RouteManifest } from "./routes";
22 changes: 20 additions & 2 deletions packages/remix-server-runtime/index.ts
Expand Up @@ -8,8 +8,8 @@ export { json, redirect } from "./responses";
export { createRequestHandler } from "./server";
export {
createSession,
isSession,
createSessionStorageFactory,
isSession,
} from "./sessions";
export { createCookieSessionStorageFactory } from "./sessions/cookieStorage";
export { createMemorySessionStorageFactory } from "./sessions/memoryStorage";
Expand Down Expand Up @@ -71,6 +71,24 @@ export type {
SignFunction,
TypedResponse,
UnsignFunction,
UploadHandlerPart,
UploadHandler,
UploadHandlerPart,
} from "./reexport";

export type {
AgnosticDataRouteObject,
AgnosticIndexRouteObject,
AgnosticNonIndexRouteObject,
AgnosticRouteObject,
InitialEntry,
Location,
MemoryHistory,
StaticHandler,
} from "./router";
export {
createMemoryHistory,
matchRoutes,
unstable_createStaticHandler,
} from "./router";
export type { Update } from "./router/history";
export type { AgnosticRouteMatch } from "./router/utils";
297 changes: 297 additions & 0 deletions packages/remix-testing/create-remix-stub.tsx
@@ -0,0 +1,297 @@
import * as React from "react";
import type {
AssetsManifest,
EntryContext,
EntryRoute,
RouteData,
RouteManifest,
RouteModules,
} from "@remix-run/react";
import { RemixEntry } from "@remix-run/react";
import type {
AgnosticDataRouteObject,
AgnosticIndexRouteObject,
AgnosticNonIndexRouteObject,
AgnosticRouteMatch,
InitialEntry,
Location,
MemoryHistory,
StaticHandler,
Update,
} from "@remix-run/server-runtime";
import {
createMemoryHistory,
json,
matchRoutes,
unstable_createStaticHandler as createStaticHandler,
} from "@remix-run/server-runtime";

type RemixStubOptions = {
/**
* The initial entries in the history stack. This allows you to start a test with
* multiple locations already in the history stack (for testing a back navigation, etc.)
* The test will default to the last entry in initialEntries if no initialIndex is provided.
* e.g. initialEntries={["/home", "/about", "/contact"]}
*/
initialEntries?: InitialEntry[];

/**
* Used to set the route's initial loader data.
* e.g. initialLoaderData={{ "/contact": { locale: "en-US" } }}
*/
initialLoaderData?: RouteData;

/**
* Used to set the route's initial action data.
* e.g. initialActionData={{ "/login": { errors: { email: "invalid email" } }}
*/
initialActionData?: RouteData;

/**
* The initial index in the history stack to render. This allows you to start a test at a specific entry.
* It defaults to the last entry in initialEntries.
* e.g.
* initialEntries: ["/", "/events/123"]
* initialIndex: 1 // start at "/events/123"
*/
initialIndex?: number;
};

type IndexStubRouteObject = AgnosticIndexRouteObject & {
element?: React.ReactNode;
children?: undefined;
};

type NonIndexStubRouteObject = AgnosticNonIndexRouteObject & {
element?: React.ReactNode;
children?: StubRouteObject[];
};

// TODO: once Remix is on RR@6.4 we can just use the native type
type StubRouteObject = IndexStubRouteObject | NonIndexStubRouteObject;

type RemixConfigFuture = Partial<EntryContext["future"]>;

export function createRemixStub(
routes: StubRouteObject[],
remixConfigFuture?: RemixConfigFuture
) {
// Setup request handler to handle requests to the mock routes
let { dataRoutes, queryRoute } = createStaticHandler(routes);
return function RemixStub({
initialEntries,
initialLoaderData = {},
initialActionData,
initialIndex,
}: RemixStubOptions) {
let historyRef = React.useRef<MemoryHistory>();
if (historyRef.current == null) {
historyRef.current = createMemoryHistory({
initialEntries,
initialIndex,
v5Compat: true,
});
}

let history = historyRef.current;

let [state, dispatch] = React.useReducer(
(_: Update, update: Update) => update,
{ action: history.action, location: history.location }
);

React.useLayoutEffect(() => history.listen(dispatch), [history]);

// Convert path based ids in user supplied initial loader/action data to data route ids
let loaderData = convertRouteData(dataRoutes, initialLoaderData);
let actionData = convertRouteData(dataRoutes, initialActionData);

// Create mock remix context
let remixContext = createRemixContext(
dataRoutes,
state.location,
loaderData,
actionData,
remixConfigFuture
);

// Patch fetch so that mock routes can handle action/loader requests
monkeyPatchFetch(queryRoute, dataRoutes);

return (
<RemixEntry
context={remixContext}
action={state.action}
location={state.location}
navigator={history}
/>
);
};
}

function createRemixContext(
routes: AgnosticDataRouteObject[],
currentLocation: Location,
initialLoaderData?: RouteData,
initialActionData?: RouteData,
future?: RemixConfigFuture
): EntryContext {
let manifest = createManifest(routes);
let matches = matchRoutes(routes, currentLocation) || [];

return {
actionData: initialActionData,
appState: {
trackBoundaries: true,
trackCatchBoundaries: true,
catchBoundaryRouteId: null,
renderBoundaryRouteId: null,
loaderBoundaryRouteId: null,
},
future: {
v2_meta: false,
...future,
},
matches: convertToEntryRouteMatch(matches),
routeData: initialLoaderData || {},
manifest,
routeModules: createRouteModules(routes),
};
}

function createManifest(routes: AgnosticDataRouteObject[]): AssetsManifest {
return {
routes: createRouteManifest(routes),
entry: { imports: [], module: "" },
url: "",
version: "",
};
}

function createRouteManifest(
routes: AgnosticDataRouteObject[],
manifest?: RouteManifest<EntryRoute>,
parentId?: string
): RouteManifest<EntryRoute> {
return routes.reduce((manifest, route) => {
if (route.children) {
createRouteManifest(route.children, manifest, route.id);
}
manifest[route.id!] = convertToEntryRoute(route, parentId);
return manifest;
}, manifest || {});
}

function createRouteModules(
routes: AgnosticDataRouteObject[],
routeModules?: RouteModules
): RouteModules {
return routes.reduce((modules, route) => {
if (route.children) {
createRouteModules(route.children, modules);
}

modules[route.id!] = {
CatchBoundary: undefined,
ErrorBoundary: undefined,
// @ts-expect-error - types are still `agnostic` here
default: () => route.element,
handle: route.handle,
links: undefined,
meta: undefined,
unstable_shouldReload: undefined,
};
return modules;
}, routeModules || {});
}

const originalFetch =
typeof global !== "undefined" ? global.fetch : window.fetch;

function monkeyPatchFetch(
queryRoute: StaticHandler["queryRoute"],
dataRoutes: StaticHandler["dataRoutes"]
) {
let fetchPatch = async (
input: RequestInfo | URL,
init: RequestInit = {}
): Promise<Response> => {
let request = new Request(input, init);
let url = new URL(request.url);

// if we have matches, send the request to mock routes via @remix-run/router rather than the normal
// @remix-run/server-runtime so that stubs can also be used in browser environments.
let matches = matchRoutes(dataRoutes, url);
if (matches && matches.length > 0) {
let response = await queryRoute(request);

if (response instanceof Response) {
return response;
}

return json(response);
}

// if no matches, passthrough to the original fetch as mock routes couldn't handle the request.
return originalFetch(request, init);
};

globalThis.fetch = fetchPatch;
}

function convertToEntryRoute(
route: AgnosticDataRouteObject,
parentId?: string
): EntryRoute {
return {
id: route.id!,
index: route.index,
caseSensitive: route.caseSensitive,
path: route.path,
parentId,
hasAction: !!route.action,
hasLoader: !!route.loader,
module: "",
hasCatchBoundary: false,
hasErrorBoundary: false,
};
}

function convertToEntryRouteMatch(
routes: AgnosticRouteMatch<string, AgnosticDataRouteObject>[]
) {
return routes.map((match) => {
return {
params: match.params,
pathname: match.pathname,
route: convertToEntryRoute(match.route),
};
});
}

// Converts route data from a path based index to a route id index value.
// e.g. { "/post/:postId": post } to { "0": post }
// TODO: may not need
function convertRouteData(
mcansh marked this conversation as resolved.
Show resolved Hide resolved
routes: AgnosticDataRouteObject[],
initialRouteData?: RouteData,
routeData: RouteData = {}
): RouteData | undefined {
if (!initialRouteData) return undefined;
return routes.reduce<RouteData>((data, route) => {
if (route.children) {
convertRouteData(route.children, initialRouteData, data);
}
// Check if any of the initial route data entries match this route
Object.keys(initialRouteData).forEach((routePath) => {
if (
routePath === route.path ||
// Let '/' refer to the root routes data
(routePath === "/" && route.id === "0" && !route.path)
) {
data[route.id!] = initialRouteData[routePath];
}
});
return data;
}, routeData);
}
2 changes: 2 additions & 0 deletions packages/remix-testing/index.ts
@@ -0,0 +1,2 @@
export { createRemixStub as unstable_createRemixStub } from "./create-remix-stub";
export { installGlobals as unstable_installGlobals } from "./install-globals";