Skip to content

Commit da534b6

Browse files
committed
Add PerfKit integration contract tests
Lock down the deterministic Hydrogen <-> PerfKit contract with dedicated tests, without changing runtime behavior: - Export the pinned PERF_KIT_URL constant (shopify-perf-kit-spa.min.js) so the exact SPA script URL is asserted in tests; bumping it is now a deliberate, reviewed change. - Memoize the data-* attributes object (stable identity; lets tests assert the exact attributes). No behavior change. - PerfKit.test.tsx covers: SPA script URL; exact data-* attributes; shop GID parsing; storefront id from hydrogenSubchannelId; Internal_Shopify_Perf_Kit registration; no wiring while loading/error; wiring only after done; all five view subscriptions; ready() once after wiring; no double-wire across a loading->done transition; page_viewed -> navigate(); product/collection/ search/cart -> setPageType(...); no throw when window.PerfKit is absent. Non-user-facing, nothing exported from the package entrypoint, no semver change (empty changeset). Requested by Dan Gayle <dan.gayle@shopify.com>
1 parent 9951900 commit da534b6

3 files changed

Lines changed: 244 additions & 7 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
Internal: add PerfKit integration contract tests (`PerfKit.test.tsx`) and export the pinned `PERF_KIT_URL` constant so the exact PerfKit SPA script URL and required `data-*` attributes are locked down and regression-tested. No runtime behavior change, nothing new exported from the package entrypoint, and no release required.
5+
6+
The tests assert the deterministic PerfKit contract: the pinned SPA script URL (`shopify-perf-kit-spa.min.js`), the exact `data-*` attributes (`data-application=hydrogen`, `data-spa-mode=true`, parsed `data-shop-id`, `data-storefront-id`, `data-monorail-region=global`, `data-resource-timing-sampling-rate=100`), that subscriptions wire only after the script status is `done` (never on `loading`/`error`) and never double-wire, that `ready()` is called once after wiring, and that `page_viewed -> navigate()` and product/collection/search/cart -> `setPageType(...)`.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
2+
import {cleanup, render} from '@testing-library/react';
3+
import type {ShopAnalytics} from './AnalyticsProvider';
4+
import {AnalyticsEvent} from './events';
5+
6+
// Control the simulated script load state per test without downloading the
7+
// real PerfKit script. `parseGid` stays the real implementation.
8+
let mockScriptStatus: 'loading' | 'done' | 'error' = 'loading';
9+
const useLoadScriptMock = vi.fn(
10+
(_url: string, _options?: unknown) => mockScriptStatus,
11+
);
12+
13+
vi.mock('@shopify/hydrogen-react', async (importOriginal) => {
14+
const actual =
15+
await importOriginal<typeof import('@shopify/hydrogen-react')>();
16+
return {
17+
...actual,
18+
useLoadScript: (url: string, options?: unknown) =>
19+
useLoadScriptMock(url, options),
20+
};
21+
});
22+
23+
// Mock the analytics context so we can directly inspect registration,
24+
// readiness, and subscriptions.
25+
const subscribeMock = vi.fn();
26+
const readyMock = vi.fn();
27+
const registerMock = vi.fn(() => ({ready: readyMock}));
28+
29+
vi.mock('./AnalyticsProvider', () => ({
30+
useAnalytics: () => ({
31+
subscribe: subscribeMock,
32+
register: registerMock,
33+
}),
34+
}));
35+
36+
// Imported after the mocks above are declared.
37+
import {PerfKit, PERF_KIT_URL} from './PerfKit';
38+
39+
const SHOP: ShopAnalytics = {
40+
shopId: 'gid://shopify/Shop/12345',
41+
acceptedLanguage: 'EN' as ShopAnalytics['acceptedLanguage'],
42+
currency: 'USD' as ShopAnalytics['currency'],
43+
hydrogenSubchannelId: 'storefront-67890',
44+
};
45+
46+
function getSubscribedCallback(event: string): (() => void) | undefined {
47+
const call = (subscribeMock.mock.calls as Array<[string, () => void]>).find(
48+
([subscribedEvent]) => subscribedEvent === event,
49+
);
50+
return call?.[1];
51+
}
52+
53+
function getLoadScriptAttributes(): Record<string, string> {
54+
const lastCall =
55+
useLoadScriptMock.mock.calls[useLoadScriptMock.mock.calls.length - 1];
56+
return (lastCall?.[1] as {attributes: Record<string, string>}).attributes;
57+
}
58+
59+
describe('<PerfKit />', () => {
60+
beforeEach(() => {
61+
mockScriptStatus = 'loading';
62+
subscribeMock.mockClear();
63+
readyMock.mockClear();
64+
registerMock.mockClear();
65+
useLoadScriptMock.mockClear();
66+
// @ts-expect-error - reset injected global between tests
67+
delete window.PerfKit;
68+
});
69+
70+
afterEach(() => {
71+
cleanup();
72+
});
73+
74+
describe('script contract', () => {
75+
it('requests the pinned PerfKit SPA script URL', () => {
76+
mockScriptStatus = 'done';
77+
render(<PerfKit shop={SHOP} />);
78+
79+
expect(useLoadScriptMock).toHaveBeenCalled();
80+
expect(useLoadScriptMock.mock.calls[0][0]).toBe(PERF_KIT_URL);
81+
// Pinned exactly — bumping PerfKit's URL/version must be a deliberate,
82+
// reviewed change that updates this assertion.
83+
expect(PERF_KIT_URL).toBe(
84+
'https://cdn.shopify.com/shopifycloud/perf-kit/shopify-perf-kit-spa.min.js',
85+
);
86+
});
87+
88+
it('passes the required data-* attributes exactly', () => {
89+
render(<PerfKit shop={SHOP} />);
90+
91+
expect(getLoadScriptAttributes()).toEqual({
92+
id: 'perfkit',
93+
'data-application': 'hydrogen',
94+
'data-shop-id': '12345',
95+
'data-storefront-id': 'storefront-67890',
96+
'data-monorail-region': 'global',
97+
'data-spa-mode': 'true',
98+
'data-resource-timing-sampling-rate': '100',
99+
});
100+
});
101+
102+
it('parses the shop id from the gid', () => {
103+
render(<PerfKit shop={SHOP} />);
104+
expect(getLoadScriptAttributes()['data-shop-id']).toBe('12345');
105+
});
106+
107+
it('uses shop.hydrogenSubchannelId for the storefront id', () => {
108+
render(<PerfKit shop={SHOP} />);
109+
expect(getLoadScriptAttributes()['data-storefront-id']).toBe(
110+
'storefront-67890',
111+
);
112+
});
113+
});
114+
115+
describe('subscription wiring', () => {
116+
it('registers Internal_Shopify_Perf_Kit', () => {
117+
render(<PerfKit shop={SHOP} />);
118+
expect(registerMock).toHaveBeenCalledWith('Internal_Shopify_Perf_Kit');
119+
});
120+
121+
it('does not wire subscriptions while the script status is loading', () => {
122+
mockScriptStatus = 'loading';
123+
render(<PerfKit shop={SHOP} />);
124+
125+
expect(subscribeMock).not.toHaveBeenCalled();
126+
expect(readyMock).not.toHaveBeenCalled();
127+
});
128+
129+
it('does not wire subscriptions when the script status is error', () => {
130+
mockScriptStatus = 'error';
131+
render(<PerfKit shop={SHOP} />);
132+
133+
expect(subscribeMock).not.toHaveBeenCalled();
134+
expect(readyMock).not.toHaveBeenCalled();
135+
});
136+
137+
it('wires all five view subscriptions only after the script is done', () => {
138+
mockScriptStatus = 'done';
139+
render(<PerfKit shop={SHOP} />);
140+
141+
const subscribedEvents = (
142+
subscribeMock.mock.calls as Array<[string, () => void]>
143+
).map(([event]) => event);
144+
145+
expect(subscribedEvents).toEqual(
146+
expect.arrayContaining([
147+
AnalyticsEvent.PAGE_VIEWED,
148+
AnalyticsEvent.PRODUCT_VIEWED,
149+
AnalyticsEvent.COLLECTION_VIEWED,
150+
AnalyticsEvent.SEARCH_VIEWED,
151+
AnalyticsEvent.CART_VIEWED,
152+
]),
153+
);
154+
expect(subscribedEvents).toHaveLength(5);
155+
});
156+
157+
it('calls ready() once, after subscriptions are wired', () => {
158+
mockScriptStatus = 'done';
159+
render(<PerfKit shop={SHOP} />);
160+
161+
expect(readyMock).toHaveBeenCalledTimes(1);
162+
});
163+
164+
it('wires once across a loading->done transition and does not re-wire', () => {
165+
// Start at loading: nothing wired yet.
166+
mockScriptStatus = 'loading';
167+
const {rerender} = render(<PerfKit shop={SHOP} />);
168+
expect(subscribeMock).not.toHaveBeenCalled();
169+
170+
// Transition to done: the effect re-runs (scriptStatus dep changed) and
171+
// wires exactly once, setting the loadedEvent guard.
172+
mockScriptStatus = 'done';
173+
rerender(<PerfKit shop={SHOP} />);
174+
expect(subscribeMock).toHaveBeenCalledTimes(5);
175+
expect(readyMock).toHaveBeenCalledTimes(1);
176+
177+
// A subsequent re-render must not re-wire — the loadedEvent.current guard
178+
// is what prevents it once deps stop changing.
179+
rerender(<PerfKit shop={SHOP} />);
180+
expect(subscribeMock).toHaveBeenCalledTimes(5);
181+
expect(readyMock).toHaveBeenCalledTimes(1);
182+
});
183+
});
184+
185+
describe('event -> PerfKit calls', () => {
186+
it('calls window.PerfKit.navigate() on page_viewed', () => {
187+
mockScriptStatus = 'done';
188+
const navigate = vi.fn();
189+
const setPageType = vi.fn();
190+
window.PerfKit = {navigate, setPageType};
191+
192+
render(<PerfKit shop={SHOP} />);
193+
getSubscribedCallback(AnalyticsEvent.PAGE_VIEWED)?.();
194+
195+
expect(navigate).toHaveBeenCalledTimes(1);
196+
});
197+
198+
it.each([
199+
[AnalyticsEvent.PRODUCT_VIEWED, 'product'],
200+
[AnalyticsEvent.COLLECTION_VIEWED, 'collection'],
201+
[AnalyticsEvent.SEARCH_VIEWED, 'search'],
202+
[AnalyticsEvent.CART_VIEWED, 'cart'],
203+
])('calls setPageType for %s', (event, pageType) => {
204+
mockScriptStatus = 'done';
205+
const navigate = vi.fn();
206+
const setPageType = vi.fn();
207+
window.PerfKit = {navigate, setPageType};
208+
209+
render(<PerfKit shop={SHOP} />);
210+
getSubscribedCallback(event)?.();
211+
212+
expect(setPageType).toHaveBeenCalledWith(pageType);
213+
});
214+
215+
it('does not throw when window.PerfKit is absent (script-load race)', () => {
216+
mockScriptStatus = 'done';
217+
// Intentionally do not assign window.PerfKit.
218+
render(<PerfKit shop={SHOP} />);
219+
220+
expect(() =>
221+
getSubscribedCallback(AnalyticsEvent.PAGE_VIEWED)?.(),
222+
).not.toThrow();
223+
});
224+
});
225+
});

