Skip to content

Commit b4bc1f9

Browse files
committed
test(remix): Add Shopify Hydrogen E2E test app.
1 parent 831e439 commit b4bc1f9

31 files changed

+1711
-0
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,7 @@ jobs:
10581058
'react-router-6-use-routes',
10591059
'react-router-5',
10601060
'react-router-6',
1061+
'remix-hydrogen',
10611062
'solid',
10621063
'svelte-5',
10631064
'sveltekit',
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
build
2+
node_modules
3+
bin
4+
*.d.ts
5+
dist
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node_modules
2+
/.cache
3+
/build
4+
/dist
5+
/public/build
6+
/.mf
7+
.env
8+
.shopify
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { RemixBrowser, useLocation, useMatches } from '@remix-run/react';
2+
import * as Sentry from '@sentry/remix';
3+
import { StrictMode, startTransition } from 'react';
4+
import { useEffect } from 'react';
5+
import { hydrateRoot } from 'react-dom/client';
6+
7+
Sentry.init({
8+
dsn: window.ENV.SENTRY_DSN,
9+
integrations: [
10+
new Sentry.BrowserTracing({
11+
routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches),
12+
}),
13+
// Replay is only available in the client
14+
new Sentry.Replay(),
15+
new Sentry.BrowserProfilingIntegration(),
16+
],
17+
18+
// Set tracesSampleRate to 1.0 to capture 100%
19+
// of transactions for performance monitoring.
20+
// We recommend adjusting this value in production
21+
tracesSampleRate: 1.0,
22+
23+
// Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled
24+
tracePropagationTargets: ['localhost', /^https:\/\/yourserver\.io\/api/],
25+
26+
// Capture Replay for 10% of all sessions,
27+
// plus for 100% of sessions with an error
28+
replaysSessionSampleRate: 0.1,
29+
replaysOnErrorSampleRate: 1.0,
30+
31+
// Capture all profiles
32+
profilesSampleRate: 1.0,
33+
});
34+
35+
Sentry.addEventProcessor(event => {
36+
if (
37+
event.type === 'transaction' &&
38+
(event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation')
39+
) {
40+
const eventId = event.event_id;
41+
if (eventId) {
42+
window.recordedTransactions = window.recordedTransactions || [];
43+
window.recordedTransactions.push(eventId);
44+
}
45+
}
46+
47+
return event;
48+
});
49+
50+
startTransition(() => {
51+
hydrateRoot(
52+
document,
53+
<StrictMode>
54+
<RemixBrowser />
55+
</StrictMode>,
56+
);
57+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as Sentry from '@sentry/remix';
2+
3+
import { RemixServer } from '@remix-run/react';
4+
import { createContentSecurityPolicy } from '@shopify/hydrogen';
5+
import type { DataFunctionArgs, EntryContext } from '@shopify/remix-oxygen';
6+
import isbot from 'isbot';
7+
import { renderToReadableStream } from 'react-dom/server';
8+
9+
export async function handleError(error: unknown, { request }: DataFunctionArgs): Promise<void> {
10+
Sentry.captureRemixServerException(error, 'remix.server', request, true);
11+
}
12+
13+
export default async function handleRequest(
14+
request: Request,
15+
responseStatusCode: number,
16+
responseHeaders: Headers,
17+
remixContext: EntryContext,
18+
) {
19+
const { nonce, header, NonceProvider } = createContentSecurityPolicy();
20+
21+
const body = await renderToReadableStream(
22+
<NonceProvider>
23+
<RemixServer context={remixContext} url={request.url} />
24+
</NonceProvider>,
25+
{
26+
nonce,
27+
signal: request.signal,
28+
onError(error) {
29+
// eslint-disable-next-line no-console
30+
console.error(error);
31+
responseStatusCode = 500;
32+
},
33+
},
34+
);
35+
36+
if (isbot(request.headers.get('user-agent'))) {
37+
await body.allReady;
38+
}
39+
40+
responseHeaders.set('Content-Type', 'text/html');
41+
responseHeaders.set('Content-Security-Policy', header);
42+
43+
// Add the document policy header to enable JS profiling
44+
// This is required for Sentry's profiling integration
45+
responseHeaders.set('Document-Policy', 'js-profiling');
46+
47+
return new Response(body, {
48+
headers: responseHeaders,
49+
status: responseStatusCode,
50+
});
51+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
2+
export const CART_QUERY_FRAGMENT = `#graphql
3+
fragment Money on MoneyV2 {
4+
currencyCode
5+
amount
6+
}
7+
fragment CartLine on CartLine {
8+
id
9+
quantity
10+
attributes {
11+
key
12+
value
13+
}
14+
cost {
15+
totalAmount {
16+
...Money
17+
}
18+
amountPerQuantity {
19+
...Money
20+
}
21+
compareAtAmountPerQuantity {
22+
...Money
23+
}
24+
}
25+
merchandise {
26+
... on ProductVariant {
27+
id
28+
availableForSale
29+
compareAtPrice {
30+
...Money
31+
}
32+
price {
33+
...Money
34+
}
35+
requiresShipping
36+
title
37+
image {
38+
id
39+
url
40+
altText
41+
width
42+
height
43+
44+
}
45+
product {
46+
handle
47+
title
48+
id
49+
vendor
50+
}
51+
selectedOptions {
52+
name
53+
value
54+
}
55+
}
56+
}
57+
}
58+
fragment CartApiQuery on Cart {
59+
updatedAt
60+
id
61+
checkoutUrl
62+
totalQuantity
63+
buyerIdentity {
64+
countryCode
65+
customer {
66+
id
67+
email
68+
firstName
69+
lastName
70+
displayName
71+
}
72+
email
73+
phone
74+
}
75+
lines(first: $numCartLines) {
76+
nodes {
77+
...CartLine
78+
}
79+
}
80+
cost {
81+
subtotalAmount {
82+
...Money
83+
}
84+
totalAmount {
85+
...Money
86+
}
87+
totalDutyAmount {
88+
...Money
89+
}
90+
totalTaxAmount {
91+
...Money
92+
}
93+
}
94+
note
95+
attributes {
96+
key
97+
value
98+
}
99+
discountCodes {
100+
code
101+
applicable
102+
}
103+
}
104+
` as const;
105+
106+
const MENU_FRAGMENT = `#graphql
107+
fragment MenuItem on MenuItem {
108+
id
109+
resourceId
110+
tags
111+
title
112+
type
113+
url
114+
}
115+
fragment ChildMenuItem on MenuItem {
116+
...MenuItem
117+
}
118+
fragment ParentMenuItem on MenuItem {
119+
...MenuItem
120+
items {
121+
...ChildMenuItem
122+
}
123+
}
124+
fragment Menu on Menu {
125+
id
126+
items {
127+
...ParentMenuItem
128+
}
129+
}
130+
` as const;
131+
132+
export const HEADER_QUERY = `#graphql
133+
fragment Shop on Shop {
134+
id
135+
name
136+
description
137+
primaryDomain {
138+
url
139+
}
140+
brand {
141+
logo {
142+
image {
143+
url
144+
}
145+
}
146+
}
147+
}
148+
query Header(
149+
$country: CountryCode
150+
$headerMenuHandle: String!
151+
$language: LanguageCode
152+
) @inContext(language: $language, country: $country) {
153+
shop {
154+
...Shop
155+
}
156+
menu(handle: $headerMenuHandle) {
157+
...Menu
158+
}
159+
}
160+
${MENU_FRAGMENT}
161+
` as const;
162+
163+
export const FOOTER_QUERY = `#graphql
164+
query Footer(
165+
$country: CountryCode
166+
$footerMenuHandle: String!
167+
$language: LanguageCode
168+
) @inContext(language: $language, country: $country) {
169+
menu(handle: $footerMenuHandle) {
170+
...Menu
171+
}
172+
}
173+
${MENU_FRAGMENT}
174+
` as const;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {
2+
PredictiveArticleFragment,
3+
PredictiveCollectionFragment,
4+
PredictivePageFragment,
5+
PredictiveProductFragment,
6+
PredictiveQueryFragment,
7+
SearchProductFragment,
8+
} from 'storefrontapi.generated';
9+
10+
export function applyTrackingParams(
11+
resource:
12+
| PredictiveQueryFragment
13+
| SearchProductFragment
14+
| PredictiveProductFragment
15+
| PredictiveCollectionFragment
16+
| PredictiveArticleFragment
17+
| PredictivePageFragment,
18+
params?: string,
19+
) {
20+
if (params) {
21+
return resource?.trackingParameters ? `?${params}&${resource.trackingParameters}` : `?${params}`;
22+
} else {
23+
return resource?.trackingParameters ? `?${resource.trackingParameters}` : '';
24+
}
25+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { HydrogenSession } from '@shopify/hydrogen';
2+
import { type Session, type SessionStorage, createCookieSessionStorage } from '@shopify/remix-oxygen';
3+
4+
/**
5+
* This is a custom session implementation for your Hydrogen shop.
6+
* Feel free to customize it to your needs, add helper methods, or
7+
* swap out the cookie-based implementation with something else!
8+
*/
9+
export class AppSession implements HydrogenSession {
10+
#sessionStorage;
11+
#session;
12+
13+
constructor(sessionStorage: SessionStorage, session: Session) {
14+
this.#sessionStorage = sessionStorage;
15+
this.#session = session;
16+
}
17+
18+
static async init(request: Request, secrets: string[]) {
19+
const storage = createCookieSessionStorage({
20+
cookie: {
21+
name: 'session',
22+
httpOnly: true,
23+
path: '/',
24+
sameSite: 'lax',
25+
secrets,
26+
},
27+
});
28+
29+
const session = await storage.getSession(request.headers.get('Cookie')).catch(() => storage.getSession());
30+
31+
return new this(storage, session);
32+
}
33+
34+
get has() {
35+
return this.#session.has;
36+
}
37+
38+
get get() {
39+
return this.#session.get;
40+
}
41+
42+
get flash() {
43+
return this.#session.flash;
44+
}
45+
46+
get unset() {
47+
return this.#session.unset;
48+
}
49+
50+
get set() {
51+
return this.#session.set;
52+
}
53+
54+
destroy() {
55+
return this.#sessionStorage.destroySession(this.#session);
56+
}
57+
58+
commit() {
59+
return this.#sessionStorage.commitSession(this.#session);
60+
}
61+
}

0 commit comments

Comments
 (0)