From 3fe9e6ccf5df3ac951b8d4188462c9414c07f327 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 25 Jun 2024 16:28:52 +0100 Subject: [PATCH 1/3] test(remix): Add Shopify Hydrogen E2E test app. --- .github/workflows/build.yml | 1 + .../remix-hydrogen/.eslintignore | 5 + .../remix-hydrogen/.gitignore | 8 + .../test-applications/remix-hydrogen/.npmrc | 2 + .../remix-hydrogen/app/entry.client.tsx | 57 ++++ .../remix-hydrogen/app/entry.server.tsx | 51 +++ .../remix-hydrogen/app/lib/fragments.ts | 174 +++++++++++ .../remix-hydrogen/app/lib/search.ts | 25 ++ .../remix-hydrogen/app/lib/session.ts | 61 ++++ .../remix-hydrogen/app/lib/variants.ts | 41 +++ .../remix-hydrogen/app/root.tsx | 290 ++++++++++++++++++ .../remix-hydrogen/app/routes/_index.tsx | 29 ++ .../app/routes/action-formdata.tsx | 17 + .../app/routes/client-error.tsx | 24 ++ .../app/routes/loader-error.tsx | 16 + .../remix-hydrogen/app/routes/navigate.tsx | 20 ++ .../remix-hydrogen/app/routes/user.$id.tsx | 3 + .../remix-hydrogen/app/utils.ts | 41 +++ .../test-applications/remix-hydrogen/env.d.ts | 49 +++ .../remix-hydrogen/globals.d.ts | 7 + .../remix-hydrogen/instrument.server.mjs | 12 + .../remix-hydrogen/package.json | 58 ++++ .../remix-hydrogen/playwright.config.mjs | 7 + .../remix-hydrogen/public/favicon.svg | 28 ++ .../remix-hydrogen/server.ts | 107 +++++++ .../remix-hydrogen/start-event-proxy.mjs | 6 + .../storefrontapi.generated.d.ts | 153 +++++++++ .../tests/behaviour-client.test.ts | 192 ++++++++++++ .../tests/behaviour-server.test.ts | 158 ++++++++++ .../remix-hydrogen/tsconfig.json | 23 ++ .../remix-hydrogen/vite.config.ts | 46 +++ 31 files changed, 1711 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/search.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/session.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/variants.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/action-formdata.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/client-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/loader-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/user.$id.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/app/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/instrument.server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.svg create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/storefrontapi.generated.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 647d08b3feff..af3f5a848029 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -948,6 +948,7 @@ jobs: 'react-router-6-use-routes', 'react-router-5', 'react-router-6', + 'remix-hydrogen', 'solid', 'svelte-5', 'sveltekit', diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore new file mode 100644 index 000000000000..a362bcaa13b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore @@ -0,0 +1,5 @@ +build +node_modules +bin +*.d.ts +dist diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.gitignore b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.gitignore new file mode 100644 index 000000000000..336224ba3666 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.gitignore @@ -0,0 +1,8 @@ +node_modules +/.cache +/build +/dist +/public/build +/.mf +.env +.shopify diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.npmrc b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx new file mode 100644 index 000000000000..adb2d3960191 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx @@ -0,0 +1,57 @@ +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { StrictMode, startTransition } from 'react'; +import { useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +Sentry.init({ + dsn: window.ENV.SENTRY_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches), + }), + // Replay is only available in the client + new Sentry.Replay(), + new Sentry.BrowserProfilingIntegration(), + ], + + // Set tracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + // We recommend adjusting this value in production + tracesSampleRate: 1.0, + + // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled + tracePropagationTargets: ['localhost', /^https:\/\/yourserver\.io\/api/], + + // Capture Replay for 10% of all sessions, + // plus for 100% of sessions with an error + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + + // Capture all profiles + profilesSampleRate: 1.0, +}); + +Sentry.addEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx new file mode 100644 index 000000000000..fb1fe0fda404 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx @@ -0,0 +1,51 @@ +import * as Sentry from '@sentry/remix'; + +import { RemixServer } from '@remix-run/react'; +import { createContentSecurityPolicy } from '@shopify/hydrogen'; +import type { DataFunctionArgs, EntryContext } from '@shopify/remix-oxygen'; +import isbot from 'isbot'; +import { renderToReadableStream } from 'react-dom/server'; + +export async function handleError(error: unknown, { request }: DataFunctionArgs): Promise { + Sentry.captureRemixServerException(error, 'remix.server', request, true); +} + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + const { nonce, header, NonceProvider } = createContentSecurityPolicy(); + + const body = await renderToReadableStream( + + + , + { + nonce, + signal: request.signal, + onError(error) { + // eslint-disable-next-line no-console + console.error(error); + responseStatusCode = 500; + }, + }, + ); + + if (isbot(request.headers.get('user-agent'))) { + await body.allReady; + } + + responseHeaders.set('Content-Type', 'text/html'); + responseHeaders.set('Content-Security-Policy', header); + + // Add the document policy header to enable JS profiling + // This is required for Sentry's profiling integration + responseHeaders.set('Document-Policy', 'js-profiling'); + + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts new file mode 100644 index 000000000000..ccf430475620 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts @@ -0,0 +1,174 @@ +// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart +export const CART_QUERY_FRAGMENT = `#graphql + fragment Money on MoneyV2 { + currencyCode + amount + } + fragment CartLine on CartLine { + id + quantity + attributes { + key + value + } + cost { + totalAmount { + ...Money + } + amountPerQuantity { + ...Money + } + compareAtAmountPerQuantity { + ...Money + } + } + merchandise { + ... on ProductVariant { + id + availableForSale + compareAtPrice { + ...Money + } + price { + ...Money + } + requiresShipping + title + image { + id + url + altText + width + height + + } + product { + handle + title + id + vendor + } + selectedOptions { + name + value + } + } + } + } + fragment CartApiQuery on Cart { + updatedAt + id + checkoutUrl + totalQuantity + buyerIdentity { + countryCode + customer { + id + email + firstName + lastName + displayName + } + email + phone + } + lines(first: $numCartLines) { + nodes { + ...CartLine + } + } + cost { + subtotalAmount { + ...Money + } + totalAmount { + ...Money + } + totalDutyAmount { + ...Money + } + totalTaxAmount { + ...Money + } + } + note + attributes { + key + value + } + discountCodes { + code + applicable + } + } +` as const; + +const MENU_FRAGMENT = `#graphql + fragment MenuItem on MenuItem { + id + resourceId + tags + title + type + url + } + fragment ChildMenuItem on MenuItem { + ...MenuItem + } + fragment ParentMenuItem on MenuItem { + ...MenuItem + items { + ...ChildMenuItem + } + } + fragment Menu on Menu { + id + items { + ...ParentMenuItem + } + } +` as const; + +export const HEADER_QUERY = `#graphql + fragment Shop on Shop { + id + name + description + primaryDomain { + url + } + brand { + logo { + image { + url + } + } + } + } + query Header( + $country: CountryCode + $headerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + shop { + ...Shop + } + menu(handle: $headerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; + +export const FOOTER_QUERY = `#graphql + query Footer( + $country: CountryCode + $footerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + menu(handle: $footerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/search.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/search.ts new file mode 100644 index 000000000000..d3295f1fc66a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/search.ts @@ -0,0 +1,25 @@ +import type { + PredictiveArticleFragment, + PredictiveCollectionFragment, + PredictivePageFragment, + PredictiveProductFragment, + PredictiveQueryFragment, + SearchProductFragment, +} from 'storefrontapi.generated'; + +export function applyTrackingParams( + resource: + | PredictiveQueryFragment + | SearchProductFragment + | PredictiveProductFragment + | PredictiveCollectionFragment + | PredictiveArticleFragment + | PredictivePageFragment, + params?: string, +) { + if (params) { + return resource?.trackingParameters ? `?${params}&${resource.trackingParameters}` : `?${params}`; + } else { + return resource?.trackingParameters ? `?${resource.trackingParameters}` : ''; + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/session.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/session.ts new file mode 100644 index 000000000000..80d6e7b86b52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/session.ts @@ -0,0 +1,61 @@ +import type { HydrogenSession } from '@shopify/hydrogen'; +import { type Session, type SessionStorage, createCookieSessionStorage } from '@shopify/remix-oxygen'; + +/** + * This is a custom session implementation for your Hydrogen shop. + * Feel free to customize it to your needs, add helper methods, or + * swap out the cookie-based implementation with something else! + */ +export class AppSession implements HydrogenSession { + #sessionStorage; + #session; + + constructor(sessionStorage: SessionStorage, session: Session) { + this.#sessionStorage = sessionStorage; + this.#session = session; + } + + static async init(request: Request, secrets: string[]) { + const storage = createCookieSessionStorage({ + cookie: { + name: 'session', + httpOnly: true, + path: '/', + sameSite: 'lax', + secrets, + }, + }); + + const session = await storage.getSession(request.headers.get('Cookie')).catch(() => storage.getSession()); + + return new this(storage, session); + } + + get has() { + return this.#session.has; + } + + get get() { + return this.#session.get; + } + + get flash() { + return this.#session.flash; + } + + get unset() { + return this.#session.unset; + } + + get set() { + return this.#session.set; + } + + destroy() { + return this.#sessionStorage.destroySession(this.#session); + } + + commit() { + return this.#sessionStorage.commitSession(this.#session); + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/variants.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/variants.ts new file mode 100644 index 000000000000..0b8fbabdd528 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/variants.ts @@ -0,0 +1,41 @@ +import { useLocation } from '@remix-run/react'; +import type { SelectedOption } from '@shopify/hydrogen/storefront-api-types'; +import { useMemo } from 'react'; + +export function useVariantUrl(handle: string, selectedOptions: SelectedOption[]) { + const { pathname } = useLocation(); + + return useMemo(() => { + return getVariantUrl({ + handle, + pathname, + searchParams: new URLSearchParams(), + selectedOptions, + }); + }, [handle, selectedOptions, pathname]); +} + +export function getVariantUrl({ + handle, + pathname, + searchParams, + selectedOptions, +}: { + handle: string; + pathname: string; + searchParams: URLSearchParams; + selectedOptions: SelectedOption[]; +}) { + const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname); + const isLocalePathname = match && match.length > 0; + + const path = isLocalePathname ? `${match![0]}products/${handle}` : `/products/${handle}`; + + selectedOptions.forEach(option => { + searchParams.set(option.name, option.value); + }); + + const searchString = searchParams.toString(); + + return path + (searchString ? '?' + searchParams.toString() : ''); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx new file mode 100644 index 000000000000..c412e40a2866 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx @@ -0,0 +1,290 @@ +import { + Links, + LiveReload, + Meta, + type MetaFunction, + Outlet, + Scripts, + ScrollRestoration, + type ShouldRevalidateFunction, + isRouteErrorResponse, + useLoaderData, + useMatches, + useRouteError, +} from '@remix-run/react'; +import type { SentryMetaArgs } from '@sentry/remix'; +import { useNonce } from '@shopify/hydrogen'; +import type { CustomerAccessToken } from '@shopify/hydrogen/storefront-api-types'; +import { type LoaderArgs, defer } from '@shopify/remix-oxygen'; +import favicon from '../public/favicon.svg'; +import type { HydrogenSession } from '../server'; + +import * as Sentry from '@sentry/remix'; + +// This is important to avoid re-fetching root queries on sub-navigations +export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod, currentUrl, nextUrl }) => { + // revalidate when a mutation is performed e.g add to cart, login... + if (formMethod && formMethod !== 'GET') { + return true; + } + + // revalidate when manually revalidating via useRevalidator + if (currentUrl.toString() === nextUrl.toString()) { + return true; + } + + return false; +}; + +export function links() { + return [ + { + rel: 'preconnect', + href: 'https://cdn.shopify.com', + }, + { + rel: 'preconnect', + href: 'https://shop.app', + }, + { rel: 'icon', type: 'image/svg+xml', href: favicon }, + ]; +} + +export async function loader({ context }: LoaderArgs) { + const { storefront, session, cart } = context; + const customerAccessToken = await session.get('customerAccessToken'); + const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN; + + // validate the customer access token is valid + const { isLoggedIn, headers } = await validateCustomerAccessToken(session, customerAccessToken); + + // defer the cart query by not awaiting it + const cartPromise = cart.get(); + + // defer the footer query (below the fold) + const footerPromise = storefront.query(FOOTER_QUERY, { + cache: storefront.CacheLong(), + variables: { + footerMenuHandle: 'footer', // Adjust to your footer menu handle + }, + }); + + // await the header query (above the fold) + const headerPromise = storefront.query(HEADER_QUERY, { + cache: storefront.CacheLong(), + variables: { + headerMenuHandle: 'main-menu', // Adjust to your header menu handle + }, + }); + + return defer( + { + cart: cartPromise, + footer: footerPromise, + header: await headerPromise, + isLoggedIn, + publicStoreDomain, + ENV: { + SENTRY_DSN: process.env.E2E_TEST_DSN, + }, + }, + { headers }, + ); +} + +export const meta = ({ data }: SentryMetaArgs>) => { + return [ + { + env: data.ENV, + }, + { + name: 'sentry-trace', + content: data.sentryTrace, + }, + { + name: 'baggage', + content: data.sentryBaggage, + }, + ]; +}; + +function App() { + const nonce = useNonce(); + const { ENV } = useLoaderData(); + + return ( + + + + +