packages/hydrogen/src/analytics-manager/PerfKit.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {parseGid, useLoadScript} from '@shopify/hydrogen-react';
22
import {ShopAnalytics, useAnalytics} from './AnalyticsProvider';
33
import {AnalyticsEvent} from './events';
4-
import {useEffect, useRef} from 'react';
4+
import {useEffect, useMemo, useRef} from 'react';
55

66
declare global {
77
interface Window {
@@ -12,26 +12,32 @@ declare global {
1212
}
1313
}
1414

15-
// Pin to a version that have SPA support.
16-
const PERF_KIT_URL =
15+
// Pin to a version that has SPA support.
16+
// Exported so contract tests can assert the exact URL.
17+
export const PERF_KIT_URL =
1718
'https://cdn.shopify.com/shopifycloud/perf-kit/shopify-perf-kit-spa.min.js';
1819

1920
export function PerfKit({shop}: {shop: ShopAnalytics}) {
2021
const loadedEvent = useRef(false);
2122
const {subscribe, register} = useAnalytics();
2223
const {ready} = register('Internal_Shopify_Perf_Kit');
2324

24-
const scriptStatus = useLoadScript(PERF_KIT_URL, {
25-
attributes: {
25+
// Memoized so the object identity is stable across renders and so contract
26+
// tests can assert the exact attributes passed to `useLoadScript`.
27+
const attributes = useMemo(
28+
() => ({
2629
id: 'perfkit',
2730
'data-application': 'hydrogen',
2831
'data-shop-id': parseGid(shop.shopId).id.toString(),
2932
'data-storefront-id': shop.hydrogenSubchannelId,
3033
'data-monorail-region': 'global',
3134
'data-spa-mode': 'true',
3235
'data-resource-timing-sampling-rate': '100',
33-
},
34-
});
36+
}),
37+
[shop.shopId, shop.hydrogenSubchannelId],
38+
);
39+
40+
const scriptStatus = useLoadScript(PERF_KIT_URL, {attributes});
3541

3642
useEffect(() => {
3743
if (scriptStatus !== 'done' || loadedEvent.current) return;

0 commit comments

Comments
 (0)