From b95c0d30d9fa2d92e422746e01a806eec692cccf Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Fri, 14 Jun 2024 12:50:49 -0400 Subject: [PATCH] Update readme and storybook (#27) --- .github/workflows/release.yml | 20 +- .storybook/stories/ApolloComponent.stories.ts | 16 +- .storybook/stories/RelayComponent.stories.ts | 15 +- .storybook/stories/Welcome.mdx | 73 ++++++- .../apollo-client/ApolloComponent.tsx | 74 ++++++- .../components/relay/RelayComponent.tsx | 184 +++++------------- .../RelayComponentAppQuery.graphql.ts | 62 +++--- ...RelayComponentWithDeferAppQuery.graphql.ts | 162 +++++++++++++++ .../components/relay/relay-environment.ts | 112 +++++++++++ README.md | 38 ++-- package.json | 7 +- src/__tests__/handlers.test.tsx | 6 +- src/handlers.ts | 10 +- 13 files changed, 550 insertions(+), 229 deletions(-) create mode 100644 .storybook/stories/components/relay/__generated__/RelayComponentWithDeferAppQuery.graphql.ts create mode 100644 .storybook/stories/components/relay/relay-environment.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dc9b78..4f8fe3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,13 +54,13 @@ jobs: version: 9 run_install: true - # - name: Create release PR or publish to npm + GitHub - # id: changesets - # if: steps.check_files.outputs.files_exists == 'false' - # uses: changesets/action@v1 - # with: - # version: pnpm run changeset-version - # publish: pnpm run changeset-publish - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create release PR or publish to npm + GitHub + id: changesets + if: steps.check_files.outputs.files_exists == 'false' + uses: changesets/action@v1 + with: + version: pnpm run changeset-version + publish: pnpm run changeset-publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.storybook/stories/ApolloComponent.stories.ts b/.storybook/stories/ApolloComponent.stories.ts index 449381c..4f7fdaf 100644 --- a/.storybook/stories/ApolloComponent.stories.ts +++ b/.storybook/stories/ApolloComponent.stories.ts @@ -1,14 +1,18 @@ import type { Meta, StoryObj } from "@storybook/react"; import { within, expect, waitFor } from "@storybook/test"; -import ApolloComponent from "./components/apollo-client/ApolloComponent.js"; +import { + ApolloApp, + ApolloAppWithDefer as AppWithDefer, +} from "./components/apollo-client/ApolloComponent.js"; import { createHandler } from "../../src/handlers"; import { schemaWithMocks } from "../../src/__tests__/mocks/handlers"; +import { Canvas } from "@storybook/blocks"; const { handler } = createHandler(schemaWithMocks); const meta = { - title: "Example/ApolloComponent", - component: ApolloComponent, + title: "Example/Apollo", + component: ApolloApp, parameters: { layout: "centered", msw: { @@ -17,13 +21,15 @@ const meta = { }, }, }, -} satisfies Meta; +} satisfies Meta; export default meta; +export { AppWithDefer }; + type Story = StoryObj; -export const Primary: Story = { +export const App: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( diff --git a/.storybook/stories/RelayComponent.stories.ts b/.storybook/stories/RelayComponent.stories.ts index 638d0f9..b8d4f44 100644 --- a/.storybook/stories/RelayComponent.stories.ts +++ b/.storybook/stories/RelayComponent.stories.ts @@ -1,14 +1,17 @@ import type { Meta, StoryObj } from "@storybook/react"; import { within, expect, waitFor } from "@storybook/test"; -import RelayComponent from "./components/relay/RelayComponent.js"; +import { + RelayApp, + RelayAppWithDefer as AppWithDefer, +} from "./components/relay/RelayComponent.js"; import { createHandler } from "../../src/handlers"; import { schemaWithMocks } from "../../src/__tests__/mocks/handlers"; const { handler } = createHandler(schemaWithMocks); const meta = { - title: "Example/RelayComponent", - component: RelayComponent, + title: "Example/Relay", + component: RelayApp, parameters: { layout: "centered", msw: { @@ -17,13 +20,15 @@ const meta = { }, }, }, -} satisfies Meta; +} satisfies Meta; export default meta; +export { AppWithDefer }; + type Story = StoryObj; -export const Primary: Story = { +export const App: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( diff --git a/.storybook/stories/Welcome.mdx b/.storybook/stories/Welcome.mdx index f04305e..3977568 100644 --- a/.storybook/stories/Welcome.mdx +++ b/.storybook/stories/Welcome.mdx @@ -2,8 +2,75 @@ import { Meta } from "@storybook/blocks"; -# GraphQL Testing Library +
+

GraphQL Testing Library

-GraphQL Testing Library provides generic utilities for creating [Mock Service Worker](https://mswjs.io/) handlers for any GraphQL API. MSW is also the [Testing Library-recommended](https://testing-library.com/docs/react-testing-library/example-intro/#full-example) way to declaratively mock API communication in your tests without stubbing `window.fetch`. + {/* Apollo Client */} -> This project is not affiliated with the ["Testing Library"](https://github.com/testing-library) ecosystem that this project is clearly inspired from. +

Testing utilities that encourage good practices for apps built with GraphQL.

+ +
+
+ +**GraphQL Testing Library** provides utilities that make it easy to generate [Mock Service Worker](https://mswjs.io/) handlers for any GraphQL API. + +MSW is the [Testing Library-recommended](https://testing-library.com/docs/react-testing-library/example-intro/#full-example) way to declaratively mock API communication in your tests without stubbing `window.fetch`. + +This library currently supports incremental delivery features `@defer` and `@stream` out of the box, with plans to support subscriptions over multipart HTTP as well as other transports such as WebSockets, [currently in beta in MSW](https://github.com/mswjs/msw/discussions/2010). + +> This project is not affiliated with the ["Testing Library"](https://github.com/testing-library) ecosystem that inspired it. We're just fans :) + + +## Installation + +This library has `peerDependencies` listings for `msw` at `^2.0.0` and `graphql` at `^15.0.0 || ^16.0.0`. Install them along with this library using your preferred package manager: + +``` +npm install --save-dev @apollo/graphql-testing-library msw graphql +pnpm add --save-dev @apollo/graphql-testing-library msw graphql +yarn add --dev @apollo/graphql-testing-library msw graphql +bun add --dev @apollo/graphql-testing-library msw graphql +``` + +## Usage + +### `createHandler` + +```typescript +import { createHandler } from "@apollo/graphql-testing-library"; + +// We suggest using @graphql-tools/mock and @graphql-tools/schema +// to create a schema with mock resolvers. +// See https://the-guild.dev/graphql/tools/docs/mocking for more info. +import { addMocksToSchema } from "@graphql-tools/mock"; +import { makeExecutableSchema } from "@graphql-tools/schema"; +import typeDefs from "./schema.graphql"; + +// Create an executable schema +const schema = makeExecutableSchema({ typeDefs }); + +// Add mock resolvers +const schemaWithMocks = addMocksToSchema({ + schema, + resolvers: { + Query: { + products: () => + Array.from({ length: 5 }, (_element, id) => ({ + id: `product-${id}`, + })), + }, + }, +}); + +// `createHandler` returns an object with `handler` and `replaceSchema` +// functions: `handler` is a MSW handler that will intercept all GraphQL +// operations, and `replaceSchema` allows you to replace the mock schema +// the `handler` use to resolve requests against. +const { handler, replaceSchema } = createHandler(schemaWithMocks, { + // It accepts a config object as the second argument where you can specify a + // delay min and max, which will add random delays to your tests within the / + // threshold to simulate a real network connection. + // Default: delay: { min: 300, max: 300 } + delay: { min: 200, max: 500 }, +}); +``` diff --git a/.storybook/stories/components/apollo-client/ApolloComponent.tsx b/.storybook/stories/components/apollo-client/ApolloComponent.tsx index 3644071..c414727 100644 --- a/.storybook/stories/components/apollo-client/ApolloComponent.tsx +++ b/.storybook/stories/components/apollo-client/ApolloComponent.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, type ReactNode } from "react"; import type { TypedDocumentNode } from "@apollo/client"; import { gql, @@ -9,8 +9,8 @@ import { HttpLink, useSuspenseQuery, } from "@apollo/client"; -import { Container } from "../Container.js"; import { Product, Reviews } from "../Product.js"; +import { Container } from "../Container.js"; const httpLink = new HttpLink({ uri: "https://main--hack-the-e-commerce.apollographos.net/graphql", @@ -25,7 +25,7 @@ export const makeClient = () => export const client = makeClient(); -const QUERY: TypedDocumentNode<{ +const APP_QUERY: TypedDocumentNode<{ products: { id: string; title: string; @@ -33,6 +33,30 @@ const QUERY: TypedDocumentNode<{ description: string; reviews: Array<{ rating: number; id: string; content: string }>; }[]; +}> = gql` + query AppQuery { + products { + id + reviews { + id + rating + content + } + title + mediaUrl + description + } + } +`; + +const APP_QUERY_WITH_DEFER: TypedDocumentNode<{ + products: { + id: string; + title: string; + mediaUrl: string; + description: string; + reviews?: Array<{ rating: number; id: string; content: string }>; + }[]; }> = gql` query AppQuery { products { @@ -51,18 +75,34 @@ const QUERY: TypedDocumentNode<{ } `; -export default function App() { +function Wrapper({ children }: { children: ReactNode }) { return ( - Loading...}> -
- + Loading...}>{children} ); } -export function Main() { - const { data } = useSuspenseQuery(QUERY); +export function ApolloApp() { + return ( + + + + ); +} + +export function ApolloAppWithDefer() { + return ( + + + + ); +} + +export function App() { + // Use useSuspenseQuery here because we want to demo the loading experience + // with/without defer. + const { data } = useSuspenseQuery(APP_QUERY); return ( @@ -74,3 +114,19 @@ export function Main() { ); } + +export function AppWithDefer() { + // Use useSuspenseQuery here because we want to demo the loading experience + // with/without defer. + const { data } = useSuspenseQuery(APP_QUERY_WITH_DEFER); + + return ( + + {data.products.map((product) => ( + + + + ))} + + ); +} diff --git a/.storybook/stories/components/relay/RelayComponent.tsx b/.storybook/stories/components/relay/RelayComponent.tsx index 4327457..a54f5b2 100644 --- a/.storybook/stories/components/relay/RelayComponent.tsx +++ b/.storybook/stories/components/relay/RelayComponent.tsx @@ -1,134 +1,19 @@ -import { Suspense } from "react"; +import { Suspense, type ReactNode } from "react"; import type { RelayComponentAppQuery } from "./__generated__/RelayComponentAppQuery.graphql.js"; import { RelayEnvironmentProvider, - loadQuery, useFragment, - usePreloadedQuery, + useLazyLoadQuery, } from "react-relay"; -import { serializeFetchParameter } from "@apollo/client"; -import type { CacheConfig, RequestParameters } from "relay-runtime"; -import { - Environment, - Network, - Observable, - RecordSource, - Store, - graphql, - QueryResponseCache, -} from "relay-runtime"; -import type { Variables } from "relay-runtime"; - -import { maybe } from "@apollo/client/utilities"; -import { - handleError, - readMultipartBody, -} from "@apollo/client/link/http/parseAndCheckHttpResponse"; +import { graphql } from "relay-runtime"; import { Container } from "../Container.js"; import { Product, Reviews as ReviewsContainer } from "../Product.js"; +import { RelayEnvironment } from "./relay-environment.js"; -const uri = "https://main--hack-the-e-commerce.apollographos.net/graphql"; - -const oneMinute = 60 * 1000; -const cache = new QueryResponseCache({ size: 250, ttl: oneMinute }); - -const backupFetch = maybe(() => fetch); - -function fetchQuery( - operation: RequestParameters, - variables: Variables, - cacheConfig: CacheConfig -) { - const queryID = operation.text; - const isMutation = operation.operationKind === "mutation"; - const isQuery = operation.operationKind === "query"; - const forceFetch = cacheConfig && cacheConfig.force; - - // Try to get data from cache on queries - const fromCache = cache.get(queryID, variables); - if (isQuery && fromCache !== null && !forceFetch) { - return fromCache; - } - - const body = { - operationName: operation.name, - variables, - query: operation.text || "", - }; - - const options: { - method: string; - headers: Record; - body?: string; - } = { - method: "POST", - headers: { - "Content-Type": "application/json", - accept: "multipart/mixed;deferSpec=20220824,application/json", - }, - }; - - return Observable.create((sink) => { - try { - options.body = serializeFetchParameter(body, "Payload"); - } catch (parseError) { - sink.error(parseError as Error); - } - - const currentFetch = maybe(() => fetch) || backupFetch; - - const observerNext = (data) => { - if ("incremental" in data) { - for (const item of data.incremental) { - sink.next(item); - } - } else if ("data" in data) { - sink.next(data); - } - }; - - currentFetch!(uri, options) - .then(async (response) => { - const ctype = response.headers?.get("content-type"); - - if (ctype !== null && /^multipart\/mixed/i.test(ctype)) { - return readMultipartBody(response, observerNext); - } else { - const json = await response.json(); - - if (isQuery && json) { - cache.set(queryID, variables, json); - } - // Clear cache on mutations - if (isMutation) { - cache.clear(); - } - - observerNext(json); - } - }) - .then(() => { - sink.complete(); - }) - .catch((err: any) => { - handleError(err, sink); - }); - }); -} - -const network = Network.create(fetchQuery); - -export const RelayEnvironment = new Environment({ - network, - store: new Store(new RecordSource()), -}); - -export default function App() { +export function Wrapper({ children }: { children: ReactNode }) { return ( - Loading...}> -
- + Loading...}>{children} ); } @@ -145,6 +30,18 @@ const ratingsFragment = graphql` const appQuery = graphql` query RelayComponentAppQuery { + products { + id + ...RelayComponentReviewsFragment_product + title + mediaUrl + description + } + } +`; + +const appQueryWithDefer = graphql` + query RelayComponentWithDeferAppQuery { products { id ...RelayComponentReviewsFragment_product @defer @@ -155,17 +52,44 @@ const appQuery = graphql` } `; -const queryReference = loadQuery( - RelayEnvironment, - appQuery, - {} -); +export function RelayApp() { + return ( + + + + ); +} -function Main() { - const data = usePreloadedQuery( - appQuery, - queryReference +export function RelayAppWithDefer() { + return ( + + + ); +} + +function App() { + // Use useLazyLoadQuery here because we want to demo the loading experience + // with/without defer. + const data = useLazyLoadQuery(appQuery, {}); + + return ( + + {data?.products?.map((product) => ( + + + + + + ))} + + ); +} + +function AppWithDefer() { + // Use useLazyLoadQuery here because we want to demo the loading experience + // with/without defer. + const data = useLazyLoadQuery(appQueryWithDefer, {}); return ( diff --git a/.storybook/stories/components/relay/__generated__/RelayComponentAppQuery.graphql.ts b/.storybook/stories/components/relay/__generated__/RelayComponentAppQuery.graphql.ts index f73e0ec..8655db4 100644 --- a/.storybook/stories/components/relay/__generated__/RelayComponentAppQuery.graphql.ts +++ b/.storybook/stories/components/relay/__generated__/RelayComponentAppQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -71,14 +71,9 @@ return { "selections": [ (v0/*: any*/), { - "kind": "Defer", - "selections": [ - { - "args": null, - "kind": "FragmentSpread", - "name": "RelayComponentReviewsFragment_product" - } - ] + "args": null, + "kind": "FragmentSpread", + "name": "RelayComponentReviewsFragment_product" }, (v1/*: any*/), (v2/*: any*/), @@ -106,37 +101,30 @@ return { "selections": [ (v0/*: any*/), { - "if": null, - "kind": "Defer", - "label": "RelayComponentAppQuery$defer$RelayComponentReviewsFragment_product", + "alias": null, + "args": null, + "concreteType": "Review", + "kind": "LinkedField", + "name": "reviews", + "plural": true, "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "rating", + "storageKey": null + }, { "alias": null, "args": null, - "concreteType": "Review", - "kind": "LinkedField", - "name": "reviews", - "plural": true, - "selections": [ - (v0/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "rating", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "content", - "storageKey": null - } - ], + "kind": "ScalarField", + "name": "content", "storageKey": null } - ] + ], + "storageKey": null }, (v1/*: any*/), (v2/*: any*/), @@ -147,16 +135,16 @@ return { ] }, "params": { - "cacheID": "d8a71936337a80402d5cddfb525ec135", + "cacheID": "3b9c3b8cd3c59679be38e6d0f8dfc557", "id": null, "metadata": {}, "name": "RelayComponentAppQuery", "operationKind": "query", - "text": "query RelayComponentAppQuery {\n products {\n id\n ...RelayComponentReviewsFragment_product @defer(label: \"RelayComponentAppQuery$defer$RelayComponentReviewsFragment_product\")\n title\n mediaUrl\n description\n }\n}\n\nfragment RelayComponentReviewsFragment_product on Product {\n reviews {\n id\n rating\n content\n }\n}\n" + "text": "query RelayComponentAppQuery {\n products {\n id\n ...RelayComponentReviewsFragment_product\n title\n mediaUrl\n description\n }\n}\n\nfragment RelayComponentReviewsFragment_product on Product {\n reviews {\n id\n rating\n content\n }\n}\n" } }; })(); -(node as any).hash = "34db6443c113b6471fea59a54e8029e9"; +(node as any).hash = "fe50ea1f5ee94d4806b295f629910cd4"; export default node; diff --git a/.storybook/stories/components/relay/__generated__/RelayComponentWithDeferAppQuery.graphql.ts b/.storybook/stories/components/relay/__generated__/RelayComponentWithDeferAppQuery.graphql.ts new file mode 100644 index 0000000..1efce97 --- /dev/null +++ b/.storybook/stories/components/relay/__generated__/RelayComponentWithDeferAppQuery.graphql.ts @@ -0,0 +1,162 @@ +/** + * @generated SignedSource<<67e68fd02a277ad442924bac7db281d6>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest, Query } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type RelayComponentWithDeferAppQuery$variables = Record; +export type RelayComponentWithDeferAppQuery$data = { + readonly products: ReadonlyArray<{ + readonly description: string | null | undefined; + readonly id: string; + readonly mediaUrl: string | null | undefined; + readonly title: string | null | undefined; + readonly " $fragmentSpreads": FragmentRefs<"RelayComponentReviewsFragment_product">; + } | null | undefined> | null | undefined; +}; +export type RelayComponentWithDeferAppQuery = { + response: RelayComponentWithDeferAppQuery$data; + variables: RelayComponentWithDeferAppQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "title", + "storageKey": null +}, +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "mediaUrl", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "description", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "RelayComponentWithDeferAppQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Product", + "kind": "LinkedField", + "name": "products", + "plural": true, + "selections": [ + (v0/*: any*/), + { + "kind": "Defer", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "RelayComponentReviewsFragment_product" + } + ] + }, + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/) + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "RelayComponentWithDeferAppQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Product", + "kind": "LinkedField", + "name": "products", + "plural": true, + "selections": [ + (v0/*: any*/), + { + "if": null, + "kind": "Defer", + "label": "RelayComponentWithDeferAppQuery$defer$RelayComponentReviewsFragment_product", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Review", + "kind": "LinkedField", + "name": "reviews", + "plural": true, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "rating", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "content", + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/) + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "5d0a1e385798573ffda670a713f233e4", + "id": null, + "metadata": {}, + "name": "RelayComponentWithDeferAppQuery", + "operationKind": "query", + "text": "query RelayComponentWithDeferAppQuery {\n products {\n id\n ...RelayComponentReviewsFragment_product @defer(label: \"RelayComponentWithDeferAppQuery$defer$RelayComponentReviewsFragment_product\")\n title\n mediaUrl\n description\n }\n}\n\nfragment RelayComponentReviewsFragment_product on Product {\n reviews {\n id\n rating\n content\n }\n}\n" + } +}; +})(); + +(node as any).hash = "191fcdc886b10cd48ed92df8b781b5c0"; + +export default node; diff --git a/.storybook/stories/components/relay/relay-environment.ts b/.storybook/stories/components/relay/relay-environment.ts new file mode 100644 index 0000000..aeacd29 --- /dev/null +++ b/.storybook/stories/components/relay/relay-environment.ts @@ -0,0 +1,112 @@ +import { serializeFetchParameter } from "@apollo/client"; +import type { CacheConfig, RequestParameters } from "relay-runtime"; +import { + Environment, + Network, + Observable, + RecordSource, + Store, + QueryResponseCache, +} from "relay-runtime"; +import type { Variables } from "relay-runtime"; +import { maybe } from "@apollo/client/utilities"; +import { + handleError, + readMultipartBody, +} from "@apollo/client/link/http/parseAndCheckHttpResponse"; + +const uri = "https://main--hack-the-e-commerce.apollographos.net/graphql"; + +const oneMinute = 60 * 1000; +const cache = new QueryResponseCache({ size: 250, ttl: oneMinute }); + +const backupFetch = maybe(() => fetch); + +function fetchQuery( + operation: RequestParameters, + variables: Variables, + cacheConfig: CacheConfig +) { + const queryID = operation.text; + const isMutation = operation.operationKind === "mutation"; + const isQuery = operation.operationKind === "query"; + const forceFetch = cacheConfig && cacheConfig.force; + + // Try to get data from cache on queries + const fromCache = cache.get(queryID, variables); + if (isQuery && fromCache !== null && !forceFetch) { + return fromCache; + } + + const body = { + operationName: operation.name, + variables, + query: operation.text || "", + }; + + const options: { + method: string; + headers: Record; + body?: string; + } = { + method: "POST", + headers: { + "Content-Type": "application/json", + accept: "multipart/mixed;deferSpec=20220824,application/json", + }, + }; + + return Observable.create((sink) => { + try { + options.body = serializeFetchParameter(body, "Payload"); + } catch (parseError) { + sink.error(parseError as Error); + } + + const currentFetch = maybe(() => fetch) || backupFetch; + + const observerNext = (data) => { + if ("incremental" in data) { + for (const item of data.incremental) { + sink.next(item); + } + } else if ("data" in data) { + sink.next(data); + } + }; + + currentFetch!(uri, options) + .then(async (response) => { + const ctype = response.headers?.get("content-type"); + + if (ctype !== null && /^multipart\/mixed/i.test(ctype)) { + return readMultipartBody(response, observerNext); + } else { + const json = await response.json(); + + if (isQuery && json) { + cache.set(queryID, variables, json); + } + // Clear cache on mutations + if (isMutation) { + cache.clear(); + } + + observerNext(json); + } + }) + .then(() => { + sink.complete(); + }) + .catch((err: any) => { + handleError(err, sink); + }); + }); +} + +const network = Network.create(fetchQuery); + +export const RelayEnvironment = new Environment({ + network, + store: new Store(new RecordSource()), +}); \ No newline at end of file diff --git a/README.md b/README.md index 8a2280e..f986a0f 100644 --- a/README.md +++ b/README.md @@ -12,30 +12,20 @@ MSW is the [Testing Library-recommended](https://testing-library.com/docs/react-testing-library/example-intro/#full-example) way to declaratively mock API communication in your tests without stubbing `window.fetch`. -This library currently supports incremental delivery features `@defer` and `@stream` out of the box, with plans to add support for subscriptions over multipart HTTP and possibly WebSockets which are [currently in beta in MSW](https://github.com/mswjs/msw/discussions/2010). +This library currently supports incremental delivery features `@defer` and `@stream` out of the box, with plans to support subscriptions over multipart HTTP as well as other transports such as WebSockets, [currently in beta in MSW](https://github.com/mswjs/msw/discussions/2010). > This project is not affiliated with the ["Testing Library"](https://github.com/testing-library) ecosystem that inspired it. We're just fans :) ## Installation -``` -npm install --save-dev @apollo/graphql-testing-library -``` - -or for installation via yarn - -``` -yarn add --dev @apollo/graphql-testing-library -``` - -This library has `peerDependencies` listings for `msw` at `^2.0.0` and `graphql` at `^15.0.0 || ^16.0.0`. +This library has `peerDependencies` listings for `msw` at `^2.0.0` and `graphql` at `^15.0.0 || ^16.0.0`. Install them along with this library using your preferred package manager: ``` -npm install --save-dev msw graphql - - -yarn add --dev msw graphql +npm install --save-dev @apollo/graphql-testing-library msw graphql +pnpm add --save-dev @apollo/graphql-testing-library msw graphql +yarn add --dev @apollo/graphql-testing-library msw graphql +bun add --dev @apollo/graphql-testing-library msw graphql ``` ## Usage @@ -44,6 +34,10 @@ yarn add --dev msw graphql ```typescript import { createHandler } from "@apollo/graphql-testing-library"; + +// We suggest using @graphql-tools/mock and @graphql-tools/schema +// to create a schema with mock resolvers. +// See https://the-guild.dev/graphql/tools/docs/mocking for more info. import { addMocksToSchema } from "@graphql-tools/mock"; import { makeExecutableSchema } from "@graphql-tools/schema"; import typeDefs from "./schema.graphql"; @@ -64,11 +58,15 @@ const schemaWithMocks = addMocksToSchema({ }, }); -// createHandler returns an object with `handler` and `replaceSchema` -// functions: `handler` is your MSW handler, and `replaceSchema` can -// be used in tests to pass a new `schemaWithMocks` that your `handler` -// will use to resolve requests against +// `createHandler` returns an object with `handler` and `replaceSchema` +// functions: `handler` is a MSW handler that will intercept all GraphQL +// operations, and `replaceSchema` allows you to replace the mock schema +// the `handler` use to resolve requests against. const { handler, replaceSchema } = createHandler(schemaWithMocks, { + // It accepts a config object as the second argument where you can specify a + // delay min and max, which will add random delays to your tests within the / + // threshold to simulate a real network connection. + // Default: delay: { min: 300, max: 300 } delay: { min: 200, max: 500 }, }); ``` diff --git a/package.json b/package.json index f31afde..3fc0b9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/graphql-testing-library", - "version": "0.1.0", + "version": "0.0.0", "private": true, "repository": { "url": "git+https://github.com/apollographql/graphql-testing-library" @@ -33,10 +33,11 @@ "types": "./dist/index.d.ts", "scripts": { "build": "tsup", + "clean": "rm -rf dist", "prepdist:changesets": "node scripts/prepareDist.cjs", - "changeset-publish": "npm run build && npm run prepdist:changesets && cd dist && changeset publish", - "changeset-check": "changeset status --verbose --since=origin/main", + "changeset-publish": "pnpm run clean && pnpm run build && pnpm run prepdist:changesets && cd dist && changeset publish", "changeset-version": "changeset version && pnpm i", + "changeset-check": "changeset status --verbose --since=origin/main", "test": "jest", "relay": "relay-compiler", "lint": "eslint --ext .ts src", diff --git a/src/__tests__/handlers.test.tsx b/src/__tests__/handlers.test.tsx index 837f046..36fa285 100644 --- a/src/__tests__/handlers.test.tsx +++ b/src/__tests__/handlers.test.tsx @@ -1,7 +1,7 @@ import { ApolloProvider } from "@apollo/client"; import { Suspense } from "react"; import { - Main, + AppWithDefer, makeClient, } from "../../.storybook/stories/components/apollo-client/ApolloComponent.tsx"; import { addMocksToSchema } from "@graphql-tools/mock"; @@ -18,7 +18,7 @@ describe("integration tests", () => { render( Loading...}> -
+ ); @@ -73,7 +73,7 @@ describe("integration tests", () => { render( Loading...}> -
+ ); diff --git a/src/handlers.ts b/src/handlers.ts index 3bd7a4c..1c3eba5 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -131,15 +131,16 @@ export const createHandler = ( const stream = new ReadableStream({ async start(controller) { try { - for (const c of chunks) { + for (const chunk of chunks) { if (delayMin > 0) { const randomDelay = Math.random() * (delayMax - delayMin) + delayMin; - if (c === boundary) { + + if (chunk === boundary || chunk === terminatingBoundary) { await wait(randomDelay); } } - controller.enqueue(encoder.encode(c)); + controller.enqueue(encoder.encode(chunk)); } } finally { controller.close(); @@ -159,7 +160,8 @@ export const createHandler = ( schema: testSchema, variableValues: variables, }); - + const randomDelay = Math.random() * (delayMax - delayMin) + delayMin; + await wait(randomDelay); return HttpResponse.json(result as SingularExecutionResult); } }),