diff --git a/.changeset/cold-rules-wave.md b/.changeset/cold-rules-wave.md new file mode 100644 index 0000000000..92349b51d5 --- /dev/null +++ b/.changeset/cold-rules-wave.md @@ -0,0 +1,9 @@ +--- +'@shopify/hydrogen': patch +--- + +This version adds support for the new cookie system in Shopify (`_shopify_analytics` and `_shopify_marketing` http-only cookies). It is backward compatible and still supports the deprecated `_shopify_y` and `_shopify_s` cookies. + +- `createRequestHandler` can now be used for every Hydrogen app, not only the ones deployed to Oxygen. It is now exported from `@shopify/hydrogen`. +- A new Storefront API proxy is now available in Hydrogen's `createRequestHandler`. This will be used to obtain http-only cookies from Storefront API. In general, it should be transparent but it can be disabled with the `proxyStandardRoutes` option. +- `Analytics.Provider` component and `useCustomerPrivacy` hook now make a request internally to the mentioned proxy to obtain cookies in the storefront domain. diff --git a/.changeset/remix-oxygen-proxy.md b/.changeset/remix-oxygen-proxy.md new file mode 100644 index 0000000000..6003a55935 --- /dev/null +++ b/.changeset/remix-oxygen-proxy.md @@ -0,0 +1,6 @@ +--- +'@shopify/remix-oxygen': minor +--- + + +Support Shopify's new consolidated cookie architecture. Adds built-in Storefront API proxy support to `createRequestHandler`. diff --git a/.changeset/rotten-bobcats-grab.md b/.changeset/rotten-bobcats-grab.md new file mode 100644 index 0000000000..091405f37f --- /dev/null +++ b/.changeset/rotten-bobcats-grab.md @@ -0,0 +1,7 @@ +--- +'@shopify/hydrogen-react': patch +--- + +New export `getTrackingValues` to obtain information for analytics and marketing. Use this instead of `getShopifyCookies` (which is now deprecated). + +`useShopifyCookies` now accepts a `fetchTrackingValues` parameter that can be used to make a Storefront API request and obtain Shopify http-only cookies, `_shopify_analytics` and `_shopify_marketing` (which replace the deprecated `_shopify_y` and `_shopify_s` cookies). diff --git a/.changeset/two-melons-design.md b/.changeset/two-melons-design.md new file mode 100644 index 0000000000..5bfdefe39f --- /dev/null +++ b/.changeset/two-melons-design.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Fixed a number of issues related to irregular behaviors between Privacy Banner and Hydrogen's analytics events. diff --git a/e2e/envs/.env.defaultConsentAllowed_cookiesDisabled b/e2e/envs/.env.defaultConsentAllowed_cookiesDisabled new file mode 100644 index 0000000000..17015ac5a9 --- /dev/null +++ b/e2e/envs/.env.defaultConsentAllowed_cookiesDisabled @@ -0,0 +1,5 @@ +SESSION_SECRET="mock-session" +PUBLIC_CHECKOUT_DOMAIN="checkout.hydrogen.shop" +PUBLIC_STORE_DOMAIN="checkout.hydrogen.shop" +PUBLIC_STOREFRONT_API_TOKEN="b97a750a8afa8fe33f2b4012cb3a9f6f" +PUBLIC_STOREFRONT_ID="1000014875" diff --git a/e2e/envs/.env.defaultConsentAllowed_cookiesEnabled b/e2e/envs/.env.defaultConsentAllowed_cookiesEnabled new file mode 100644 index 0000000000..ac2d45279c --- /dev/null +++ b/e2e/envs/.env.defaultConsentAllowed_cookiesEnabled @@ -0,0 +1,5 @@ +SESSION_SECRET="mock-session" +PUBLIC_CHECKOUT_DOMAIN="www.iwantacheapdomainfortesting12345.club" +PUBLIC_STORE_DOMAIN="www.iwantacheapdomainfortesting12345.club" +PUBLIC_STOREFRONT_API_TOKEN="2aac2e4420f32ba0c7dadf55c7cc387b" +PUBLIC_STOREFRONT_ID="1000070232" diff --git a/e2e/envs/.env.defaultConsentDisallowed_cookiesDisabled b/e2e/envs/.env.defaultConsentDisallowed_cookiesDisabled new file mode 100644 index 0000000000..a77393c952 --- /dev/null +++ b/e2e/envs/.env.defaultConsentDisallowed_cookiesDisabled @@ -0,0 +1,5 @@ +SESSION_SECRET="mock-session" +PUBLIC_CHECKOUT_DOMAIN="www.kara2345.xyz" +PUBLIC_STORE_DOMAIN="www.kara2345.xyz" +PUBLIC_STOREFRONT_API_TOKEN="8eece95833df895900c1b285987c7f40" +PUBLIC_STOREFRONT_ID="1000070242" diff --git a/e2e/envs/.env.defaultConsentDisallowed_cookiesEnabled b/e2e/envs/.env.defaultConsentDisallowed_cookiesEnabled new file mode 100644 index 0000000000..87c69010a5 --- /dev/null +++ b/e2e/envs/.env.defaultConsentDisallowed_cookiesEnabled @@ -0,0 +1,5 @@ +SESSION_SECRET="mock-session" +PUBLIC_CHECKOUT_DOMAIN="checkout.daviduik.com" +PUBLIC_STORE_DOMAIN="checkout.daviduik.com" +PUBLIC_STOREFRONT_API_TOKEN="a79d329fc13657352c6e4734e5d4ca75" +PUBLIC_STOREFRONT_ID="1000061747" diff --git a/e2e/envs/.env.mockShop b/e2e/envs/.env.mockShop new file mode 100644 index 0000000000..805ea2b1f1 --- /dev/null +++ b/e2e/envs/.env.mockShop @@ -0,0 +1,5 @@ +SESSION_SECRET="mock-session" +PUBLIC_CHECKOUT_DOMAIN="mock.shop" +PUBLIC_STORE_DOMAIN="mock.shop" +PUBLIC_STOREFRONT_API_TOKEN="" +PUBLIC_STOREFRONT_ID="" diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts new file mode 100644 index 0000000000..b91c3f696f --- /dev/null +++ b/e2e/fixtures/index.ts @@ -0,0 +1,67 @@ +import {test as base} from '@playwright/test'; +import {DevServer} from './server'; +import path from 'node:path'; +import {stat} from 'node:fs/promises'; +import {StorefrontPage} from './storefront'; + +export * from '@playwright/test'; +export * from './storefront'; + +export const test = base.extend< + {storefront: StorefrontPage}, + {forEachWorker: void} +>({ + storefront: async ({page}, use) => { + const storefront = new StorefrontPage(page); + // eslint-disable-next-line react-hooks/rules-of-hooks + await use(storefront); + }, +}); + +const TEST_STORE_KEYS = [ + 'mockShop', + 'defaultConsentDisallowed_cookiesEnabled', + 'defaultConsentAllowed_cookiesEnabled', + 'defaultConsentDisallowed_cookiesDisabled', + 'defaultConsentAllowed_cookiesDisabled', +] as const; + +type TestStoreKey = (typeof TEST_STORE_KEYS)[number]; + +export const setTestStore = async ( + testStore: TestStoreKey | `https://${string}`, +) => { + const isLocal = !testStore.startsWith('https://'); + let server: DevServer | null = null; + + test.use({ + // eslint-disable-next-line no-empty-pattern + baseURL: async ({}, use) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + await use(isLocal ? server?.getUrl() : testStore); + }, + }); + + if (!isLocal) { + console.log(`Using test store: ${testStore}`); + return; + } + + test.afterAll(async () => { + await server?.stop(); + }); + + // eslint-disable-next-line no-empty-pattern + test.beforeAll(async ({}) => { + const filepath = path.resolve(__dirname, `../envs/.env.${testStore}`); + await stat(filepath); // Ensure the file exists + + server = new DevServer({ + storeKey: testStore, + customerAccountPush: false, + envFile: filepath, + }); + + await server.start(); + }); +}; diff --git a/e2e/fixtures/server.ts b/e2e/fixtures/server.ts new file mode 100644 index 0000000000..1f740a811d --- /dev/null +++ b/e2e/fixtures/server.ts @@ -0,0 +1,172 @@ +import {spawn} from 'node:child_process'; +import path from 'node:path'; + +type DevServerOptions = { + id?: number; + port?: number; + projectPath?: string; + customerAccountPush?: boolean; + envFile?: string; + storeKey?: string; +}; + +export class DevServer { + process: ReturnType | undefined; + port: number; + projectPath: string; + customerAccountPush: boolean; + capturedUrl?: string; + id?: number; + envFile?: string; + storeKey?: string; + + constructor(options: DevServerOptions = {}) { + this.id = options.id; + this.storeKey = options.storeKey; + this.port = options.port ?? 3100; + this.projectPath = + options.projectPath ?? path.join(__dirname, '../../templates/skeleton'); + this.customerAccountPush = options.customerAccountPush ?? false; + this.envFile = options.envFile; + } + + getUrl() { + return this.capturedUrl || `http://localhost:${this.port}`; + } + + start() { + if (this.process) { + throw new Error(`Server ${this.id} is already running`); + } + + return new Promise((resolve, reject) => { + const args = ['run', 'dev', '--']; + if (this.customerAccountPush) { + args.push('--customer-account-push'); + } + + if (this.envFile) { + args.push('--env-file', this.envFile); + } + + this.process = spawn('npm', args, { + cwd: this.projectPath, + env: { + ...process.env, + NODE_ENV: 'development', + SHOPIFY_HYDROGEN_FLAG_PORT: this.port.toString(), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let started = false; + const timeout = setTimeout(() => { + if (!started) { + this.stop(); + reject(new Error(`Server ${this.id} failed to start within timeout`)); + } + }, 60000); + + let localUrl: string | undefined; + let tunnelUrl: string | undefined; + + const handleOutput = (output: string) => { + if (!localUrl) { + localUrl = output.match(/(http:\/\/localhost:\d+)/)?.[1]; + } + if (this.customerAccountPush && !tunnelUrl) { + tunnelUrl = output.match(/(https:\/\/[^\s]+)/)?.[1]; + } + + if (!started && output.includes('success')) { + started = true; + clearTimeout(timeout); + this.capturedUrl = tunnelUrl || localUrl; + const port = this.capturedUrl?.match(/:(\d+)/)?.[1]; + if (port) { + this.port = parseInt(port, 10); + } + if (!this.id) { + this.id = + this.port || parseInt((Math.random() * 1000).toFixed(0), 10); + } + console.log( + `[test-server ${this.id}] Server started on ${this.capturedUrl} [${this.storeKey}]`, + ); + // Give the tunnel a bit more time to ensure everything is ready + setTimeout(resolve, tunnelUrl ? 5000 : 0); + } + + if ( + output.includes('log in to Shopify') || + output.includes('User verification code:') + ) { + clearTimeout(timeout); + this.stop(); + reject( + new Error( + 'Not logged in to Shopify CLI. Run: cd templates/skeleton && npx shopify auth login', + ), + ); + } else if ( + output.includes('Failed to prompt') || + output.includes('Select a shop to log in') + ) { + clearTimeout(timeout); + this.stop(); + reject( + new Error( + 'Storefront not linked. Run: cd templates/skeleton && npx shopify hydrogen link', + ), + ); + } + }; + + if (this.process.stdout) { + this.process.stdout.on('data', (data) => { + const output = data.toString(); + // !started && console.log(output); + handleOutput(output); + }); + } + + if (this.process.stderr) { + this.process.stderr.on('data', (data) => { + const output = data.toString(); + // !started && console.log(output); + handleOutput(output); + }); + } + + this.process.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + + this.process.on('exit', (code) => { + if (!started) { + clearTimeout(timeout); + reject(new Error(`Server ${this.id} exited with code ${code}`)); + } + }); + }); + } + + stop() { + return new Promise((resolve) => { + if (!this.process) return resolve(false); + console.log(`[test-server ${this.id}] Stopping server...`); + + this.process.on('exit', () => { + this.process = undefined; + resolve(true); + }); + + this.process.kill('SIGTERM'); + + setTimeout(() => { + this.process?.kill('SIGKILL'); + }, 5000); + }); + } +} diff --git a/e2e/fixtures/storefront.ts b/e2e/fixtures/storefront.ts new file mode 100644 index 0000000000..40fe92c4f1 --- /dev/null +++ b/e2e/fixtures/storefront.ts @@ -0,0 +1,836 @@ +import type {Page, BrowserContext} from '@playwright/test'; +import {expect} from '@playwright/test'; + +// Privacy Banner element IDs +export const PRIVACY_BANNER_DIALOG_ID = 'shopify-pc__banner'; +export const ACCEPT_BUTTON_ID = 'shopify-pc__banner__btn-accept'; +export const DECLINE_BUTTON_ID = 'shopify-pc__banner__btn-decline'; + +// Privacy Preferences element IDs +export const PRIVACY_PREFS_DIALOG_ID = 'shopify-pc__prefs__dialog'; +export const PREFS_ACCEPT_BUTTON_ID = 'shopify-pc__prefs__header-accept'; +export const PREFS_DECLINE_BUTTON_ID = 'shopify-pc__prefs__header-decline'; + +// Cookies that require consent +export const ANALYTICS_COOKIES = [ + '_shopify_analytics', + '_shopify_marketing', + '_shopify_y', + '_shopify_s', +]; + +// URL patterns for network request tracking +export const PERF_KIT_URL = 'cdn.shopify.com/shopifycloud/perf-kit'; +export const MONORAIL_BATCH_URL = '/produce_batch'; // Shopify Analytics batch endpoint +export const MONORAIL_PRODUCE_URL = '/v1/produce'; // Perf-kit endpoint +export const GRAPHQL_URL = 'graphql.json'; + +// Mock value pattern for declined consent (all zeros with a 5) +export const MOCK_VALUE_PATTERN = /^00000000\-0000\-0000\-5000\-000000000000$/; + +export interface ServerTimingValues { + _y?: string; + _s?: string; +} + +export interface MonorailPayload { + unique_token?: string; + deprecated_visit_token?: string; + uniqToken?: string; + visitToken?: string; + // Perf-kit specific fields + session_token?: string; + micro_session_id?: string; +} + +export interface AnalyticsRequest { + url: string; + postData?: string; + initiator?: string; // Script URL that initiated the request +} + +/** + * Storefront fixture for e2e testing common storefront operations. + * Provides methods for interacting with privacy banners, cookies, analytics, and cart. + */ +export class StorefrontPage { + readonly page: Page; + readonly context: BrowserContext; + + // Separated request arrays for clarity + readonly monorailRequests: AnalyticsRequest[] = []; + readonly perfKitProduceRequests: AnalyticsRequest[] = []; + + // Track if perf-kit script has been loaded + private perfKitScriptLoaded = false; + + private requestInitiators: Map = new Map(); + + constructor(page: Page) { + this.page = page; + this.context = page.context(); + this.setupRequestTracking(); + } + + private setupRequestTracking() { + // Use CDP to track request initiators (which script initiated the request) + this.page.context().on('page', async (newPage) => { + await this.setupCDPTracking(newPage); + }); + // Also set up for the current page + this.setupCDPTracking(this.page).catch(() => { + // Ignore errors if CDP isn't available + }); + + this.page.on('request', (request) => { + const url = request.url(); + + // Track Monorail batch requests (Shopify Analytics) + if (url.includes(MONORAIL_BATCH_URL)) { + this.monorailRequests.push({ + url, + postData: request.postData() || undefined, + initiator: this.requestInitiators.get(url), + }); + } + + // Track perf-kit produce requests (/v1/produce but not /produce_batch) + if ( + url.includes(MONORAIL_PRODUCE_URL) && + !url.includes(MONORAIL_BATCH_URL) + ) { + this.perfKitProduceRequests.push({ + url, + postData: request.postData() || undefined, + initiator: this.requestInitiators.get(url), + }); + } + + // Track perf-kit script download + if (url.includes(PERF_KIT_URL)) { + this.perfKitScriptLoaded = true; + } + }); + } + + /** + * Chrome DevTools Protocol setup to track request initiators. + */ + private async setupCDPTracking(page: Page) { + try { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Network.enable'); + + cdp.on('Network.requestWillBeSent', (event: any) => { + const url = event.request.url; + // Extract initiator URL from the stack trace or URL + const initiatorUrl = + event.initiator?.url || + event.initiator?.stack?.callFrames?.[0]?.url || + ''; + if (initiatorUrl) { + this.requestInitiators.set(url, initiatorUrl); + } + }); + } catch { + // CDP might not be available in all browsers + } + } + + /** + * Navigate to a page and wait for network idle + */ + async goto(path = '/') { + await this.page.goto(path); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Get server-timing values (_y and _s) from the Performance API + * @param preferLatestResource - If true, prefer the latest resource entry over navigation timing + */ + async getServerTimingValues( + preferLatestResource = false, + ): Promise { + return this.page.evaluate((preferLatest) => { + const result: {_y?: string; _s?: string} = {}; + + // Get values from resource timing entries (latest entries first) + const resourceEntries = performance.getEntriesByType( + 'resource', + ) as PerformanceResourceTiming[]; + + // Reverse to get latest entries first + for (const entry of [...resourceEntries].reverse()) { + if (entry.serverTiming) { + for (const {name, description} of entry.serverTiming) { + if (name === '_y' && description && !result._y) { + result._y = description; + } else if (name === '_s' && description && !result._s) { + result._s = description; + } + } + } + if (result._y && result._s) break; + } + + // Fall back to navigation timing if resource entries don't have values + // or if we explicitly don't prefer latest + if (!preferLatest || (!result._y && !result._s)) { + const navigationEntry = performance.getEntriesByType( + 'navigation', + )[0] as PerformanceNavigationTiming; + + if (navigationEntry?.serverTiming) { + for (const {name, description} of navigationEntry.serverTiming) { + if (name === '_y' && description && !result._y) { + result._y = description; + } else if (name === '_s' && description && !result._s) { + result._s = description; + } + } + } + } + + return result; + }, preferLatestResource); + } + + /** + * Get all cookies from the browser context + */ + async getCookies() { + return this.context.cookies(); + } + + /** + * Get a specific cookie by name + */ + async getCookie(name: string) { + const cookies = await this.getCookies(); + return cookies.find((c) => c.name === name); + } + + /** + * Assert that no analytics cookies are present + */ + async expectNoAnalyticsCookies() { + const cookies = await this.getCookies(); + for (const cookieName of ANALYTICS_COOKIES) { + const cookie = cookies.find((c) => c.name.startsWith(cookieName)); + expect( + cookie, + `Cookie ${cookieName} should not be present`, + ).toBeUndefined(); + } + } + + /** + * Assert that analytics cookies are present and return them + */ + async expectAnalyticsCookiesPresent() { + const cookies = await this.getCookies(); + const shopifyY = cookies.find((c) => c.name === '_shopify_y'); + const shopifyS = cookies.find((c) => c.name === '_shopify_s'); + const shopifyAnalytics = cookies.find( + (c) => c.name === '_shopify_analytics', + ); + const shopifyMarketing = cookies.find( + (c) => c.name === '_shopify_marketing', + ); + + expect(shopifyY, '_shopify_y cookie should be present').toBeDefined(); + expect(shopifyS, '_shopify_s cookie should be present').toBeDefined(); + + return {shopifyY, shopifyS, shopifyAnalytics, shopifyMarketing}; + } + + /** + * Assert that essential cookie is present (set after declining consent) + */ + async expectEssentialCookiePresent() { + const cookies = await this.getCookies(); + const essentialCookie = cookies.find( + (c) => + c.name === '_shopify_essential' || c.name === '_shopify_essentials', + ); + + expect( + essentialCookie, + '_shopify_essential(s) cookie should be present', + ).toBeDefined(); + + return essentialCookie; + } + + /** + * Get the privacy banner locator + */ + getPrivacyBanner() { + return this.page.locator(`#${PRIVACY_BANNER_DIALOG_ID}`); + } + + /** + * Assert that privacy banner is visible + */ + async expectPrivacyBannerVisible(timeout = 20000) { + const banner = this.getPrivacyBanner(); + await expect(banner).toBeVisible({timeout}); + return banner; + } + + /** + * Assert that privacy banner is not visible + */ + async expectPrivacyBannerNotVisible() { + const banner = this.getPrivacyBanner(); + await expect(banner).not.toBeVisible(); + } + + /** + * Wait for consent management GraphQL response + */ + waitForConsentResponse() { + return this.page.waitForResponse(async (response) => { + const url = response.url(); + if (url.includes(GRAPHQL_URL)) { + const postData = response.request().postData(); + if (postData && postData.includes('consentManagement')) { + return true; + } + } + return false; + }); + } + + /** + * Accept the privacy banner and wait for consent response + */ + async acceptPrivacyBanner() { + const banner = await this.expectPrivacyBannerVisible(); + const responsePromise = this.waitForConsentResponse(); + + const acceptButton = this.page.locator(`#${ACCEPT_BUTTON_ID}`); + await expect(acceptButton).toBeVisible(); + await acceptButton.click(); + + await expect(banner).not.toBeVisible(); + const response = await responsePromise; + expect(response.ok(), 'Consent request should succeed').toBe(true); + + await this.page.waitForLoadState('networkidle'); + return response; + } + + /** + * Decline the privacy banner and wait for consent response + */ + async declinePrivacyBanner() { + const banner = await this.expectPrivacyBannerVisible(); + const responsePromise = this.waitForConsentResponse(); + + const declineButton = this.page.locator(`#${DECLINE_BUTTON_ID}`); + await expect(declineButton).toBeVisible(); + await declineButton.click(); + + await expect(banner).not.toBeVisible(); + const response = await responsePromise; + expect(response.ok(), 'Consent request should succeed').toBe(true); + + await this.page.waitForLoadState('networkidle'); + return response; + } + + /** + * Get the privacy preferences dialog locator + */ + getPrivacyPreferencesDialog() { + return this.page.locator(`#${PRIVACY_PREFS_DIALOG_ID}`); + } + + /** + * Assert that privacy preferences dialog is visible + */ + async expectPrivacyPreferencesVisible(timeout = 20000) { + const dialog = this.getPrivacyPreferencesDialog(); + await expect(dialog).toBeVisible({timeout}); + return dialog; + } + + /** + * Open privacy preferences dialog via window.privacyBanner.showPreferences() + */ + async openPrivacyPreferences() { + // Wait for the privacy banner API to be available + await this.page.waitForFunction( + () => { + const privacyBanner = (window as any).privacyBanner; + return privacyBanner?.showPreferences !== undefined; + }, + {timeout: 10000}, + ); + + // Call showPreferences to open the dialog + await this.page.evaluate(() => { + const privacyBanner = (window as any).privacyBanner; + privacyBanner.showPreferences(); + }); + + // Wait for the preferences dialog to appear + await this.expectPrivacyPreferencesVisible(); + } + + /** + * Accept consent in the privacy preferences dialog + */ + async acceptInPreferences() { + const dialog = await this.expectPrivacyPreferencesVisible(); + const responsePromise = this.waitForConsentResponse(); + + const acceptButton = this.page.locator(`#${PREFS_ACCEPT_BUTTON_ID}`); + await expect(acceptButton).toBeVisible(); + await acceptButton.click(); + + await expect(dialog).not.toBeVisible(); + const response = await responsePromise; + expect(response.ok(), 'Consent request should succeed').toBe(true); + + await this.page.waitForLoadState('networkidle'); + return response; + } + + /** + * Decline consent in the privacy preferences dialog + */ + async declineInPreferences() { + const dialog = await this.expectPrivacyPreferencesVisible(); + const responsePromise = this.waitForConsentResponse(); + + const declineButton = this.page.locator(`#${PREFS_DECLINE_BUTTON_ID}`); + await expect(declineButton).toBeVisible(); + await declineButton.click(); + + await expect(dialog).not.toBeVisible(); + const response = await responsePromise; + expect(response.ok(), 'Consent request should succeed').toBe(true); + + await this.page.waitForLoadState('networkidle'); + return response; + } + + /** + * Wait for perf-kit script to be loaded (checks DOM) + */ + async waitForPerfKit(timeout = 15000) { + await this.page.waitForFunction( + () => { + const perfKitScript = document.querySelector('script[src*="perf-kit"]'); + return perfKitScript !== null; + }, + {timeout}, + ); + } + + /** + * Check if perf-kit script has been loaded + */ + isPerfKitLoaded(): boolean { + return this.perfKitScriptLoaded; + } + + /** + * Assert that perf-kit script has not been loaded + */ + expectPerfKitNotLoaded() { + expect( + this.perfKitScriptLoaded, + 'Perf-kit script should not be loaded', + ).toBe(false); + } + + /** + * Assert that perf-kit script has been loaded + */ + expectPerfKitLoaded() { + expect(this.perfKitScriptLoaded, 'Perf-kit script should be loaded').toBe( + true, + ); + } + + /** + * Navigate to the first product on the page + */ + async navigateToFirstProduct() { + const productLink = this.page.locator('a[href*="/products/"]').first(); + await expect(productLink).toBeVisible(); + await productLink.click(); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Click the "Add to cart" button and wait for cart drawer with checkout URL + */ + async addToCart() { + const addToCartButton = this.page.locator( + 'button:has-text("Add to cart"), button:has-text("Add to Cart")', + ); + await expect(addToCartButton).toBeVisible({timeout: 10000}); + await addToCartButton.click(); + + // Wait for cart drawer to appear + const cartDrawer = this.page.locator('.overlay.expanded'); + await expect(cartDrawer).toBeVisible({timeout: 5000}); + + // Wait for checkout link to appear in the drawer (needs cart mutation response) + const checkoutLink = this.page.locator( + '.overlay.expanded a[href*="checkout"], .overlay.expanded a[href*="/cart/c/"]', + ); + await expect(checkoutLink).toBeVisible({timeout: 10000}); + + await this.page.waitForLoadState('networkidle'); + } + + /** + * Get checkout URLs from the page (links containing /cart/c/ or checkout) + */ + async getCheckoutUrls(): Promise { + // Look for links in the cart drawer that go to checkout + return this.page.evaluate(() => { + // First try /cart/c/ pattern (Shopify checkout URLs) + let links = document.querySelectorAll('a[href*="/cart/c/"]'); + if (links.length === 0) { + // Fallback: look for any checkout links in the cart drawer + links = document.querySelectorAll( + '.overlay.expanded a[href*="checkout"], .overlay.expanded a[href*="/cart/c"]', + ); + } + return Array.from(links).map((link) => link.getAttribute('href') || ''); + }); + } + + /** + * Verify checkout URLs contain tracking params (_y and _s) with expected values + */ + async verifyCheckoutUrlTrackingParams( + expectedY: string, + expectedS: string, + context: string, + ) { + const checkoutUrls = await this.getCheckoutUrls(); + + expect( + checkoutUrls.length, + `Should have checkout URLs ${context}`, + ).toBeGreaterThan(0); + + for (const url of checkoutUrls) { + const urlObj = new URL(url, this.page.url()); + const yParam = urlObj.searchParams.get('_y'); + const sParam = urlObj.searchParams.get('_s'); + + expect(yParam, `Checkout URL should have '_y' param ${context}`).toBe( + expectedY, + ); + expect(sParam, `Checkout URL should have '_s' param ${context}`).toBe( + expectedS, + ); + } + + return checkoutUrls; + } + + /** + * Verify checkout URLs do NOT contain real tracking params (_y and _s) + * Params should either be missing or have mock values (starting with 0000...) + * Used when consent is declined + */ + async expectNoCheckoutUrlTrackingParams(context: string) { + const checkoutUrls = await this.getCheckoutUrls(); + + expect( + checkoutUrls.length, + `Should have checkout URLs ${context}`, + ).toBeGreaterThan(0); + + for (const url of checkoutUrls) { + const urlObj = new URL(url, this.page.url()); + const yParam = urlObj.searchParams.get('_y'); + const sParam = urlObj.searchParams.get('_s'); + + // Params should either be null (missing) or mock values + const yIsValid = yParam === null || MOCK_VALUE_PATTERN.test(yParam); + const sIsValid = sParam === null || MOCK_VALUE_PATTERN.test(sParam); + + expect( + yIsValid, + `Checkout URL '_y' param should be missing or mock value ${context}, got: ${yParam}`, + ).toBe(true); + expect( + sIsValid, + `Checkout URL '_s' param should be missing or mock value ${context}, got: ${sParam}`, + ).toBe(true); + } + } + + /** + * Navigate to the cart page + */ + async navigateToCart() { + // Close any open overlays/drawers first (like cart drawer) + const closeButton = this.page.locator( + 'button[aria-label="Close"], .overlay button.close', + ); + if (await closeButton.isVisible().catch(() => false)) { + await closeButton.click(); + await this.page.waitForTimeout(300); + } + + // Navigate directly to cart page + await this.page.goto('/cart'); + await this.page.waitForLoadState('networkidle'); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Reload the page and clear request tracking + */ + async reload() { + this.clearRequests(); + await this.page.reload(); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Clear tracked requests + */ + clearRequests() { + this.monorailRequests.length = 0; + this.perfKitProduceRequests.length = 0; + this.perfKitScriptLoaded = false; + } + + /** + * Assert that no Monorail analytics requests have been made + */ + expectNoMonorailRequests() { + expect( + this.monorailRequests, + 'No Monorail analytics requests should be made', + ).toHaveLength(0); + } + + /** + * Assert that no perf-kit produce requests have been made + */ + expectNoPerfKitProduceRequests() { + expect( + this.perfKitProduceRequests, + 'No perf-kit produce requests should be made', + ).toHaveLength(0); + } + + /** + * Wait for Monorail analytics requests to be made + */ + async waitForMonorailRequests(minCount = 1) { + await expect + .poll( + () => this.monorailRequests.length, + 'Monorail analytics requests should be made', + ) + .toBeGreaterThanOrEqual(minCount); + } + + /** + * Verify that Monorail batch analytics requests contain the correct tracking values. + */ + verifyMonorailRequests( + expectedY: string, + expectedS: string, + context: string, + ) { + const requestsWithData = this.monorailRequests.filter( + (req) => req.postData, + ); + + expect( + requestsWithData.length, + `Monorail requests with data ${context}`, + ).toBeGreaterThan(0); + + for (const request of requestsWithData) { + const payload = JSON.parse(request.postData!) as { + events?: Array<{payload: MonorailPayload}>; + }; + + expect( + payload.events, + `Monorail request payload should be present ${context}`, + ).toBeDefined(); + + for (const event of payload.events!) { + const eventPayload = event.payload; + + const uniqueToken = eventPayload.unique_token || eventPayload.uniqToken; + expect( + uniqueToken, + `Monorail unique_token ${context} should match _y value`, + ).toBe(expectedY); + + const visitToken = + eventPayload.deprecated_visit_token || eventPayload.visitToken; + expect( + visitToken, + `Monorail visit_token ${context} should match _s value`, + ).toBe(expectedS); + } + } + } + + /** + * Trigger perf-kit to finalize its metrics by clicking on the page + * and waiting for metrics to be processed. This finalizes LCP measurement. + */ + async finalizePerfKitMetrics() { + // Click on the page body to finalize LCP measurement + await this.page.click('body'); + // Wait for metrics to be processed + await this.page.waitForTimeout(100); + } + + /** + * Verify that perf-kit produce requests contain the correct tracking values. + * Also verifies that the request was initiated by the perf-kit script. + */ + verifyPerfKitRequests(expectedY: string, expectedS: string, context: string) { + // Filter for requests initiated by perf-kit + const perfKitRequests = this.perfKitProduceRequests.filter( + (req) => req.postData && req.initiator?.includes('perf-kit'), + ); + + let foundPerfKitPayload = false; + + for (const request of perfKitRequests) { + const payload = JSON.parse(request.postData!) as { + payload?: MonorailPayload; + }; + + foundPerfKitPayload = true; + + // Verify the request was initiated by perf-kit + expect( + request.initiator, + `Request ${context} should be initiated by perf-kit script`, + ).toContain('perf-kit'); + + expect( + payload.payload?.unique_token, + `Perf-kit unique_token ${context} should match _y value`, + ).toBe(expectedY); + + expect( + payload.payload?.session_token, + `Perf-kit session_token ${context} should match _s value`, + ).toBe(expectedS); + } + + expect( + foundPerfKitPayload, + `At least one perf-kit produce request ${context} should be found`, + ).toBe(true); + + return foundPerfKitPayload; + } + + /** + * Assert that server-timing values are mock values (for declined consent) + */ + expectMockServerTimingValues(values: ServerTimingValues) { + if (values._y) { + expect( + MOCK_VALUE_PATTERN.test(values._y), + `Server-timing _y should be a mock value, got: ${values._y}`, + ).toBe(true); + } + if (values._s) { + expect( + MOCK_VALUE_PATTERN.test(values._s), + `Server-timing _s should be a mock value, got: ${values._s}`, + ).toBe(true); + } + } + + /** + * Assert that server-timing values are real UUIDs (not mock values) + */ + expectRealServerTimingValues(values: ServerTimingValues) { + expect(values._y, 'Y value should be present').toBeTruthy(); + expect(values._s, 'S value should be present').toBeTruthy(); + // Mock values match MOCK_VALUE_PATTERN: /^0+[-0]*5/ (zeros followed by 5) + expect(values._y, 'Y value should not be a mock value').not.toMatch( + MOCK_VALUE_PATTERN, + ); + expect(values._s, 'S value should not be a mock value').not.toMatch( + MOCK_VALUE_PATTERN, + ); + } + + /** + * Remove specific cookies by name + */ + async removeCookies(cookieNames: string[]) { + const cookies = await this.getCookies(); + const baseUrl = new URL(this.page.url()); + + for (const name of cookieNames) { + const cookie = cookies.find((c) => c.name === name); + if (cookie) { + await this.context.clearCookies({name, domain: baseUrl.hostname}); + } + } + } + + /** + * Remove HTTP-only analytics cookies (for migration testing) + */ + async removeHttpOnlyCookies() { + await this.removeCookies([ + '_shopify_analytics', + '_shopify_marketing', + '_shopify_essential', + '_shopify_essentials', + ]); + } + + /** + * Set the `withPrivacyBanner` value by intercepting the Hydrogen JS bundle. + * This injects code to directly set the value before Hydrogen's default check. + * Unlike HTML document interception, this preserves server-timing headers since they come + * from the document response, not from JS files. + * @param enable - Whether to enable (true) or disable (false) the privacy banner + */ + async setWithPrivacyBanner(enable: boolean) { + // Intercept the Hydrogen development bundle (Vite pre-bundled deps path) + await this.page.route( + '**/node_modules/.vite/deps/@shopify_hydrogen.js*', + async (route) => { + const response = await route.fetch(); + let body = await response.text(); + + // Inject code to set withPrivacyBanner BEFORE the default check: + // Original: if (consent.withPrivacyBanner === void 0) { ... + // Modified: consent.withPrivacyBanner = true/false; if (consent.withPrivacyBanner === void 0) { ... + body = body.replace( + /if\s*\(consent\.withPrivacyBanner\s*===\s*void 0\)/g, + `consent.withPrivacyBanner = ${enable}; $&`, + ); + + await route.fulfill({ + status: response.status(), + headers: response.headers(), + body, + }); + }, + ); + } +} diff --git a/e2e/specs/new-cookies/consent-tracking-accept.spec.ts b/e2e/specs/new-cookies/consent-tracking-accept.spec.ts new file mode 100644 index 0000000000..4e705c68b3 --- /dev/null +++ b/e2e/specs/new-cookies/consent-tracking-accept.spec.ts @@ -0,0 +1,270 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentAllowed_cookiesEnabled'); + +test.describe('Consent Tracking - Auto-Allowed (Consent Allowed by Default)', () => { + test('should set analytics cookies and fire analytics requests immediately when consent is allowed by default', async ({ + storefront, + }: Parameters[2]>[0]) => { + // Enable privacy banner setting (but banner shouldn't show since consent is auto-allowed) + await storefront.setWithPrivacyBanner(true); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Verify privacy banner does NOT appear (consent is already allowed by default) + await storefront.expectPrivacyBannerNotVisible(); + + // 3. Check server-timing values from navigation - these should be real UUIDs + const navigationServerTiming = await storefront.getServerTimingValues(); + + expect( + navigationServerTiming._y, + '_y value should be present in server-timing', + ).toBeTruthy(); + expect( + navigationServerTiming._s, + '_s value should be present in server-timing', + ).toBeTruthy(); + + // Verify they are real UUIDs (not mock values) + storefront.expectRealServerTimingValues(navigationServerTiming); + + // 4. Verify analytics cookies are set immediately (no user action needed) + const {shopifyY, shopifyS, shopifyAnalytics, shopifyMarketing} = + await storefront.expectAnalyticsCookiesPresent(); + + // Cookie values should match navigation server-timing values + // (unlike accept flow, values shouldn't change - consent was already allowed) + expect( + shopifyY!.value, + '_shopify_y cookie value should match navigation server-timing _y value', + ).toBe(navigationServerTiming._y); + + expect( + shopifyS!.value, + '_shopify_s cookie value should match navigation server-timing _s value', + ).toBe(navigationServerTiming._s); + + // Verify HTTP-only cookies are set + expect( + shopifyAnalytics, + '_shopify_analytics cookie should be present', + ).toBeDefined(); + expect( + shopifyMarketing, + '_shopify_marketing cookie should be present', + ).toBeDefined(); + + // Verify HTTP-only cookies have httpOnly flag set + expect( + shopifyAnalytics!.httpOnly, + '_shopify_analytics cookie should be HTTP-only', + ).toBe(true); + expect( + shopifyMarketing!.httpOnly, + '_shopify_marketing cookie should be HTTP-only', + ).toBe(true); + + // 5. Wait for perf-kit to download and analytics requests to fire + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // Wait for Monorail analytics requests + await storefront.waitForMonorailRequests(); + + // Verify the analytics requests contain the correct tracking values + storefront.verifyMonorailRequests( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'after page load', + ); + + // 6. Finalize perf-kit metrics before navigation + await storefront.finalizePerfKitMetrics(); + + // 7. Navigate to a product (triggers perf-kit to send metrics) + await storefront.navigateToFirstProduct(); + + // Wait for perf-kit to send metrics after visibility change + await storefront.page.waitForTimeout(500); + + // Verify perf-kit payload contains correct tracking values + storefront.verifyPerfKitRequests( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'after navigation', + ); + + // 8. Add to cart + await storefront.addToCart(); + + // 9. Verify server-timing values after cart mutation match the session values + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + + expect( + serverTimingAfterCart._y, + 'Server-timing _y should be present after cart mutation', + ).toBeTruthy(); + expect( + serverTimingAfterCart._s, + 'Server-timing _s should be present after cart mutation', + ).toBeTruthy(); + + // Values should match the original navigation values (same session) + expect( + serverTimingAfterCart._y, + 'Server-timing _y after cart mutation should match navigation value', + ).toBe(navigationServerTiming._y); + expect( + serverTimingAfterCart._s, + 'Server-timing _s after cart mutation should match navigation value', + ).toBe(navigationServerTiming._s); + + // 10. Verify checkout URLs contain tracking params matching session + await storefront.verifyCheckoutUrlTrackingParams( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'in cart drawer after adding to cart', + ); + + // 11. Reload and verify state persists + await storefront.reload(); + + // Verify privacy banner still doesn't show + await storefront.expectPrivacyBannerNotVisible(); + + // Verify cookies persist after reload + const cookiesAfterReload = await storefront.expectAnalyticsCookiesPresent(); + + // Verify server-timing values after reload match original navigation values + const serverTimingAfterReload = await storefront.getServerTimingValues(); + + expect( + serverTimingAfterReload._y, + 'Server-timing _y should be present after reload', + ).toBeTruthy(); + expect( + serverTimingAfterReload._s, + 'Server-timing _s should be present after reload', + ).toBeTruthy(); + + // Values should match the original navigation values (same session) + expect( + serverTimingAfterReload._y, + 'Server-timing _y after reload should match original navigation value', + ).toBe(navigationServerTiming._y); + expect( + serverTimingAfterReload._s, + 'Server-timing _s after reload should match original navigation value', + ).toBe(navigationServerTiming._s); + + // Cookies should also match + expect( + cookiesAfterReload.shopifyY?.value, + '_shopify_y cookie after reload should match navigation value', + ).toBe(navigationServerTiming._y); + expect( + cookiesAfterReload.shopifyS?.value, + '_shopify_s cookie after reload should match navigation value', + ).toBe(navigationServerTiming._s); + + // Wait for analytics requests after reload + await storefront.waitForMonorailRequests(); + + // Verify analytics events after reload use original tracking values + storefront.verifyMonorailRequests( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'after reload', + ); + + // === MIGRATION: Test upgrade from old cookies === + + // 12. Remove HTTP-only cookies but keep old _shopify_y/_shopify_s + await storefront.removeHttpOnlyCookies(); + + // Verify HTTP-only cookies are removed + const analyticsAfterRemoval = + await storefront.getCookie('_shopify_analytics'); + const marketingAfterRemoval = + await storefront.getCookie('_shopify_marketing'); + + expect( + analyticsAfterRemoval, + '_shopify_analytics should be removed', + ).toBeUndefined(); + expect( + marketingAfterRemoval, + '_shopify_marketing should be removed', + ).toBeUndefined(); + + // Verify old tracking cookies are still present + const yAfterRemoval = await storefront.getCookie('_shopify_y'); + const sAfterRemoval = await storefront.getCookie('_shopify_s'); + + expect(yAfterRemoval, '_shopify_y should still exist').toBeDefined(); + expect(sAfterRemoval, '_shopify_s should still exist').toBeDefined(); + expect(yAfterRemoval!.value, '_shopify_y value should be unchanged').toBe( + navigationServerTiming._y, + ); + expect(sAfterRemoval!.value, '_shopify_s value should be unchanged').toBe( + navigationServerTiming._s, + ); + + // Clear tracked requests before migration reload + storefront.clearRequests(); + + // 13. Reload to trigger migration + await storefront.page.reload(); + await storefront.page.waitForLoadState('networkidle'); + + // 14. Verify tracking values are preserved after migration + const serverTimingAfterMigration = await storefront.getServerTimingValues(); + + expect( + serverTimingAfterMigration._y, + 'Server-timing _y after migration should match original value', + ).toBe(navigationServerTiming._y); + expect( + serverTimingAfterMigration._s, + 'Server-timing _s after migration should match original value', + ).toBe(navigationServerTiming._s); + + // 15. Verify HTTP-only cookies are recreated with original values + const { + shopifyY: yAfterMigration, + shopifyS: sAfterMigration, + shopifyAnalytics: analyticsAfterMigration, + shopifyMarketing: marketingAfterMigration, + } = await storefront.expectAnalyticsCookiesPresent(); + + expect( + analyticsAfterMigration, + '_shopify_analytics should be recreated after migration', + ).toBeDefined(); + expect( + marketingAfterMigration, + '_shopify_marketing should be recreated after migration', + ).toBeDefined(); + + // Cookie values should match original tracking values + expect( + yAfterMigration!.value, + '_shopify_y should keep original value after migration', + ).toBe(navigationServerTiming._y); + expect( + sAfterMigration!.value, + '_shopify_s should keep original value after migration', + ).toBe(navigationServerTiming._s); + + // 16. Wait for analytics and verify they use original tracking values + await storefront.waitForMonorailRequests(); + + storefront.verifyMonorailRequests( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'after migration', + ); + }); +}); diff --git a/e2e/specs/new-cookies/consent-tracking-decline.spec.ts b/e2e/specs/new-cookies/consent-tracking-decline.spec.ts new file mode 100644 index 0000000000..04aef84cb0 --- /dev/null +++ b/e2e/specs/new-cookies/consent-tracking-decline.spec.ts @@ -0,0 +1,86 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesEnabled'); + +test.describe('Consent Tracking - No Banner (Declined by Default)', () => { + test('should not set analytics cookies or make analytics requests when privacy banner is disabled and consent is declined by default', async ({ + storefront, + }) => { + // Explicitly disable privacy banner (template default is false, but be explicit) + await storefront.setWithPrivacyBanner(false); + + // Set up wait for consent response BEFORE navigating (it fires during page load) + const consentResponsePromise = storefront.waitForConsentResponse(); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Wait for consent response to be processed + await consentResponsePromise; + + // 3. Verify privacy banner does NOT appear (disabled) + await storefront.expectPrivacyBannerNotVisible(); + + // 4. Verify server-timing values are now mock values + const serverTimingValues = await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(serverTimingValues); + + // 5. Verify analyticsProcessingAllowed returns false (consent declined by default) + const analyticsAllowed = await storefront.page.evaluate(() => { + try { + return window.Shopify?.customerPrivacy?.analyticsProcessingAllowed?.(); + } catch { + return undefined; + } + }); + + expect( + analyticsAllowed, + 'analyticsProcessingAllowed() should return false when consent is declined by default', + ).toBe(false); + + // 6. Verify no analytics cookies are set (consent declined) + await storefront.expectNoAnalyticsCookies(); + + // 7. Verify no Monorail analytics requests have been made + storefront.expectNoMonorailRequests(); + + // 8. Wait for perf-kit to be downloaded (it loads regardless of consent) + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // 9. Verify still no Monorail analytics requests after perf-kit loads + storefront.expectNoMonorailRequests(); + + // 10. Navigate to first product and add to cart + await storefront.navigateToFirstProduct(); + await storefront.addToCart(); + + // 11. Check server-timing from cart mutation - should be mock values + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(serverTimingAfterCart); + + // 12. Verify still no Monorail analytics requests after cart action + storefront.expectNoMonorailRequests(); + + // 13. Verify checkout URLs contain MOCK tracking params (consent declined) + await storefront.expectNoCheckoutUrlTrackingParams( + 'in cart drawer with consent declined by default', + ); + + // 14. Reload the page to verify state persists + await storefront.reload(); + + // Verify privacy banner still does NOT show (disabled) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify analytics cookies are still not present after reload + await storefront.expectNoAnalyticsCookies(); + + // Wait for perf-kit to be downloaded after reload + await storefront.waitForPerfKit(); + + // 15. Verify no Monorail analytics requests after reload + storefront.expectNoMonorailRequests(); + }); +}); diff --git a/e2e/specs/new-cookies/privacy-banner-accept.spec.ts b/e2e/specs/new-cookies/privacy-banner-accept.spec.ts new file mode 100644 index 0000000000..48c0ce1168 --- /dev/null +++ b/e2e/specs/new-cookies/privacy-banner-accept.spec.ts @@ -0,0 +1,213 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesEnabled'); + +test.describe('Privacy Banner - Accept Flow', () => { + test('should set analytics cookies and make analytics requests when user accepts consent', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception (preserves server-timing) + await storefront.setWithPrivacyBanner(true); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Check that server-timing values (_y and _s) are available via Performance API + const initialServerTimingValues = await storefront.getServerTimingValues(); + + expect( + initialServerTimingValues._y, + 'Initial _y value should be present in server-timing', + ).toBeTruthy(); + expect( + initialServerTimingValues._s, + 'Initial _s value should be present in server-timing', + ).toBeTruthy(); + expect(initialServerTimingValues._y!.length).toBeGreaterThan(0); + expect(initialServerTimingValues._s!.length).toBeGreaterThan(0); + + // 3. Verify no analytics requests have been made and no analytics cookies are present + await storefront.expectNoAnalyticsCookies(); + storefront.expectNoMonorailRequests(); + + // 4. Verify perf-kit script is not downloaded yet + storefront.expectPerfKitNotLoaded(); + + // 5. Verify privacy banner appears and click accept + await storefront.acceptPrivacyBanner(); + + // 6. Verify server-timing values changed after consent + // Get updated server timing values from the page (prefer latest resource entries) + const updatedServerTimingValues = + await storefront.getServerTimingValues(true); + + // Verify server-timing values after consent are different from initial values + // The consent response should bring back different _y and _s values + expect( + updatedServerTimingValues._y, + 'Updated _y value should be present after consent', + ).toBeTruthy(); + expect( + updatedServerTimingValues._s, + 'Updated _s value should be present after consent', + ).toBeTruthy(); + + // Verify values changed from initial (new tracking session after consent) + expect( + updatedServerTimingValues._y, + 'Server-timing _y should be different after consent', + ).not.toBe(initialServerTimingValues._y); + expect( + updatedServerTimingValues._s, + 'Server-timing _s should be different after consent', + ).not.toBe(initialServerTimingValues._s); + + // 7. Verify _shopify_y and _shopify_s cookies are created with server-timing values + const {shopifyY, shopifyS, shopifyAnalytics, shopifyMarketing} = + await storefront.expectAnalyticsCookiesPresent(); + + // The cookie values should match the LATEST server-timing values (from consent response) + expect( + shopifyY!.value, + '_shopify_y cookie value should match latest server-timing _y value', + ).toBe(updatedServerTimingValues._y); + + expect( + shopifyS!.value, + '_shopify_s cookie value should match latest server-timing _s value', + ).toBe(updatedServerTimingValues._s); + + // Verify HTTP-only cookies are set after consent + expect( + shopifyAnalytics, + '_shopify_analytics cookie should be present after accept', + ).toBeDefined(); + expect( + shopifyMarketing, + '_shopify_marketing cookie should be present after accept', + ).toBeDefined(); + + // Verify HTTP-only cookies have httpOnly flag set + expect( + shopifyAnalytics!.httpOnly, + '_shopify_analytics cookie should be HTTP-only', + ).toBe(true); + expect( + shopifyMarketing!.httpOnly, + '_shopify_marketing cookie should be HTTP-only', + ).toBe(true); + + // 8. Wait for perf-kit to download and analytics requests to fire + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // Wait for analytics requests to Monorail + await storefront.waitForMonorailRequests(); + + // Verify the analytics requests contain the correct _y and _s values + storefront.verifyMonorailRequests( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'after consent', + ); + + // 9. Finalize perf-kit metrics before navigation (triggers LCP finalization) + await storefront.finalizePerfKitMetrics(); + + // 10. Navigate to a product (this triggers perf-kit to send metrics via visibility change) + await storefront.navigateToFirstProduct(); + + // 11. Verify perf-kit payload contains correct tracking values + // Wait a moment for perf-kit to send its metrics after visibility change + await storefront.page.waitForTimeout(500); + + storefront.verifyPerfKitRequests( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'after navigation', + ); + + // 12. Add to cart + await storefront.addToCart(); + + // 13. Verify server-timing values after cart mutation match the session values + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + + expect( + serverTimingAfterCart._y, + 'Server-timing _y should be present after cart mutation', + ).toBeTruthy(); + expect( + serverTimingAfterCart._s, + 'Server-timing _s should be present after cart mutation', + ).toBeTruthy(); + + // Values should match the session established after consent + expect( + serverTimingAfterCart._y, + 'Server-timing _y after cart mutation should match session value', + ).toBe(updatedServerTimingValues._y); + expect( + serverTimingAfterCart._s, + 'Server-timing _s after cart mutation should match session value', + ).toBe(updatedServerTimingValues._s); + + // 14. Verify checkout URLs in cart drawer contain tracking params + await storefront.verifyCheckoutUrlTrackingParams( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'in cart drawer after adding to cart', + ); + + // 15. Reload the page and verify state is preserved + await storefront.reload(); + + // Verify privacy banner does NOT show up on reload (consent was saved) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify cookies are still present after reload + const cookiesAfterReload = await storefront.expectAnalyticsCookiesPresent(); + + // Verify server-timing values after reload match the values from before reload (same session) + const serverTimingAfterReload = await storefront.getServerTimingValues(); + + expect( + serverTimingAfterReload._y, + 'Server-timing _y should be present after reload', + ).toBeTruthy(); + expect( + serverTimingAfterReload._s, + 'Server-timing _s should be present after reload', + ).toBeTruthy(); + + // Values should match the session established after consent (before reload) + expect( + serverTimingAfterReload._y, + 'Server-timing _y after reload should match value from before reload', + ).toBe(updatedServerTimingValues._y); + expect( + serverTimingAfterReload._s, + 'Server-timing _s after reload should match value from before reload', + ).toBe(updatedServerTimingValues._s); + + // Cookies should also match the server-timing values + expect( + cookiesAfterReload.shopifyY?.value, + '_shopify_y cookie after reload should match server-timing _y', + ).toBe(updatedServerTimingValues._y); + expect( + cookiesAfterReload.shopifyS?.value, + '_shopify_s cookie after reload should match server-timing _s', + ).toBe(updatedServerTimingValues._s); + + // Wait for analytics requests after reload + await storefront.waitForMonorailRequests(); + + // Verify analytics events after reload have correct values (matching session from before reload) + storefront.verifyMonorailRequests( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'after reload', + ); + }); +}); diff --git a/e2e/specs/new-cookies/privacy-banner-consent-change.spec.ts b/e2e/specs/new-cookies/privacy-banner-consent-change.spec.ts new file mode 100644 index 0000000000..b3d07a98b4 --- /dev/null +++ b/e2e/specs/new-cookies/privacy-banner-consent-change.spec.ts @@ -0,0 +1,259 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesEnabled'); + +test.describe('Privacy Banner - Consent Change', () => { + test.describe('Accept → Decline', () => { + test('should stop analytics when user revokes consent', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception + await storefront.setWithPrivacyBanner(true); + + // === SETUP: Accept consent initially === + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Accept privacy banner to establish consent + await storefront.acceptPrivacyBanner(); + + // 3. Get the established Y/S values after consent + const establishedServerTiming = + await storefront.getServerTimingValues(true); + + // Verify they are real UUIDs (not mock values) + storefront.expectRealServerTimingValues(establishedServerTiming); + + const originalYValue = establishedServerTiming._y!; + const originalSValue = establishedServerTiming._s!; + + // 4. Verify all analytics cookies are present + const {shopifyY, shopifyS, shopifyAnalytics, shopifyMarketing} = + await storefront.expectAnalyticsCookiesPresent(); + + expect(shopifyY!.value, '_shopify_y should match server-timing').toBe( + originalYValue, + ); + expect(shopifyS!.value, '_shopify_s should match server-timing').toBe( + originalSValue, + ); + expect( + shopifyAnalytics, + '_shopify_analytics should be present after accept', + ).toBeDefined(); + expect( + shopifyMarketing, + '_shopify_marketing should be present after accept', + ).toBeDefined(); + + // 5. Wait for analytics to fire to confirm tracking is working + await storefront.waitForPerfKit(); + await storefront.waitForMonorailRequests(); + + // Verify analytics requests have correct tracking values + storefront.verifyMonorailRequests( + originalYValue, + originalSValue, + 'after initial accept', + ); + + // Clear tracked requests before consent change + storefront.clearRequests(); + + // === CONSENT CHANGE: Decline via preferences === + + // 6. Open privacy preferences and decline consent + await storefront.openPrivacyPreferences(); + await storefront.declineInPreferences(); + + // 7. Verify _shopify_essential cookie is set after declining + await storefront.expectEssentialCookiePresent(); + + // 8. Verify analytics/marketing cookies are no longer valid (cleared or invalidated) + await storefront.expectNoAnalyticsCookies(); + + // 9. Verify server-timing values are now mock values (consent revoked) + const serverTimingAfterDecline = + await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(serverTimingAfterDecline); + + // 10. Wait and verify no NEW analytics requests are made after revoking consent + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + + // 11. Navigate to a product page to verify no tracking on navigation + await storefront.navigateToFirstProduct(); + + // Still no analytics requests after navigation + storefront.expectNoMonorailRequests(); + + // 12. Add to cart and verify checkout URLs have mock tracking params + await storefront.addToCart(); + await storefront.expectNoCheckoutUrlTrackingParams( + 'after revoking consent', + ); + + // 13. Reload the page to verify persistence + await storefront.reload(); + + // Verify privacy banner does NOT show (consent choice was saved) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify analytics cookies are still not present after reload + await storefront.expectNoAnalyticsCookies(); + + // Verify essential cookie persists + await storefront.expectEssentialCookiePresent(); + + // Wait and verify no analytics requests after reload + await storefront.waitForPerfKit(); + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + }); + }); + + test.describe('Decline → Accept', () => { + test('should start analytics when user grants consent', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception (preserves server-timing) + await storefront.setWithPrivacyBanner(true); + + // === SETUP: Decline consent initially === + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Decline privacy banner + await storefront.declinePrivacyBanner(); + + // 3. Verify _shopify_essential cookie is set after declining + await storefront.expectEssentialCookiePresent(); + + // 4. Verify no analytics cookies are present + await storefront.expectNoAnalyticsCookies(); + + // 5. Verify server-timing values are mock values + const initialServerTiming = await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(initialServerTiming); + + // 6. Wait for perf-kit to load but verify no analytics requests + await storefront.waitForPerfKit(); + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + + // Clear tracked requests before consent change + storefront.clearRequests(); + + // === CONSENT CHANGE: Accept via preferences === + + // 7. Open privacy preferences and accept consent + await storefront.openPrivacyPreferences(); + await storefront.acceptInPreferences(); + + // 8. Verify server-timing values changed to real UUIDs after consent + const serverTimingAfterAccept = + await storefront.getServerTimingValues(true); + + storefront.expectRealServerTimingValues(serverTimingAfterAccept); + + const newYValue = serverTimingAfterAccept._y!; + const newSValue = serverTimingAfterAccept._s!; + + // 9. Verify analytics cookies are now present + const {shopifyY, shopifyS, shopifyAnalytics, shopifyMarketing} = + await storefront.expectAnalyticsCookiesPresent(); + + // Cookie values should match the new server-timing values + expect( + shopifyY!.value, + '_shopify_y cookie should match new server-timing _y value', + ).toBe(newYValue); + + expect( + shopifyS!.value, + '_shopify_s cookie should match new server-timing _s value', + ).toBe(newSValue); + + // Verify HTTP-only cookies are set + expect( + shopifyAnalytics, + '_shopify_analytics cookie should be present after accepting', + ).toBeDefined(); + expect( + shopifyMarketing, + '_shopify_marketing cookie should be present after accepting', + ).toBeDefined(); + + // 10. Wait for analytics requests to fire after granting consent + await storefront.waitForMonorailRequests(); + + // Verify analytics requests contain the correct tracking values + storefront.verifyMonorailRequests( + newYValue, + newSValue, + 'after granting consent via preferences', + ); + + // 11. Navigate to a product page + await storefront.finalizePerfKitMetrics(); + await storefront.navigateToFirstProduct(); + + // Note: We skip perf-kit request verification here because it captures Y/S values + // only when its script is first downloaded so it won't update the values after changing + // consent mid-session. This is a bug in perf-kit that needs to be fixed separately. + // The Monorail requests above already verify tracking is working correctly. + + // 12. Add to cart and verify checkout URLs have real tracking params + await storefront.addToCart(); + await storefront.verifyCheckoutUrlTrackingParams( + newYValue, + newSValue, + 'after granting consent', + ); + + // 13. Reload the page to verify persistence + await storefront.reload(); + + // Verify privacy banner does NOT show (consent choice was saved) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify cookies persist after reload + const cookiesAfterReload = + await storefront.expectAnalyticsCookiesPresent(); + + // Verify server-timing values match + const serverTimingAfterReload = await storefront.getServerTimingValues(); + + expect( + serverTimingAfterReload._y, + 'Server-timing _y after reload should match value from before reload', + ).toBe(newYValue); + expect( + serverTimingAfterReload._s, + 'Server-timing _s after reload should match value from before reload', + ).toBe(newSValue); + + // Cookies should also match + expect( + cookiesAfterReload.shopifyY?.value, + '_shopify_y cookie after reload should match server-timing _y', + ).toBe(newYValue); + expect( + cookiesAfterReload.shopifyS?.value, + '_shopify_s cookie after reload should match server-timing _s', + ).toBe(newSValue); + + // Wait for analytics requests after reload + await storefront.waitForMonorailRequests(); + + // Verify analytics events after reload have correct values + storefront.verifyMonorailRequests( + newYValue, + newSValue, + 'after reload with consent granted', + ); + }); + }); +}); diff --git a/e2e/specs/new-cookies/privacy-banner-decline.spec.ts b/e2e/specs/new-cookies/privacy-banner-decline.spec.ts new file mode 100644 index 0000000000..d12ba6f73b --- /dev/null +++ b/e2e/specs/new-cookies/privacy-banner-decline.spec.ts @@ -0,0 +1,97 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesEnabled'); + +test.describe('Privacy Banner - Decline Flow', () => { + test('should not set analytics cookies or make analytics requests when user declines consent', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception (preserves server-timing) + await storefront.setWithPrivacyBanner(true); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Check that server-timing values (_y and _s) are available via Performance API + const initialServerTimingValues = await storefront.getServerTimingValues(); + + expect( + initialServerTimingValues._y, + 'Initial _y value should be present in server-timing', + ).toBeTruthy(); + expect( + initialServerTimingValues._s, + 'Initial _s value should be present in server-timing', + ).toBeTruthy(); + expect(initialServerTimingValues._y!.length).toBeGreaterThan(0); + expect(initialServerTimingValues._s!.length).toBeGreaterThan(0); + + // 3. Verify no analytics cookies are set yet and no analytics requests have been made + await storefront.expectNoAnalyticsCookies(); + storefront.expectNoMonorailRequests(); + + // 4. Verify perf-kit script is not downloaded yet + storefront.expectPerfKitNotLoaded(); + + // 5. Verify privacy banner appears and click decline + await storefront.declinePrivacyBanner(); + + // 6. Verify _shopify_essential cookie is set after declining + await storefront.expectEssentialCookiePresent(); + + // 7. Verify analytics/marketing cookies are NOT set after declining + await storefront.expectNoAnalyticsCookies(); + + // 8. Verify server-timing values after decline are either absent or contain mock values + const updatedServerTimingValues = + await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(updatedServerTimingValues); + + // 9. Verify perf-kit is downloaded after declining + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // 10. Wait and verify no analytics requests are made + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + + // 11. Navigate to first product and add to cart to verify server-timing mock values + await storefront.navigateToFirstProduct(); + + // Add item to cart + await storefront.addToCart(); + + // Check server-timing from the latest resource (cart mutation response) + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + + // Server-timing _y and _s should be mock values after cart action (consent was declined) + storefront.expectMockServerTimingValues(serverTimingAfterCart); + + // Verify still no analytics requests after cart action + storefront.expectNoMonorailRequests(); + + // 12. Verify checkout URLs contain MOCK tracking params (consent declined) + await storefront.expectNoCheckoutUrlTrackingParams( + 'in cart drawer after declining consent', + ); + + // 13. Reload the page to verify persistence + await storefront.reload(); + + // Verify privacy banner does NOT show up on reload (consent was saved) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify _shopify_essential cookie persists after reload + await storefront.expectEssentialCookiePresent(); + + // Verify analytics cookies are still not present after reload + await storefront.expectNoAnalyticsCookies(); + + // Wait for perf-kit to be downloaded after reload + await storefront.waitForPerfKit(); + + // Wait and verify no analytics requests after reload + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + }); +}); diff --git a/e2e/specs/new-cookies/privacy-banner-migration.spec.ts b/e2e/specs/new-cookies/privacy-banner-migration.spec.ts new file mode 100644 index 0000000000..0fc9ee0a19 --- /dev/null +++ b/e2e/specs/new-cookies/privacy-banner-migration.spec.ts @@ -0,0 +1,215 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesEnabled'); + +test.describe('Privacy Banner - Session Migration', () => { + test.describe('Consent Allowed (with existing old tracking cookies)', () => { + test('should preserve tracking values from old cookies after migration', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception (preserves server-timing) + await storefront.setWithPrivacyBanner(true); + + // === SETUP: Establish consent and get initial tracking values === + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Accept privacy banner to establish consent + await storefront.acceptPrivacyBanner(); + + // 3. Get the established Y/S values after consent + const establishedServerTiming = + await storefront.getServerTimingValues(true); + + // Verify they are real UUIDs (not mock values) + storefront.expectRealServerTimingValues(establishedServerTiming); + + const originalYValue = establishedServerTiming._y!; + const originalSValue = establishedServerTiming._s!; + + // 4. Verify all cookies are present + const {shopifyY, shopifyS} = + await storefront.expectAnalyticsCookiesPresent(); + + expect(shopifyY!.value, '_shopify_y should match server-timing').toBe( + originalYValue, + ); + expect(shopifyS!.value, '_shopify_s should match server-timing').toBe( + originalSValue, + ); + + // === MIGRATION: Remove new cookies but keep old ones === + + // 5. Remove ONLY the new HTTP-only cookies (simulate migration scenario) + // Keep: _shopify_y, _shopify_s (old tracking cookies) + // Remove: _shopify_analytics, _shopify_marketing, _shopify_essential(s) + await storefront.removeHttpOnlyCookies(); + + // Verify HTTP-only cookies are removed + const analyticsAfterRemoval = + await storefront.getCookie('_shopify_analytics'); + const marketingAfterRemoval = + await storefront.getCookie('_shopify_marketing'); + + expect( + analyticsAfterRemoval, + '_shopify_analytics should be removed', + ).toBeUndefined(); + expect( + marketingAfterRemoval, + '_shopify_marketing should be removed', + ).toBeUndefined(); + + // Verify old tracking cookies are still present + const yAfterRemoval = await storefront.getCookie('_shopify_y'); + const sAfterRemoval = await storefront.getCookie('_shopify_s'); + + expect(yAfterRemoval, '_shopify_y should still exist').toBeDefined(); + expect(sAfterRemoval, '_shopify_s should still exist').toBeDefined(); + expect(yAfterRemoval!.value, '_shopify_y value should be unchanged').toBe( + originalYValue, + ); + expect(sAfterRemoval!.value, '_shopify_s value should be unchanged').toBe( + originalSValue, + ); + + // Clear tracked requests before reload + storefront.clearRequests(); + + // 6. Reload the page - this initiates the migration test + await storefront.page.reload(); + await storefront.page.waitForLoadState('networkidle'); + + // === VERIFY: Tracking values should be preserved after migration === + + // 7. Privacy banner should NOT show (consent was previously saved) + await storefront.expectPrivacyBannerNotVisible(); + + // 8. Verify server-timing Y/S values match the original established values + const serverTimingAfterReload = await storefront.getServerTimingValues(); + + expect( + serverTimingAfterReload._y, + 'Server-timing _y after migration should match original value', + ).toBe(originalYValue); + expect( + serverTimingAfterReload._s, + 'Server-timing _s after migration should match original value', + ).toBe(originalSValue); + + // 9. Verify new HTTP-only cookies are recreated with correct values + const { + shopifyY: yAfterReload, + shopifyS: sAfterReload, + shopifyAnalytics: analyticsAfterReload, + shopifyMarketing: marketingAfterReload, + } = await storefront.expectAnalyticsCookiesPresent(); + + expect( + analyticsAfterReload, + '_shopify_analytics should be recreated after migration', + ).toBeDefined(); + expect( + marketingAfterReload, + '_shopify_marketing should be recreated after migration', + ).toBeDefined(); + + // Verify cookie values match original tracking session + expect( + yAfterReload!.value, + '_shopify_y should keep original value after migration', + ).toBe(originalYValue); + expect( + sAfterReload!.value, + '_shopify_s should keep original value after migration', + ).toBe(originalSValue); + + // 10. Wait for analytics requests and verify they use original tracking values + await storefront.waitForPerfKit(); + await storefront.waitForMonorailRequests(); + + storefront.verifyMonorailRequests( + originalYValue, + originalSValue, + 'after migration', + ); + }); + }); + + test.describe('Consent Declined (no existing tracking cookies)', () => { + test('should not send analytics or set tracking params when consent was declined', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception (preserves server-timing) + await storefront.setWithPrivacyBanner(true); + + // === SETUP: Decline consent === + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Decline privacy banner + await storefront.declinePrivacyBanner(); + + // 3. Verify _shopify_essential cookie is set after declining + await storefront.expectEssentialCookiePresent(); + + // 4. Verify analytics cookies are NOT set + await storefront.expectNoAnalyticsCookies(); + + // === MIGRATION: Remove new cookies AND old tracking cookies === + + // 5. Remove HTTP-only cookies AND _shopify_y and _shopify_s + // This simulates a migration where user had declined consent and + // we're testing the system handles missing tracking cookies correctly + await storefront.removeHttpOnlyCookies(); + await storefront.removeCookies(['_shopify_y', '_shopify_s']); + + // Verify all tracking-related cookies are removed + const yAfterRemoval = await storefront.getCookie('_shopify_y'); + const sAfterRemoval = await storefront.getCookie('_shopify_s'); + const analyticsAfterRemoval = + await storefront.getCookie('_shopify_analytics'); + const marketingAfterRemoval = + await storefront.getCookie('_shopify_marketing'); + + expect(yAfterRemoval, '_shopify_y should be removed').toBeUndefined(); + expect(sAfterRemoval, '_shopify_s should be removed').toBeUndefined(); + expect( + analyticsAfterRemoval, + '_shopify_analytics should be removed', + ).toBeUndefined(); + expect( + marketingAfterRemoval, + '_shopify_marketing should be removed', + ).toBeUndefined(); + + // Clear tracked requests before reload + storefront.clearRequests(); + + // 6. Reload the page - this initiates the migration test + await storefront.page.reload(); + await storefront.page.waitForLoadState('networkidle'); + + // === VERIFY: No tracking should occur === + + // 7. Privacy banner should NOT show (consent choice was previously saved) + await storefront.expectPrivacyBannerNotVisible(); + + // 8. Verify server-timing values are mock values (not real tracking values) + const serverTimingAfterReload = await storefront.getServerTimingValues(); + storefront.expectMockServerTimingValues(serverTimingAfterReload); + + // 9. Verify analytics cookies are still NOT present + await storefront.expectNoAnalyticsCookies(); + + // 10. Wait for perf-kit to load (it should still load for performance metrics) + await storefront.waitForPerfKit(); + + // 11. Wait and verify NO Monorail analytics requests are made + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + }); + }); +}); diff --git a/e2e/specs/old-cookies/consent-tracking-accept.spec.ts b/e2e/specs/old-cookies/consent-tracking-accept.spec.ts new file mode 100644 index 0000000000..91be8e8867 --- /dev/null +++ b/e2e/specs/old-cookies/consent-tracking-accept.spec.ts @@ -0,0 +1,162 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentAllowed_cookiesDisabled'); + +test.describe('Consent Tracking - Auto-Allowed (Consent Allowed by Default)', () => { + test('should set analytics cookies and fire analytics requests immediately when consent is allowed by default', async ({ + storefront, + }: Parameters[2]>[0]) => { + // Enable privacy banner setting (but banner shouldn't show since consent is auto-allowed) + await storefront.setWithPrivacyBanner(true); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Verify privacy banner does NOT appear (consent is already allowed by default) + await storefront.expectPrivacyBannerNotVisible(); + + // 3. Check server-timing values from navigation - these should be real UUIDs + const navigationServerTiming = await storefront.getServerTimingValues(); + + expect( + navigationServerTiming._y, + '_y value should be present in server-timing', + ).toBeTruthy(); + expect( + navigationServerTiming._s, + '_s value should be present in server-timing', + ).toBeTruthy(); + + // Verify they are real UUIDs (not mock values) + storefront.expectRealServerTimingValues(navigationServerTiming); + + // 4. Verify analytics cookies are set immediately (no user action needed) + const {shopifyY, shopifyS} = + await storefront.expectAnalyticsCookiesPresent(); + + // Cookie values should match navigation server-timing values + // (unlike accept flow, values shouldn't change - consent was already allowed) + expect( + shopifyY!.value, + '_shopify_y cookie value should match navigation server-timing _y value', + ).toBe(navigationServerTiming._y); + + expect( + shopifyS!.value, + '_shopify_s cookie value should match navigation server-timing _s value', + ).toBe(navigationServerTiming._s); + + // 5. Wait for perf-kit to download and analytics requests to fire + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // Wait for Monorail analytics requests + await storefront.waitForMonorailRequests(); + + // Verify the analytics requests contain the correct tracking values + storefront.verifyMonorailRequests( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'after page load', + ); + + // 6. Finalize perf-kit metrics before navigation + await storefront.finalizePerfKitMetrics(); + + // 7. Navigate to a product (triggers perf-kit to send metrics) + await storefront.navigateToFirstProduct(); + + // Wait for perf-kit to send metrics after visibility change + await storefront.page.waitForTimeout(500); + + // Verify perf-kit payload contains correct tracking values + storefront.verifyPerfKitRequests( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'after navigation', + ); + + // 8. Add to cart + await storefront.addToCart(); + + // 9. Verify server-timing values after cart mutation match the session values + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + + expect( + serverTimingAfterCart._y, + 'Server-timing _y should be present after cart mutation', + ).toBeTruthy(); + expect( + serverTimingAfterCart._s, + 'Server-timing _s should be present after cart mutation', + ).toBeTruthy(); + + // Values should match the original navigation values (same session) + expect( + serverTimingAfterCart._y, + 'Server-timing _y after cart mutation should match navigation value', + ).toBe(navigationServerTiming._y); + expect( + serverTimingAfterCart._s, + 'Server-timing _s after cart mutation should match navigation value', + ).toBe(navigationServerTiming._s); + + // 10. Verify checkout URLs contain tracking params matching session + await storefront.verifyCheckoutUrlTrackingParams( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'in cart drawer after adding to cart', + ); + + // 11. Reload and verify state persists + await storefront.reload(); + + // Verify privacy banner still doesn't show + await storefront.expectPrivacyBannerNotVisible(); + + // Verify cookies persist after reload + const cookiesAfterReload = await storefront.expectAnalyticsCookiesPresent(); + + // Verify server-timing values after reload match original navigation values + const serverTimingAfterReload = await storefront.getServerTimingValues(); + + expect( + serverTimingAfterReload._y, + 'Server-timing _y should be present after reload', + ).toBeTruthy(); + expect( + serverTimingAfterReload._s, + 'Server-timing _s should be present after reload', + ).toBeTruthy(); + + // Values should match the original navigation values (same session) + expect( + serverTimingAfterReload._y, + 'Server-timing _y after reload should match original navigation value', + ).toBe(navigationServerTiming._y); + expect( + serverTimingAfterReload._s, + 'Server-timing _s after reload should match original navigation value', + ).toBe(navigationServerTiming._s); + + // Cookies should also match + expect( + cookiesAfterReload.shopifyY?.value, + '_shopify_y cookie after reload should match navigation value', + ).toBe(navigationServerTiming._y); + expect( + cookiesAfterReload.shopifyS?.value, + '_shopify_s cookie after reload should match navigation value', + ).toBe(navigationServerTiming._s); + + // Wait for analytics requests after reload + await storefront.waitForMonorailRequests(); + + // Verify analytics events after reload use original tracking values + storefront.verifyMonorailRequests( + navigationServerTiming._y!, + navigationServerTiming._s!, + 'after reload', + ); + }); +}); diff --git a/e2e/specs/old-cookies/consent-tracking-decline.spec.ts b/e2e/specs/old-cookies/consent-tracking-decline.spec.ts new file mode 100644 index 0000000000..faae4ab517 --- /dev/null +++ b/e2e/specs/old-cookies/consent-tracking-decline.spec.ts @@ -0,0 +1,86 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesDisabled'); + +test.describe('Consent Tracking - No Banner (Declined by Default)', () => { + test('should not set analytics cookies or make analytics requests when privacy banner is disabled and consent is declined by default', async ({ + storefront, + }) => { + // Explicitly disable privacy banner (template default is false, but be explicit) + await storefront.setWithPrivacyBanner(false); + + // Set up wait for consent response BEFORE navigating (it fires during page load) + const consentResponsePromise = storefront.waitForConsentResponse(); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Wait for consent response to be processed + await consentResponsePromise; + + // 3. Verify privacy banner does NOT appear (disabled) + await storefront.expectPrivacyBannerNotVisible(); + + // 4. Verify server-timing values are now mock values + const serverTimingValues = await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(serverTimingValues); + + // 5. Verify analyticsProcessingAllowed returns false (consent declined by default) + const analyticsAllowed = await storefront.page.evaluate(() => { + try { + return window.Shopify?.customerPrivacy?.analyticsProcessingAllowed?.(); + } catch { + return undefined; + } + }); + + expect( + analyticsAllowed, + 'analyticsProcessingAllowed() should return false when consent is declined by default', + ).toBe(false); + + // 6. Verify no analytics cookies are set (consent declined) + await storefront.expectNoAnalyticsCookies(); + + // 7. Verify no Monorail analytics requests have been made + storefront.expectNoMonorailRequests(); + + // 8. Wait for perf-kit to be downloaded (it loads regardless of consent) + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // 9. Verify still no Monorail analytics requests after perf-kit loads + storefront.expectNoMonorailRequests(); + + // 10. Navigate to first product and add to cart + await storefront.navigateToFirstProduct(); + await storefront.addToCart(); + + // 11. Check server-timing from cart mutation - should be mock values + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(serverTimingAfterCart); + + // 12. Verify still no Monorail analytics requests after cart action + storefront.expectNoMonorailRequests(); + + // 13. Verify checkout URLs contain MOCK tracking params (consent declined) + await storefront.expectNoCheckoutUrlTrackingParams( + 'in cart drawer with consent declined by default', + ); + + // 14. Reload the page to verify state persists + await storefront.reload(); + + // Verify privacy banner still does NOT show (disabled) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify analytics cookies are still not present after reload + await storefront.expectNoAnalyticsCookies(); + + // Wait for perf-kit to be downloaded after reload + await storefront.waitForPerfKit(); + + // 15. Verify no Monorail analytics requests after reload + storefront.expectNoMonorailRequests(); + }); +}); diff --git a/e2e/specs/old-cookies/privacy-banner-accept.spec.ts b/e2e/specs/old-cookies/privacy-banner-accept.spec.ts new file mode 100644 index 0000000000..f692a7e561 --- /dev/null +++ b/e2e/specs/old-cookies/privacy-banner-accept.spec.ts @@ -0,0 +1,194 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesDisabled'); + +test.describe('Privacy Banner - Accept Flow', () => { + test('should set analytics cookies and make analytics requests when user accepts consent', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception (preserves server-timing) + await storefront.setWithPrivacyBanner(true); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Check that server-timing values (_y and _s) are available via Performance API + const initialServerTimingValues = await storefront.getServerTimingValues(); + + expect( + initialServerTimingValues._y, + 'Initial _y value should be present in server-timing', + ).toBeTruthy(); + expect( + initialServerTimingValues._s, + 'Initial _s value should be present in server-timing', + ).toBeTruthy(); + expect(initialServerTimingValues._y!.length).toBeGreaterThan(0); + expect(initialServerTimingValues._s!.length).toBeGreaterThan(0); + + // 3. Verify no analytics requests have been made and no analytics cookies are present + await storefront.expectNoAnalyticsCookies(); + storefront.expectNoMonorailRequests(); + + // 4. Verify perf-kit script is not downloaded yet + storefront.expectPerfKitNotLoaded(); + + // 5. Verify privacy banner appears and click accept + await storefront.acceptPrivacyBanner(); + + // 6. Verify server-timing values changed after consent + // Get updated server timing values from the page (prefer latest resource entries) + const updatedServerTimingValues = + await storefront.getServerTimingValues(true); + + // Verify server-timing values after consent are different from initial values + // The consent response should bring back different _y and _s values + expect( + updatedServerTimingValues._y, + 'Updated _y value should be present after consent', + ).toBeTruthy(); + expect( + updatedServerTimingValues._s, + 'Updated _s value should be present after consent', + ).toBeTruthy(); + + // Verify values changed from initial (new tracking session after consent) + expect( + updatedServerTimingValues._y, + 'Server-timing _y should be different after consent', + ).not.toBe(initialServerTimingValues._y); + expect( + updatedServerTimingValues._s, + 'Server-timing _s should be different after consent', + ).not.toBe(initialServerTimingValues._s); + + // 7. Verify _shopify_y and _shopify_s cookies are created with server-timing values + // Note: Old cookie system doesn't have HTTP-only _shopify_analytics/_shopify_marketing cookies + const {shopifyY, shopifyS} = + await storefront.expectAnalyticsCookiesPresent(); + + // The cookie values should match the LATEST server-timing values (from consent response) + expect( + shopifyY!.value, + '_shopify_y cookie value should match latest server-timing _y value', + ).toBe(updatedServerTimingValues._y); + + expect( + shopifyS!.value, + '_shopify_s cookie value should match latest server-timing _s value', + ).toBe(updatedServerTimingValues._s); + + // 8. Wait for perf-kit to download and analytics requests to fire + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // Wait for analytics requests to Monorail + await storefront.waitForMonorailRequests(); + + // Verify the analytics requests contain the correct _y and _s values + storefront.verifyMonorailRequests( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'after consent', + ); + + // 9. Finalize perf-kit metrics before navigation (triggers LCP finalization) + await storefront.finalizePerfKitMetrics(); + + // 10. Navigate to a product (this triggers perf-kit to send metrics via visibility change) + await storefront.navigateToFirstProduct(); + + // 11. Verify perf-kit payload contains correct tracking values + // Wait a moment for perf-kit to send its metrics after visibility change + await storefront.page.waitForTimeout(500); + + storefront.verifyPerfKitRequests( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'after navigation', + ); + + // 12. Add to cart + await storefront.addToCart(); + + // 13. Verify server-timing values after cart mutation match the session values + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + + expect( + serverTimingAfterCart._y, + 'Server-timing _y should be present after cart mutation', + ).toBeTruthy(); + expect( + serverTimingAfterCart._s, + 'Server-timing _s should be present after cart mutation', + ).toBeTruthy(); + + // Values should match the session established after consent + expect( + serverTimingAfterCart._y, + 'Server-timing _y after cart mutation should match session value', + ).toBe(updatedServerTimingValues._y); + expect( + serverTimingAfterCart._s, + 'Server-timing _s after cart mutation should match session value', + ).toBe(updatedServerTimingValues._s); + + // 14. Verify checkout URLs in cart drawer contain tracking params + await storefront.verifyCheckoutUrlTrackingParams( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'in cart drawer after adding to cart', + ); + + // 15. Reload the page and verify state is preserved + await storefront.reload(); + + // Verify privacy banner does NOT show up on reload (consent was saved) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify cookies are still present after reload + const cookiesAfterReload = await storefront.expectAnalyticsCookiesPresent(); + + // Verify server-timing values after reload match the values from before reload (same session) + const serverTimingAfterReload = await storefront.getServerTimingValues(); + + expect( + serverTimingAfterReload._y, + 'Server-timing _y should be present after reload', + ).toBeTruthy(); + expect( + serverTimingAfterReload._s, + 'Server-timing _s should be present after reload', + ).toBeTruthy(); + + // Values should match the session established after consent (before reload) + expect( + serverTimingAfterReload._y, + 'Server-timing _y after reload should match value from before reload', + ).toBe(updatedServerTimingValues._y); + expect( + serverTimingAfterReload._s, + 'Server-timing _s after reload should match value from before reload', + ).toBe(updatedServerTimingValues._s); + + // Cookies should also match the server-timing values + expect( + cookiesAfterReload.shopifyY?.value, + '_shopify_y cookie after reload should match server-timing _y', + ).toBe(updatedServerTimingValues._y); + expect( + cookiesAfterReload.shopifyS?.value, + '_shopify_s cookie after reload should match server-timing _s', + ).toBe(updatedServerTimingValues._s); + + // Wait for analytics requests after reload + await storefront.waitForMonorailRequests(); + + // Verify analytics events after reload have correct values (matching session from before reload) + storefront.verifyMonorailRequests( + updatedServerTimingValues._y!, + updatedServerTimingValues._s!, + 'after reload', + ); + }); +}); diff --git a/e2e/specs/old-cookies/privacy-banner-decline.spec.ts b/e2e/specs/old-cookies/privacy-banner-decline.spec.ts new file mode 100644 index 0000000000..47dab01e28 --- /dev/null +++ b/e2e/specs/old-cookies/privacy-banner-decline.spec.ts @@ -0,0 +1,97 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('defaultConsentDisallowed_cookiesDisabled'); + +test.describe('Privacy Banner - Decline Flow', () => { + test('should not set analytics cookies or make analytics requests when user declines consent', async ({ + storefront, + }) => { + // Enable privacy banner via JS bundle interception (preserves server-timing) + await storefront.setWithPrivacyBanner(true); + + // 1. Navigate to main page + await storefront.goto('/'); + + // 2. Check that server-timing values (_y and _s) are available via Performance API + const initialServerTimingValues = await storefront.getServerTimingValues(); + + expect( + initialServerTimingValues._y, + 'Initial _y value should be present in server-timing', + ).toBeTruthy(); + expect( + initialServerTimingValues._s, + 'Initial _s value should be present in server-timing', + ).toBeTruthy(); + expect(initialServerTimingValues._y!.length).toBeGreaterThan(0); + expect(initialServerTimingValues._s!.length).toBeGreaterThan(0); + + // 3. Verify no analytics cookies are set yet and no analytics requests have been made + await storefront.expectNoAnalyticsCookies(); + storefront.expectNoMonorailRequests(); + + // 4. Verify perf-kit script is not downloaded yet + storefront.expectPerfKitNotLoaded(); + + // 5. Verify privacy banner appears and click decline + await storefront.declinePrivacyBanner(); + + // 6. Verify _shopify_essential cookie is set after declining + await storefront.expectEssentialCookiePresent(); + + // 7. Verify analytics/marketing cookies are NOT set after declining + await storefront.expectNoAnalyticsCookies(); + + // 8. Verify server-timing values after decline are either absent or contain mock values + const updatedServerTimingValues = + await storefront.getServerTimingValues(true); + storefront.expectMockServerTimingValues(updatedServerTimingValues); + + // 9. Verify perf-kit is downloaded after declining + await storefront.waitForPerfKit(); + storefront.expectPerfKitLoaded(); + + // 10. Wait and verify no analytics requests are made + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + + // 11. Navigate to first product and add to cart to verify server-timing mock values + await storefront.navigateToFirstProduct(); + + // Add item to cart + await storefront.addToCart(); + + // Check server-timing from the latest resource (cart mutation response) + const serverTimingAfterCart = await storefront.getServerTimingValues(true); + + // Server-timing _y and _s should be mock values after cart action (consent was declined) + storefront.expectMockServerTimingValues(serverTimingAfterCart); + + // Verify still no analytics requests after cart action + storefront.expectNoMonorailRequests(); + + // 12. Verify checkout URLs contain MOCK tracking params (consent declined) + await storefront.expectNoCheckoutUrlTrackingParams( + 'in cart drawer after declining consent', + ); + + // 13. Reload the page to verify persistence + await storefront.reload(); + + // Verify privacy banner does NOT show up on reload (consent was saved) + await storefront.expectPrivacyBannerNotVisible(); + + // Verify _shopify_essential cookie persists after reload + await storefront.expectEssentialCookiePresent(); + + // Verify analytics cookies are still not present after reload + await storefront.expectNoAnalyticsCookies(); + + // Wait for perf-kit to be downloaded after reload + await storefront.waitForPerfKit(); + + // Wait and verify no analytics requests after reload + await storefront.page.waitForTimeout(1500); + storefront.expectNoMonorailRequests(); + }); +}); diff --git a/e2e/specs/smoke/cart.spec.ts b/e2e/specs/smoke/cart.spec.ts new file mode 100644 index 0000000000..467c2e0d50 --- /dev/null +++ b/e2e/specs/smoke/cart.spec.ts @@ -0,0 +1,40 @@ +import {setTestStore, test, expect} from '../../fixtures'; + +setTestStore('mockShop'); + +test.describe('Cart Functionality', () => { + test('should add first product to cart and verify cart updates', async ({ + page, + }) => { + await page.goto('/'); + + const cartBadgeBefore = page.locator('a[href="/cart"]').first(); + const initialCartText = await cartBadgeBefore.textContent(); + const initialCount = initialCartText?.match(/\d+/)?.[0] || '0'; + + const firstProduct = page.locator('.product-item').first(); + await firstProduct.click(); + + await page.waitForLoadState('networkidle'); + + const addToCartButton = page + .locator('button:has-text("Add to cart")') + .first(); + await expect(addToCartButton).toBeVisible(); + await addToCartButton.click(); + + // Wait for the cart update network request to complete + await page.waitForLoadState('networkidle'); + + // Additionally wait for the cart badge text to change from initial value + const cartBadgeAfter = page.locator('a[href="/cart"]').first(); + await expect(cartBadgeAfter).not.toHaveText(initialCartText || '', { + timeout: 10000, + }); + + const updatedCartText = await cartBadgeAfter.textContent(); + const updatedCount = updatedCartText?.match(/\d+/)?.[0] || '0'; + + expect(parseInt(updatedCount)).toBeGreaterThan(parseInt(initialCount)); + }); +}); diff --git a/e2e/specs/smoke/home.spec.ts b/e2e/specs/smoke/home.spec.ts new file mode 100644 index 0000000000..357cf068ff --- /dev/null +++ b/e2e/specs/smoke/home.spec.ts @@ -0,0 +1,37 @@ +import {test, expect, setTestStore} from '../../fixtures'; + +setTestStore('mockShop'); + +test.describe('Home Page', () => { + test('should display hero image, product grid, and no console errors', async ({ + page, + }) => { + const consoleErrors: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/'); + + const featuredCollection = page.locator('.featured-collection').first(); + await expect(featuredCollection).toBeVisible(); + + const featuredImage = page + .locator('.featured-collection-image img') + .first(); + await expect(featuredImage).toBeVisible(); + + const recommendedProducts = page + .locator('.recommended-products-grid') + .first(); + await expect(recommendedProducts).toBeVisible(); + + const productItems = page.locator('.product-item'); + await expect(productItems.first()).toBeVisible(); + + expect(consoleErrors).toHaveLength(0); + }); +}); diff --git a/examples/classic-remix/server.ts b/examples/classic-remix/server.ts index f2baff0566..e1fd474967 100644 --- a/examples/classic-remix/server.ts +++ b/examples/classic-remix/server.ts @@ -32,6 +32,8 @@ export default { build: remixBuild, mode: process.env.NODE_ENV, getLoadContext: () => appLoadContext, + proxyStandardRoutes: false, + collectTrackingInformation: false, }); const response = await handleRequest(request); diff --git a/package-lock.json b/package-lock.json index 9afa1849b6..f1de576b72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "@eslint/compat": "^1.2.5", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", - "@playwright/test": "^1.40.1", + "@playwright/test": "^1.57.0", "@shopify/cli": "~3.79.2", "@types/eslint": "^9.6.1", "@types/semver": "^7.5.8", @@ -121,7 +121,7 @@ "@remix-run/node": "^2.16.1", "@remix-run/react": "^2.16.1", "@remix-run/server-runtime": "^2.16.1", - "@shopify/hydrogen": "2025.4.0", + "@shopify/hydrogen": "2025.4.1", "compression": "^1.7.4", "cross-env": "^7.0.3", "express": "^4.19.2", @@ -8113,18 +8113,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.1.tgz", - "integrity": "sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright": "1.40.1" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/@pnpm/config.env-replace": { @@ -31136,33 +31137,35 @@ } }, "node_modules/playwright": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.1.tgz", - "integrity": "sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.40.1" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.1.tgz", - "integrity": "sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/playwright/node_modules/fsevents": { @@ -31171,6 +31174,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -41068,7 +41072,7 @@ }, "packages/cli": { "name": "@shopify/cli-hydrogen", - "version": "10.0.2", + "version": "10.1.0", "license": "MIT", "dependencies": { "@ast-grep/napi": "0.11.0", @@ -41966,7 +41970,7 @@ }, "packages/hydrogen": { "name": "@shopify/hydrogen", - "version": "2025.4.0", + "version": "2025.4.1", "license": "MIT", "dependencies": { "@shopify/hydrogen-react": "2025.4.0", @@ -43488,11 +43492,11 @@ } }, "templates/skeleton": { - "version": "2025.4.0", + "version": "2025.4.1", "dependencies": { "@remix-run/react": "^2.16.1", "@remix-run/server-runtime": "^2.16.1", - "@shopify/hydrogen": "2025.4.0", + "@shopify/hydrogen": "2025.4.1", "@shopify/remix-oxygen": "^2.0.12", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", diff --git a/package.json b/package.json index 76c7da02ed..a9516a62c3 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,8 @@ ], "prettier": "@shopify/prettier-config", "dependencies": { - "@shopify/cli-hydrogen": "*", - "@remix-run/fs-routes": "^2.16.1" + "@remix-run/fs-routes": "^2.16.1", + "@shopify/cli-hydrogen": "*" }, "devDependencies": { "@changesets/changelog-github": "^0.4.8", @@ -67,7 +67,7 @@ "@eslint/compat": "^1.2.5", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", - "@playwright/test": "^1.40.1", + "@playwright/test": "^1.57.0", "@shopify/cli": "~3.79.2", "@types/eslint": "^9.6.1", "@types/semver": "^7.5.8", diff --git a/packages/create-hydrogen/integration.test.ts b/packages/create-hydrogen/integration.test.ts index cfee0a10bd..a696b0434e 100644 --- a/packages/create-hydrogen/integration.test.ts +++ b/packages/create-hydrogen/integration.test.ts @@ -59,6 +59,7 @@ describe('create-hydrogen', () => { │ • Search (/search) │ │ • Robots (/robots.txt) │ │ • Sitemap (/sitemap.xml & /sitemap/:type/:page.xml) │ + │ • TokenlessApi (/api/:version/graphql.json) │ │ │ │ Next steps │ │ │ diff --git a/packages/hydrogen-react/src/ShopifyProvider.tsx b/packages/hydrogen-react/src/ShopifyProvider.tsx index 1b64820962..596d741682 100644 --- a/packages/hydrogen-react/src/ShopifyProvider.tsx +++ b/packages/hydrogen-react/src/ShopifyProvider.tsx @@ -24,6 +24,27 @@ const ShopifyContext = createContext( defaultShopifyContext, ); +/** + * Hydrogen server sets this server timing key when the SFAPI proxy is enabled. + * Read it automatically in the browser for apps using frontend cart in full-stack Hydrogen, + * but don't export this utility yet since we don't want to make this a public convention. + */ +function isSfapiProxyEnabled() { + if (typeof window === 'undefined') return false; + + try { + const navigationEntry = window.performance?.getEntriesByType?.( + 'navigation', + )[0] as PerformanceNavigationTiming; + + return !!navigationEntry?.serverTiming?.some( + (entry) => entry.name === '_sfapi_proxy', + ); + } catch (e) { + return false; + } +} + /** * The `` component enables use of the `useShop()` hook. The component should wrap your app. */ @@ -50,6 +71,9 @@ export function ShopifyProvider({ } const finalConfig = useMemo(() => { + const sameDomainForStorefrontApi = + shopifyConfig.sameDomainForStorefrontApi ?? isSfapiProxyEnabled(); + function getShopifyDomain(overrideProps?: {storeDomain?: string}): string { const domain = overrideProps?.storeDomain ?? shopifyConfig.storeDomain; return domain.includes('://') ? domain : `https://${domain}`; @@ -57,6 +81,7 @@ export function ShopifyProvider({ return { ...shopifyConfig, + sameDomainForStorefrontApi, getPublicTokenHeaders(overrideProps): Record { return getPublicTokenHeadersRaw( overrideProps.contentType, @@ -66,9 +91,14 @@ export function ShopifyProvider({ }, getShopifyDomain, getStorefrontApiUrl(overrideProps): string { - const finalDomainUrl = getShopifyDomain({ - storeDomain: overrideProps?.storeDomain ?? shopifyConfig.storeDomain, - }); + const finalDomainUrl = + sameDomainForStorefrontApi && typeof window !== 'undefined' + ? window.location.origin + : getShopifyDomain({ + storeDomain: + overrideProps?.storeDomain ?? shopifyConfig.storeDomain, + }); + return `${finalDomainUrl}${ finalDomainUrl.endsWith('/') ? '' : '/' }api/${ @@ -114,6 +144,11 @@ export interface ShopifyProviderBase { * `ISO 369` language codes supported by Shopify. */ languageIsoCode: LanguageCode; + /** + * Uses the current window.location.origin for Storefront API requests. + * This requires setting up a proxy for Storefront API requests in your domain. + */ + sameDomainForStorefrontApi?: boolean; } /** diff --git a/packages/hydrogen-react/src/analytics.ts b/packages/hydrogen-react/src/analytics.ts index 3934f921cb..0b6de701ba 100644 --- a/packages/hydrogen-react/src/analytics.ts +++ b/packages/hydrogen-react/src/analytics.ts @@ -1,4 +1,3 @@ -import {SHOPIFY_S, SHOPIFY_Y} from './cart-constants.js'; import type { ClientBrowserParameters, ShopifyAddToCartPayload, @@ -8,7 +7,7 @@ import type { } from './analytics-types.js'; import {AnalyticsEventName} from './analytics-constants.js'; import {errorIfServer} from './analytics-utils.js'; -import {getShopifyCookies} from './cookies-utils.js'; +import {getTrackingValues} from './tracking-utils.js'; import {pageView as trekkiePageView} from './analytics-schema-trekkie-storefront-page-view.js'; import { @@ -153,11 +152,11 @@ export function getClientBrowserParameters(): ClientBrowserParameters { } const [navigationType, navigationApi] = getNavigationType(); - const cookies = getShopifyCookies(document.cookie); + const trackingValues = getTrackingValues(); return { - uniqueToken: cookies[SHOPIFY_Y], - visitToken: cookies[SHOPIFY_S], + uniqueToken: trackingValues.uniqueToken, + visitToken: trackingValues.visitToken, url: location.href, path: location.pathname, search: location.search, diff --git a/packages/hydrogen-react/src/cart-hooks.tsx b/packages/hydrogen-react/src/cart-hooks.tsx index 4de3459d64..d224a042a3 100644 --- a/packages/hydrogen-react/src/cart-hooks.tsx +++ b/packages/hydrogen-react/src/cart-hooks.tsx @@ -8,15 +8,22 @@ import { SHOPIFY_STOREFRONT_ID_HEADER, SHOPIFY_STOREFRONT_Y_HEADER, SHOPIFY_STOREFRONT_S_HEADER, - SHOPIFY_Y, - SHOPIFY_S, } from './cart-constants.js'; import type {StorefrontApiResponseOkPartial} from './storefront-api-response.types.js'; -import {getShopifyCookies} from './cookies-utils.js'; +import { + getTrackingValues, + SHOPIFY_UNIQUE_TOKEN_HEADER, + SHOPIFY_VISIT_TOKEN_HEADER, +} from './tracking-utils.js'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function useCartFetch() { - const {storefrontId, getPublicTokenHeaders, getStorefrontApiUrl} = useShop(); + const { + storefrontId, + getPublicTokenHeaders, + getStorefrontApiUrl, + sameDomainForStorefrontApi, + } = useShop(); return useCallback( ({ @@ -32,10 +39,19 @@ export function useCartFetch() { headers[SHOPIFY_STOREFRONT_ID_HEADER] = storefrontId; } - // Find Shopify cookies - const cookieData = getShopifyCookies(document.cookie); - headers[SHOPIFY_STOREFRONT_Y_HEADER] = cookieData[SHOPIFY_Y]; - headers[SHOPIFY_STOREFRONT_S_HEADER] = cookieData[SHOPIFY_S]; + if (!sameDomainForStorefrontApi) { + // If we are in cross-domain mode, add tracking headers manually. + // Otherwise, for same-domain we rely on the browser to attach cookies automatically. + const {uniqueToken, visitToken} = getTrackingValues(); + if (uniqueToken) { + headers[SHOPIFY_STOREFRONT_Y_HEADER] = uniqueToken; + headers[SHOPIFY_UNIQUE_TOKEN_HEADER] = uniqueToken; + } + if (visitToken) { + headers[SHOPIFY_STOREFRONT_S_HEADER] = visitToken; + headers[SHOPIFY_VISIT_TOKEN_HEADER] = visitToken; + } + } return fetch(getStorefrontApiUrl(), { method: 'POST', @@ -57,7 +73,12 @@ export function useCartFetch() { }; }); }, - [getPublicTokenHeaders, storefrontId, getStorefrontApiUrl], + [ + getPublicTokenHeaders, + storefrontId, + getStorefrontApiUrl, + sameDomainForStorefrontApi, + ], ); } diff --git a/packages/hydrogen-react/src/cookies-utils.tsx b/packages/hydrogen-react/src/cookies-utils.tsx index 70f90aad41..9b22ef87e7 100644 --- a/packages/hydrogen-react/src/cookies-utils.tsx +++ b/packages/hydrogen-react/src/cookies-utils.tsx @@ -1,6 +1,6 @@ -import {parse} from 'worktop/cookie'; import {ShopifyCookies} from './analytics-types.js'; import {SHOPIFY_Y, SHOPIFY_S} from './cart-constants.js'; +import {getTrackingValues} from './tracking-utils.js'; const tokenHash = 'xxxx-4xxx-xxxx-xxxxxxxxxxxx'; @@ -58,10 +58,15 @@ export function hexTime(): string { return output.padStart(8, '0'); } +/** + * Gets the values of _shopify_y and _shopify_s cookies from the provided cookie string. + * @deprecated Use getTrackingValues instead. + */ export function getShopifyCookies(cookies: string): ShopifyCookies { - const cookieData = parse(cookies); + const trackingValues = getTrackingValues(cookies); + return { - [SHOPIFY_Y]: cookieData[SHOPIFY_Y] || '', - [SHOPIFY_S]: cookieData[SHOPIFY_S] || '', + [SHOPIFY_Y]: trackingValues.uniqueToken, + [SHOPIFY_S]: trackingValues.visitToken, }; } diff --git a/packages/hydrogen-react/src/index.ts b/packages/hydrogen-react/src/index.ts index 0c34289693..ceea88a97f 100644 --- a/packages/hydrogen-react/src/index.ts +++ b/packages/hydrogen-react/src/index.ts @@ -75,6 +75,12 @@ export type { } from './storefront-api-response.types.js'; export type {StorefrontClientProps} from './storefront-client.js'; export {createStorefrontClient} from './storefront-client.js'; +export { + getTrackingValues, + SHOPIFY_UNIQUE_TOKEN_HEADER, + SHOPIFY_VISIT_TOKEN_HEADER, + type TrackingValues, +} from './tracking-utils.js'; export {useMoney} from './useMoney.js'; export {useSelectedOptionInUrlParam} from './useSelectedOptionInUrlParam.js'; export {useShopifyCookies} from './useShopifyCookies.js'; diff --git a/packages/hydrogen-react/src/tracking-utils.test.ts b/packages/hydrogen-react/src/tracking-utils.test.ts new file mode 100644 index 0000000000..4f022f92c7 --- /dev/null +++ b/packages/hydrogen-react/src/tracking-utils.test.ts @@ -0,0 +1,401 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cachedTrackingValues, getTrackingValues} from './tracking-utils.js'; + +const testOrigin = 'https://shop.myshopify.com'; + +describe('tracking-utils', () => { + afterEach(() => { + vi.unstubAllGlobals(); + cachedTrackingValues.current = null; + }); + + describe('getTrackingValues', () => { + it('returns tokens from the latest resource performance entry', () => { + stubPerformanceAPI({ + resource: [ + createResourceEntry({ + name: `${testOrigin}/other-endpoint`, + // No tokens - should be ignored even though same origin + }), + createResourceEntry({ + unique: 'resource-unique', + visit: 'resource-visit', + consent: 'resource-consent', + }), + ], + navigation: [ + createNavigationEntry({ + unique: 'nav-unique-ignored', + visit: 'nav-visit-ignored', + consent: 'nav-consent-ignored', + }), + ], + }); + + expect(getTrackingValues()).toEqual({ + uniqueToken: 'resource-unique', + visitToken: 'resource-visit', + consent: 'resource-consent', + }); + }); + + it('returns tokens from non-SFAPI same-origin requests that have tracking values', () => { + stubPerformanceAPI({ + resource: [ + createResourceEntry({ + name: `${testOrigin}/other/path/update-my-cart`, + unique: 'other-path-unique', + visit: 'other-path-visit', + consent: 'other-path-consent', + }), + ], + navigation: [ + createNavigationEntry({ + unique: 'nav-unique-ignored', + visit: 'nav-visit-ignored', + consent: 'nav-consent-ignored', + }), + ], + }); + + // Should match same-origin request regardless of path if it has _y and _s + expect(getTrackingValues()).toEqual({ + uniqueToken: 'other-path-unique', + visitToken: 'other-path-visit', + consent: 'other-path-consent', + }); + }); + + it('falls back to navigation performance entries when no resource tokens are found', () => { + const mockGetEntries = stubPerformanceAPI({ + resource: [ + createResourceEntry({ + name: `${testOrigin}/other-endpoint`, + // No tokens - should be ignored + }), + createResourceEntry(), + ], + navigation: [ + createNavigationEntry({ + unique: 'nav-unique', + visit: 'nav-visit', + consent: 'nav-consent', + }), + ], + }); + + expect(getTrackingValues()).toEqual({ + uniqueToken: 'nav-unique', + visitToken: 'nav-visit', + consent: 'nav-consent', + }); + expect(mockGetEntries).toHaveBeenNthCalledWith(1, 'resource'); + expect(mockGetEntries).toHaveBeenNthCalledWith(2, 'navigation'); + }); + + it('reuses cached resource tokens when later resource entries are unavailable', () => { + const resourceEntries = [ + createResourceEntry({ + unique: 'cached-unique', + visit: 'cached-visit', + consent: 'cached-consent', + }), + ]; + const mockGetEntries = stubPerformanceAPI({resource: resourceEntries}); + + expect(getTrackingValues()).toEqual({ + uniqueToken: 'cached-unique', + visitToken: 'cached-visit', + consent: 'cached-consent', + }); + resourceEntries.splice(0); + expect(getTrackingValues()).toEqual({ + uniqueToken: 'cached-unique', + visitToken: 'cached-visit', + consent: 'cached-consent', + }); + expect(mockGetEntries).toHaveBeenCalledTimes(2); + }); + + it('falls back to deprecated cookies when performance APIs are unavailable', () => { + stubPerformanceAPI({ + resource: [ + createResourceEntry({ + name: `${testOrigin}/other-endpoint`, + // No tokens - should be ignored + }), + createResourceEntry(), + ], + navigation: [createNavigationEntry()], + }); + vi.stubGlobal('document', { + cookie: '_shopify_y=legacy-unique; _shopify_s=legacy-visit', + } as unknown as Document); + + expect(getTrackingValues()).toEqual({ + uniqueToken: 'legacy-unique', + visitToken: 'legacy-visit', + consent: '', + }); + }); + + describe('cross-origin matching', () => { + it('matches the most recent entry with values (same-origin wins when newer)', () => { + stubPerformanceAPI( + { + resource: [ + createResourceEntry({ + name: 'https://checkout.mystore.com/api/2024-01/graphql.json', + unique: 'subdomain-unique', + visit: 'subdomain-visit', + consent: 'subdomain-consent', + }), + createResourceEntry({ + name: 'https://mystore.com/any-path', + unique: 'same-origin-unique', + visit: 'same-origin-visit', + consent: 'same-origin-consent', + }), + ], + }, + 'https://mystore.com', + ); + + // Most recent match (same-origin) wins + expect(getTrackingValues()).toEqual({ + uniqueToken: 'same-origin-unique', + visitToken: 'same-origin-visit', + consent: 'same-origin-consent', + }); + }); + + it('matches the most recent entry with values (subdomain wins when newer)', () => { + stubPerformanceAPI( + { + resource: [ + createResourceEntry({ + name: 'https://mystore.com/any-path', + unique: 'same-origin-unique', + visit: 'same-origin-visit', + consent: 'same-origin-consent', + }), + createResourceEntry({ + name: 'https://checkout.mystore.com/api/2024-01/graphql.json', + unique: 'subdomain-unique', + visit: 'subdomain-visit', + consent: 'subdomain-consent', + }), + ], + }, + 'https://mystore.com', + ); + + // Most recent match (subdomain SFAPI) wins + expect(getTrackingValues()).toEqual({ + uniqueToken: 'subdomain-unique', + visitToken: 'subdomain-visit', + consent: 'subdomain-consent', + }); + }); + + it('matches subdomain SFAPI requests with tracking values', () => { + stubPerformanceAPI( + { + resource: [ + createResourceEntry({ + name: 'https://checkout.mystore.com/api/2024-01/graphql.json', + unique: 'subdomain-unique', + visit: 'subdomain-visit', + consent: 'subdomain-consent', + }), + ], + }, + 'https://mystore.com', + ); + + expect(getTrackingValues()).toEqual({ + uniqueToken: 'subdomain-unique', + visitToken: 'subdomain-visit', + consent: 'subdomain-consent', + }); + }); + + it('ignores subdomain requests without SFAPI path', () => { + stubPerformanceAPI( + { + resource: [ + createResourceEntry({ + name: 'https://checkout.mystore.com/some-other-path', + unique: 'ignored-unique', + visit: 'ignored-visit', + consent: 'ignored-consent', + }), + ], + navigation: [ + createNavigationEntry({ + unique: 'nav-unique', + visit: 'nav-visit', + consent: 'nav-consent', + }), + ], + }, + 'https://mystore.com', + ); + + // Subdomain without SFAPI path should be ignored + expect(getTrackingValues()).toEqual({ + uniqueToken: 'nav-unique', + visitToken: 'nav-visit', + consent: 'nav-consent', + }); + }); + + it('ignores unrelated cross-domain requests even with SFAPI path', () => { + stubPerformanceAPI( + { + resource: [ + createResourceEntry({ + name: 'https://mystore.myshopify.com/api/2024-01/graphql.json', + unique: 'cross-domain-unique', + visit: 'cross-domain-visit', + consent: 'cross-domain-consent', + }), + ], + navigation: [ + createNavigationEntry({ + unique: 'nav-unique', + visit: 'nav-visit', + consent: 'nav-consent', + }), + ], + }, + 'https://mystore.com', + ); + + // Unrelated cross-domain (not a subdomain) should be ignored + expect(getTrackingValues()).toEqual({ + uniqueToken: 'nav-unique', + visitToken: 'nav-visit', + consent: 'nav-consent', + }); + }); + + it('ignores cross-domain non-SFAPI requests even with tracking values', () => { + stubPerformanceAPI( + { + resource: [ + createResourceEntry({ + name: 'https://other-domain.com/some-path', + unique: 'ignored-unique', + visit: 'ignored-visit', + consent: 'ignored-consent', + }), + ], + navigation: [ + createNavigationEntry({ + unique: 'nav-unique', + visit: 'nav-visit', + consent: 'nav-consent', + }), + ], + }, + 'https://mystore.com', + ); + + // Should fall back to navigation because cross-domain non-SFAPI is ignored + expect(getTrackingValues()).toEqual({ + uniqueToken: 'nav-unique', + visitToken: 'nav-visit', + consent: 'nav-consent', + }); + }); + + it('requires tracking values for subdomain SFAPI match', () => { + stubPerformanceAPI( + { + resource: [ + createResourceEntry({ + name: 'https://checkout.mystore.com/api/2024-01/graphql.json', + // No tokens - should be ignored even with SFAPI path + }), + ], + navigation: [ + createNavigationEntry({ + unique: 'nav-unique', + visit: 'nav-visit', + consent: 'nav-consent', + }), + ], + }, + 'https://mystore.com', + ); + + // Subdomain SFAPI without tokens should fall back to navigation + expect(getTrackingValues()).toEqual({ + uniqueToken: 'nav-unique', + visitToken: 'nav-visit', + consent: 'nav-consent', + }); + }); + }); + }); +}); + +function stubPerformanceAPI( + { + resource = [], + navigation = [], + }: { + resource?: PerformanceResourceTiming[]; + navigation?: PerformanceNavigationTiming[]; + } = {}, + origin: string = testOrigin, +) { + const getEntriesByType = vi.fn((type: string) => { + if (type === 'resource') { + return resource ?? []; + } + if (type === 'navigation') { + return navigation ?? []; + } + return []; + }); + + const mockPerformance = {getEntriesByType} as unknown as Performance; + + const url = new URL(origin); + vi.stubGlobal('window', { + location: {origin, hostname: url.hostname, host: url.host}, + performance: mockPerformance, + } as unknown as Window & typeof globalThis); + vi.stubGlobal('performance', mockPerformance); + + return getEntriesByType; +} + +type EntryOptions = {unique?: string; visit?: string; consent?: string}; + +function createNavigationEntry({unique, visit, consent}: EntryOptions = {}) { + const serverTiming = [ + unique ? {name: '_y', description: unique} : null, + visit ? {name: '_s', description: visit} : null, + consent ? {name: '_cmp', description: consent} : null, + ].filter(Boolean) as PerformanceServerTiming[]; + + return { + serverTiming, + } as unknown as PerformanceNavigationTiming; +} + +function createResourceEntry({ + name = `${testOrigin}/api/unstable/graphql.json`, + unique, + visit, + consent, +}: EntryOptions & {name?: string} = {}) { + return { + ...createNavigationEntry({unique, visit, consent}), + initiatorType: 'fetch', + name, + } as unknown as PerformanceResourceTiming; +} diff --git a/packages/hydrogen-react/src/tracking-utils.ts b/packages/hydrogen-react/src/tracking-utils.ts new file mode 100644 index 0000000000..30cf6283b3 --- /dev/null +++ b/packages/hydrogen-react/src/tracking-utils.ts @@ -0,0 +1,169 @@ +/** Storefront API header for VisitToken */ +export const SHOPIFY_VISIT_TOKEN_HEADER = 'X-Shopify-VisitToken'; +/** Storefront API header for UniqueToken */ +export const SHOPIFY_UNIQUE_TOKEN_HEADER = 'X-Shopify-UniqueToken'; + +export type TrackingValues = { + /** Identifier for the unique user. Equivalent to the deprecated _shopify_y cookie */ + uniqueToken: string; + /** Identifier for the current visit. Equivalent to the deprecated _shopify_s cookie */ + visitToken: string; + /** Represents the consent given by the user or the default region consent configured in Admin */ + consent: string; +}; + +// Cache values to avoid losing them when performance +// entries are cleared from the buffer over time. +export const cachedTrackingValues: { + current: null | TrackingValues; +} = {current: null}; + +/** + * Retrieves user session tracking values for analytics + * and marketing from the browser environment. + * @param cookieString - Optional cookie string to parse for deprecated cookie fallback. + * Used internally by getShopifyCookies for backward compatibility. + */ +export function getTrackingValues(cookieString?: string): TrackingValues { + // Overall behavior: Tracking values are returned in Server-Timing headers from + // Storefront API responses, and we want to find and return these tracking values. + // + // Search recent fetches for SFAPI requests matching either: same origin (proxy case) + // or a subdomain of the current host (eg: checkout subdomain, if there is no proxy). + // We consider SF API-like endpoints (/api/.../graphql.json) on subdomains, as well as + // any same-origin request. The reason for the latter is that Hydrogen server collects + // tracking values and returns them in any non-cached response, not just direct SF API + // responses. For example, a cart mutation in a server action could return tracking values. + // + // If we didn't find tracking values in fetch requests, we fall back to checking cached values, + // then the initial page navigation entry, and finally the deprecated `_shopify_s` and `_shopify_y`. + + let trackingValues: TrackingValues | undefined; + + if ( + typeof window !== 'undefined' && + typeof window.performance !== 'undefined' + ) { + try { + // RE to extract host and optionally match SFAPI pathname. + // Group 1: host (e.g. "checkout.mystore.com") + // Group 2: SFAPI path if present (e.g. "/api/2024-01/graphql.json") + const resourceRE = + /^https?:\/\/([^/]+)(\/api\/(?:unstable|2\d{3}-\d{2})\/graphql\.json(?=$|\?))?/; + + // Search backwards through resource entries to find the most recent match. + // Match criteria (first one with _y and _s values wins): + // - Same origin (exact host match) with tracking values, OR + // - Subdomain + SFAPI pathname with tracking values + const entries = performance.getEntriesByType( + 'resource', + ) as PerformanceResourceTiming[]; + + let matchedValues: ReturnType; + + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + + if (entry.initiatorType !== 'fetch') continue; + + const currentHost = window.location.host; + const match = entry.name.match(resourceRE); + if (!match) continue; + + const [, matchedHost, sfapiPath] = match; + + const isMatch = + // Same origin (exact host match) + matchedHost === currentHost || + // Subdomain with SFAPI path + (sfapiPath && matchedHost?.endsWith(`.${currentHost}`)); + + if (isMatch) { + const values = extractFromPerformanceEntry(entry); + if (values) { + matchedValues = values; + break; + } + } + } + + if (matchedValues) { + trackingValues = matchedValues; + } + + // Resource entries have a limited buffer and are removed over time. + // Cache the latest values for future calls if we find them. + // A cached resource entry is always newer than a navigation entry. + if (trackingValues) { + cachedTrackingValues.current = trackingValues; + } else if (cachedTrackingValues.current) { + // Fallback to cached values from previous calls: + trackingValues = cachedTrackingValues.current; + } + + if (!trackingValues) { + // Fallback to navigation entry from full page rendering load: + const navigationEntries = performance.getEntriesByType( + 'navigation', + )[0] as PerformanceNavigationTiming; + + // Navigation entries might omit consent when the Hydrogen server generates it. + // In this case, we skip consent requirement and only extract _y and _s values. + trackingValues = extractFromPerformanceEntry(navigationEntries, false); + } + } catch {} + } + + // Fallback to deprecated cookies to support transitioning: + if (!trackingValues) { + const cookie = + typeof cookieString === 'string' + ? cookieString + : typeof document !== 'undefined' + ? document.cookie + : ''; + + trackingValues = { + uniqueToken: cookie.match(/\b_shopify_y=([^;]+)/)?.[1] || '', + visitToken: cookie.match(/\b_shopify_s=([^;]+)/)?.[1] || '', + consent: cookie.match(/\b_tracking_consent=([^;]+)/)?.[1] || '', + }; + } + + return trackingValues; +} + +function extractFromPerformanceEntry( + entry: PerformanceNavigationTiming | PerformanceResourceTiming, + isConsentRequired = true, +): TrackingValues | undefined { + let uniqueToken = ''; + let visitToken = ''; + let consent = ''; + + const serverTiming = entry.serverTiming; + // Quick check: we need at least 3 entries (_y, _s, _cmp) + if (serverTiming && serverTiming.length >= 3) { + // Iterate backwards since our headers are typically at the end + for (let i = serverTiming.length - 1; i >= 0; i--) { + const {name, description} = serverTiming[i]; + if (!name || !description) continue; + + if (name === '_y') { + uniqueToken = description; + } else if (name === '_s') { + visitToken = description; + } else if (name === '_cmp') { + // _cmp (consent management platform) holds the consent value + // used by consent-tracking-api and privacy-banner scripts. + consent = description; + } + + if (uniqueToken && visitToken && consent) break; + } + } + + return uniqueToken && visitToken && (isConsentRequired ? consent : true) + ? {uniqueToken, visitToken, consent} + : undefined; +} diff --git a/packages/hydrogen-react/src/useShopifyCookies.tsx b/packages/hydrogen-react/src/useShopifyCookies.tsx index 69046e62a3..68fa77f5c4 100644 --- a/packages/hydrogen-react/src/useShopifyCookies.tsx +++ b/packages/hydrogen-react/src/useShopifyCookies.tsx @@ -1,12 +1,18 @@ -import {useEffect} from 'react'; +import {useEffect, useRef, useState} from 'react'; +// @ts-ignore - worktop/cookie types not properly exported import {stringify} from 'worktop/cookie'; import {SHOPIFY_Y, SHOPIFY_S} from './cart-constants.js'; -import {buildUUID, getShopifyCookies} from './cookies-utils.js'; +import {buildUUID} from './cookies-utils.js'; +import { + getTrackingValues, + SHOPIFY_UNIQUE_TOKEN_HEADER, + SHOPIFY_VISIT_TOKEN_HEADER, +} from './tracking-utils.js'; const longTermLength = 60 * 60 * 24 * 360 * 1; // ~1 year expiry const shortTermLength = 60 * 30; // 30 mins -type UseShopifyCookiesOptions = { +type UseShopifyCookiesOptions = CoreShopifyCookiesOptions & { /** * If set to `false`, Shopify cookies will be removed. * If set to `true`, Shopify unique user token cookie will have cookie expiry of 1 year. @@ -21,16 +27,49 @@ type UseShopifyCookiesOptions = { * The checkout domain of the shop. Defaults to empty string. If set, the cookie domain will check if it can be set with the checkout domain. */ checkoutDomain?: string; + /** + * If set to `true`, it skips modifying the deprecated shopify_y and shopify_s cookies. + */ + ignoreDeprecatedCookies?: boolean; }; -export function useShopifyCookies(options?: UseShopifyCookiesOptions): void { +/** + * Sets the `shopify_y` and `shopify_s` cookies in the browser based on user consent + * for backward compatibility support. + * + * If `fetchTrackingValues` is true, it makes a request to Storefront API + * to fetch or refresh Shopiy analytics and marketing cookies and tracking values. + * Generally speaking, this should only be needed if you're not using Hydrogen's + * built-in analytics components and hooks that already handle this automatically. + * For example, set it to `true` if you are using `hydrogen-react` only with + * a different framework and still need to make a same-domain request to + * Storefront API to set cookies. + * + * If `ignoreDeprecatedCookies` is true, it skips setting the deprecated cookies entirely. + * Useful when you only want to use the newer tracking values and not rely on the deprecated ones. + * + * @returns `true` when cookies are set and ready. + */ +export function useShopifyCookies(options?: UseShopifyCookiesOptions): boolean { const { - hasUserConsent = false, + hasUserConsent, domain = '', checkoutDomain = '', + storefrontAccessToken, + fetchTrackingValues, + ignoreDeprecatedCookies = false, } = options || {}; + + const coreCookiesReady = useCoreShopifyCookies({ + storefrontAccessToken, + fetchTrackingValues, + checkoutDomain, + }); + useEffect(() => { - const cookies = getShopifyCookies(document.cookie); + // Skip setting JS cookies until http-only cookies and server-timing + // are ready so that we have values synced in JS and http-only cookies. + if (ignoreDeprecatedCookies || !coreCookiesReady) return; /** * Setting cookie with domain @@ -40,7 +79,7 @@ export function useShopifyCookies(options?: UseShopifyCookiesOptions): void { */ // Use override domain or current host - let currentDomain = domain || window.document.location.host; + let currentDomain = domain || window.location.host; if (checkoutDomain) { const checkoutDomainParts = checkoutDomain.split('.').reverse(); @@ -69,15 +108,27 @@ export function useShopifyCookies(options?: UseShopifyCookiesOptions): void { * Set user and session cookies and refresh the expiry time */ if (hasUserConsent) { + const trackingValues = getTrackingValues(); + if ( + ( + trackingValues.uniqueToken || + trackingValues.visitToken || + '' + ).startsWith('00000000-') + ) { + // Skip writing cookies when tracking values signal we don't have consent yet + return; + } + setCookie( SHOPIFY_Y, - cookies[SHOPIFY_Y] || buildUUID(), + trackingValues.uniqueToken || buildUUID(), longTermLength, domainWithLeadingDot, ); setCookie( SHOPIFY_S, - cookies[SHOPIFY_S] || buildUUID(), + trackingValues.visitToken || buildUUID(), shortTermLength, domainWithLeadingDot, ); @@ -85,7 +136,15 @@ export function useShopifyCookies(options?: UseShopifyCookiesOptions): void { setCookie(SHOPIFY_Y, '', 0, domainWithLeadingDot); setCookie(SHOPIFY_S, '', 0, domainWithLeadingDot); } - }, [options, hasUserConsent, domain, checkoutDomain]); + }, [ + coreCookiesReady, + hasUserConsent, + domain, + checkoutDomain, + ignoreDeprecatedCookies, + ]); + + return coreCookiesReady; } function setCookie( @@ -101,3 +160,112 @@ function setCookie( path: '/', }); } + +async function fetchTrackingValuesFromBrowser( + storefrontAccessToken?: string, + storefrontApiDomain = '', +): Promise { + // These values might come from server-timing or old cookies. + // If consent cannot be initially assumed, these tokens + // will be dropped in SFAPI and it will return a mock token + // starting with '00000000-'. + // However, if consent can be assumed initially, these tokens + // will be used to create proper cookies and continue our flow. + const {uniqueToken, visitToken} = getTrackingValues(); + + const response = await fetch( + // TODO: update this endpoint when it becomes stable + `${storefrontApiDomain.replace(/\/+$/, '')}/api/unstable/graphql.json`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(storefrontAccessToken && { + 'X-Shopify-Storefront-Access-Token': storefrontAccessToken, + }), + ...(visitToken || uniqueToken + ? { + [SHOPIFY_VISIT_TOKEN_HEADER]: visitToken, + [SHOPIFY_UNIQUE_TOKEN_HEADER]: uniqueToken, + } + : undefined), + }, + body: JSON.stringify({ + query: + // This query ensures we get _cmp (consent) server-timing header, which is not available in other queries. + // This value can be passed later to consent-tracking-api and privacy-banner scripts to avoid extra requests. + 'query ensureCookies { consentManagement { cookies(visitorConsent:{}) { cookieDomain } } }', + }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch consent from browser: ${response.status} ${response.statusText}`, + ); + } + + // Consume the body to complete the request and + // ensure server-timing is available in performance API + await response.json(); + + // Ensure we cache the latest tracking values from resources timing + getTrackingValues(); +} + +type CoreShopifyCookiesOptions = { + storefrontAccessToken?: string; + fetchTrackingValues?: boolean; + checkoutDomain?: string; +}; + +/** + * Gets http-only cookies from Storefront API via same-origin fetch request. + * Falls back to checkout domain if provided to at least obtain the tracking + * values via server-timing headers. + */ +function useCoreShopifyCookies({ + checkoutDomain, + storefrontAccessToken, + fetchTrackingValues = false, +}: CoreShopifyCookiesOptions) { + const [cookiesReady, setCookiesReady] = useState(!fetchTrackingValues); + const hasFetchedTrackingValues = useRef(false); + + useEffect(() => { + if (!fetchTrackingValues) { + // Backend did the work, or proxy is disabled. + setCookiesReady(true); + return; + } + + // React runs effects twice in dev mode, avoid double fetching + if (hasFetchedTrackingValues.current) return; + hasFetchedTrackingValues.current = true; + + // Fetch consent from browser via proxy + fetchTrackingValuesFromBrowser(storefrontAccessToken) + .catch((error) => + checkoutDomain + ? // Retry with checkout domain if available to at least + // get the server-timing values for tracking. + fetchTrackingValuesFromBrowser( + storefrontAccessToken, + checkoutDomain, + ) + : Promise.reject(error), + ) + .catch((error) => { + console.warn( + '[h2:warn:useShopifyCookies] Failed to fetch tracking values from browser: ' + + (error instanceof Error ? error.message : String(error)), + ); + }) + .finally(() => { + // Proceed even on errors, degraded tracking is better than no app + setCookiesReady(true); + }); + }, [checkoutDomain, fetchTrackingValues, storefrontAccessToken]); + + return cookiesReady; +} diff --git a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx index b20cc9a23e..6035fc84d4 100644 --- a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx +++ b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.test.tsx @@ -89,12 +89,14 @@ const CART_DATA_3 = { } as CartReturn; // Mock the useLocation hook to return a different path each time to simulate page navigation +// Also mock useRevalidator which is used by useCustomerPrivacy let pathCount = 1; vi.mock('@remix-run/react', () => ({ useLocation: () => ({ pathname: `/example/path/${pathCount++}`, search: '', }), + useRevalidator: () => ({revalidate: vi.fn()}), })); // Avoid downloading the PerfKit script in tests diff --git a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx index c2c3857344..9621677f97 100644 --- a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx +++ b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx @@ -5,7 +5,6 @@ import { useMemo, createContext, useContext, - useRef, } from 'react'; import {type CartReturn} from '../cart/queries/cart-types'; import { @@ -60,7 +59,11 @@ export type ShopAnalytics = { export type Consent = Partial< Pick< CustomerPrivacyApiProps, - 'checkoutDomain' | 'storefrontAccessToken' | 'withPrivacyBanner' | 'country' + | 'checkoutDomain' + | 'sameDomainForStorefrontApi' + | 'storefrontAccessToken' + | 'withPrivacyBanner' + | 'country' > > & {language?: LanguageCode}; // the privacyBanner SDKs refers to "language" as "locale" :( @@ -73,7 +76,7 @@ export type AnalyticsProviderProps = { canTrack?: () => boolean; /** An optional custom payload to pass to all events. e.g language/locale/currency. */ customData?: Record; - /** The shop configuration required to publish analytics events to Shopify. Use [`getShopAnalytics`](/docs/api/hydrogen/2025-04/utilities/getshopanalytics). */ + /** The shop configuration required to publish analytics events to Shopify. Use [`getShopAnalytics`](/docs/api/hydrogen/utilities/getshopanalytics). */ shop: Promise | ShopAnalytics | null; /** The customer privacy consent configuration and options. */ consent: Consent; @@ -296,11 +299,11 @@ function AnalyticsProvider({ shop: shopProp = null, cookieDomain, }: AnalyticsProviderProps): JSX.Element { - const listenerSet = useRef(false); const {shop} = useShopAnalytics(shopProp); const [analyticsLoaded, setAnalyticsLoaded] = useState( customCanTrack ? true : false, ); + const [consentCollected, setConsentCollected] = useState(false); const [carts, setCarts] = useState({cart: null, prevCart: null}); const [canTrack, setCanTrack] = useState<() => boolean>( customCanTrack ? () => customCanTrack : () => shopifyCanTrack, @@ -314,6 +317,8 @@ function AnalyticsProvider({ '[h2:error:Analytics.Provider] - Mock shop is used. Analytics will not work properly.', ); } else { + // TODO: we likely don't need checkout domain if SFAPI proxy is enabled + // but keep it for backward compatibility for now until we have checkout URL params. if (!consent.checkoutDomain) { const errorMsg = messageOnError( 'consent.checkoutDomain', @@ -379,20 +384,23 @@ function AnalyticsProvider({ {!!shop && !!currentCart && ( )} - {!!shop && consent.checkoutDomain && ( + {!!shop && ( { - listenerSet.current = true; setAnalyticsLoaded(true); setCanTrack( customCanTrack ? () => customCanTrack : () => shopifyCanTrack, ); + + // Delay loading PerfKit until consent is collected + // so that it reads updated tracking values from old cookies. + setConsentCollected(true); }} domain={cookieDomain} /> )} - {!!shop && } + {!!shop && consentCollected && } ); } @@ -428,7 +436,7 @@ function useShopAnalytics(shopProp: AnalyticsProviderProps['shop']): { type ShopAnalyticsProps = { /** - * The storefront client instance created by [`createStorefrontClient`](docs/api/hydrogen/2025-04/utilities/createstorefrontclient). + * The storefront client instance created by [`createStorefrontClient`](docs/api/hydrogen/utilities/createstorefrontclient). */ storefront: Storefront; /** @@ -498,7 +506,7 @@ export type AnalyticsContextValueForDoc = { prevCart?: UserCart | DefaultCart; /** A function to publish an analytics event. */ publish?: AnalyticsContextPublishForDoc; - /** A function to register with the analytics provider. It holds the first browser load events until all registered key has executed the supplied `ready` function. [See example register usage](/docs/api/hydrogen/2025-04/hooks/useanalytics#example-useanalytics.register). */ + /** A function to register with the analytics provider. It holds the first browser load events until all registered key has executed the supplied `ready` function. [See example register usage](/docs/api/hydrogen/hooks/useanalytics#example-useanalytics.register). */ register?: (key: string) => {ready: () => void}; /** The shop configuration required to publish events to Shopify. */ shop?: Promise | ShopAnalytics | null; diff --git a/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx b/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx index df9f8c90ad..2cbc8b444f 100644 --- a/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx +++ b/packages/hydrogen/src/analytics-manager/ShopifyAnalytics.tsx @@ -23,7 +23,7 @@ import type { CartLineUpdatePayload, SearchViewPayload, } from './AnalyticsView'; -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; import { CartLine, ComponentizableCartLine, @@ -64,6 +64,7 @@ export function ShopifyAnalytics({ const {subscribe, register, canTrack} = useAnalytics(); const [shopifyReady, setShopifyReady] = useState(false); const [privacyReady, setPrivacyReady] = useState(false); + const [collectedConsent, setCollectedConsent] = useState(''); const init = useRef(false); const {checkoutDomain, storefrontAccessToken, language} = consent; const {ready: shopifyAnalyticsReady} = register('Internal_Shopify_Analytics'); @@ -76,15 +77,35 @@ export function ShopifyAnalytics({ storefrontAccessToken: !storefrontAccessToken ? 'abcdefghijklmnopqrstuvwxyz123456' : storefrontAccessToken, - onVisitorConsentCollected: () => setPrivacyReady(true), - onReady: () => setPrivacyReady(true), + // If we use privacy banner, we should wait until consent is collected. + // Otherwise, we can consider privacy ready immediately: + onReady: () => !consent.withPrivacyBanner && setPrivacyReady(true), + onVisitorConsentCollected: (consent) => { + try { + // Store consent to refresh local cookies after it changes + setCollectedConsent(JSON.stringify(consent)); + } catch (e) {} + + setPrivacyReady(true); + }, }); + const hasUserConsent = useMemo( + // must be initialized with true to avoid removing cookies too early + () => (privacyReady ? canTrack() : true), + // Make this value depend on collectedConsent to re-run `canTrack()` when consent changes + [privacyReady, canTrack, collectedConsent], + ); + // set up shopify_Y and shopify_S cookies useShopifyCookies({ - hasUserConsent: privacyReady ? canTrack() : true, // must be initialized with true + hasUserConsent, domain, checkoutDomain, + // Already done inside useCustomerPrivacy + fetchTrackingValues: false, + // Avoid creating local cookies too early + ignoreDeprecatedCookies: !privacyReady, }); useEffect(() => { @@ -148,21 +169,21 @@ function prepareBasePageViewPayload( return; } - const eventPayload: ShopifyPageViewPayload = { + const eventPayload = { shopifySalesChannel: 'hydrogen', assetVersionId: version, ...payload.shop, hasUserConsent, ...getClientBrowserParameters(), + analyticsAllowed: customerPrivacy.analyticsProcessingAllowed(), + marketingAllowed: customerPrivacy.marketingAllowed(), + saleOfDataAllowed: customerPrivacy.saleOfDataAllowed(), ccpaEnforced: !customerPrivacy.saleOfDataAllowed(), gdprEnforced: !( customerPrivacy.marketingAllowed() && customerPrivacy.analyticsProcessingAllowed() ), - analyticsAllowed: customerPrivacy.analyticsProcessingAllowed(), - marketingAllowed: customerPrivacy.marketingAllowed(), - saleOfDataAllowed: customerPrivacy.saleOfDataAllowed(), - }; + } as ShopifyPageViewPayload; return eventPayload; } diff --git a/packages/hydrogen/src/cache/server-fetch.ts b/packages/hydrogen/src/cache/server-fetch.ts index c0f8327c53..295d87c84c 100644 --- a/packages/hydrogen/src/cache/server-fetch.ts +++ b/packages/hydrogen/src/cache/server-fetch.ts @@ -14,10 +14,16 @@ export type FetchCacheOptions = { shouldCacheResponse: (body: T, response: Response) => boolean; waitUntil?: WaitUntil; debugInfo?: DebugOptions; + /** Called when fresh raw headers are received (skipped on cache hits) */ + onRawHeaders?: (headers: Headers) => void; }; type SerializableResponse = [any, ResponseInit]; +// Exclude headers that are not safe or useful to cache +// since they are individual to each user session/request. +const excludedHeaders = ['set-cookie', 'server-timing']; + function toSerializableResponse( body: any, response: Response, @@ -27,7 +33,9 @@ function toSerializableResponse( { status: response.status, statusText: response.statusText, - headers: Array.from(response.headers.entries()), + headers: [...response.headers].filter( + ([key]) => !excludedHeaders.includes(key.toLowerCase()), + ), }, ]; } @@ -51,6 +59,7 @@ export async function fetchWithServerCache( shouldCacheResponse, waitUntil, debugInfo, + onRawHeaders, }: FetchCacheOptions, ): Promise { if (!cacheOptions && (!requestInit.method || requestInit.method === 'GET')) { @@ -61,6 +70,8 @@ export async function fetchWithServerCache( cacheKey, async () => { const response = await fetch(url, requestInit); + onRawHeaders?.(response.headers); + if (!response.ok) { // Skip caching and consuming the response body return response; diff --git a/packages/hydrogen/src/constants.ts b/packages/hydrogen/src/constants.ts index 125695e351..195efa6f59 100644 --- a/packages/hydrogen/src/constants.ts +++ b/packages/hydrogen/src/constants.ts @@ -5,3 +5,7 @@ export const STOREFRONT_ACCESS_TOKEN_HEADER = export const SDK_VARIANT_HEADER = 'X-SDK-Variant'; export const SDK_VARIANT_SOURCE_HEADER = 'X-SDK-Variant-Source'; export const SDK_VERSION_HEADER = 'X-SDK-Version'; +export const SHOPIFY_CLIENT_IP_HEADER = 'X-Shopify-Client-IP'; +export const SHOPIFY_CLIENT_IP_SIG_HEADER = 'X-Shopify-Client-IP-Sig'; +export const HYDROGEN_SFAPI_PROXY_KEY = '_sfapi_proxy'; +export const HYDROGEN_SERVER_TRACKING_KEY = '_server_tracking'; diff --git a/packages/hydrogen/src/createHydrogenContext.test.ts b/packages/hydrogen/src/createHydrogenContext.test.ts index eff430f924..9ff087b3a6 100644 --- a/packages/hydrogen/src/createHydrogenContext.test.ts +++ b/packages/hydrogen/src/createHydrogenContext.test.ts @@ -104,6 +104,7 @@ describe('createHydrogenContext', () => { const mockStorefrontHeaders = { requestGroupId: 'requestGroupId value', buyerIp: 'buyerIp value', + buyerIpSig: 'buyerIpSig value', cookie: 'cookie value', purpose: 'purpose value', }; @@ -139,6 +140,7 @@ describe('createHydrogenContext', () => { const mockeStorefrontHeaders = { requestGroupId: 'requestGroupId value', buyerIp: 'buyerIp value', + buyerIpSig: 'buyerIpSig value', cookie: 'cookie value', purpose: 'purpose', }; diff --git a/packages/hydrogen/src/createHydrogenContext.ts b/packages/hydrogen/src/createHydrogenContext.ts index 812d371b69..2fd399e553 100644 --- a/packages/hydrogen/src/createHydrogenContext.ts +++ b/packages/hydrogen/src/createHydrogenContext.ts @@ -248,8 +248,9 @@ function getStorefrontHeaders(request: CrossRuntimeRequest): StorefrontHeaders { return { requestGroupId: getHeader(request, 'request-id'), buyerIp: getHeader(request, 'oxygen-buyer-ip'), + buyerIpSig: getHeader(request, 'X-Shopify-Client-IP-Sig'), cookie: getHeader(request, 'cookie'), - purpose: getHeader(request, 'purpose'), + purpose: getHeader(request, 'sec-purpose') || getHeader(request, 'purpose'), }; } diff --git a/packages/hydrogen/src/createRequestHandler.ts b/packages/hydrogen/src/createRequestHandler.ts new file mode 100644 index 0000000000..7927cf9471 --- /dev/null +++ b/packages/hydrogen/src/createRequestHandler.ts @@ -0,0 +1,139 @@ +import { + createRequestHandler as createRemixRequestHandler, + type AppLoadContext, + type ServerBuild, +} from '@remix-run/server-runtime'; +import {HYDROGEN_SFAPI_PROXY_KEY} from './constants'; +import {appendServerTimingHeader} from './utils/server-timing'; +import {warnOnce} from './utils/warning'; + +type CreateRequestHandlerOptions = { + /** Remix's server build */ + build: ServerBuild; + /** Remix's mode */ + mode?: string; + /** + * Function to provide the load context for each request. + * It must contain Hydrogen's storefront client instance + * for other Hydrogen utilities to work properly. + */ + getLoadContext?: (request: Request) => Promise | Context; + /** + * Whether to include the `powered-by` header in responses + * @default true + */ + poweredByHeader?: boolean; + /** + * Collect tracking information from subrequests such as cookies + * and forward them to the browser. Disable this if you are not + * using Hydrogen's built-in analytics. + * @default true + */ + collectTrackingInformation?: boolean; + /** + * Whether to proxy standard routes such as `/api/.../graphql.json` (Storefront API). + * You can disable this if you are handling these routes yourself. Ensure that + * the proxy works if you rely on Hydrogen's built-in behaviors such as analytics. + * @default true + */ + proxyStandardRoutes?: boolean; +}; + +/** + * Creates a request handler for Hydrogen apps using Remix. + */ +export function createRequestHandler({ + build, + mode, + poweredByHeader = true, + getLoadContext, + collectTrackingInformation = true, + proxyStandardRoutes = true, +}: CreateRequestHandlerOptions) { + const handleRequest = createRemixRequestHandler(build, mode); + + const appendPoweredByHeader = poweredByHeader + ? (response: Response) => + response.headers.append('powered-by', 'Shopify, Hydrogen') + : undefined; + + return async (request: Request) => { + const method = request.method; + + if ((method === 'GET' || method === 'HEAD') && request.body) { + return new Response(`${method} requests cannot have a body`, { + status: 400, + }); + } + + const url = new URL(request.url); + + if (url.pathname.includes('//')) { + return new Response(null, { + status: 301, + headers: { + location: url.pathname.replace(/\/+/g, '/'), + }, + }); + } + + const context = getLoadContext + ? ((await getLoadContext(request)) as AppLoadContext) + : undefined; + + // Access storefront from context if available + const storefront = ( + context as {storefront?: StorefrontForProxy} | undefined + )?.storefront; + + if (proxyStandardRoutes) { + if (!storefront) { + // TODO: this should throw error in future major version + warnOnce( + '[h2:createRequestHandler] Storefront instance is required to proxy standard routes.', + ); + } + + // Proxy Storefront API requests + if (storefront?.isStorefrontApiUrl(request)) { + const response = await storefront.forward(request); + appendPoweredByHeader?.(response); + return response; + } + } + + const response = await handleRequest(request, context); + + if (storefront && proxyStandardRoutes) { + if (collectTrackingInformation) { + storefront.setCollectedSubrequestHeaders(response); + } + + // TODO: assume SFAPI proxy is available in future major version + // Signal that SFAPI proxy is enabled for document requests. + // Note: sec-fetch-dest is automatically added by modern browsers, + // but we also check the Accept header for other clients. + const fetchDest = request.headers.get('sec-fetch-dest'); + if ( + (fetchDest && fetchDest === 'document') || + request.headers.get('accept')?.includes('text/html') + ) { + appendServerTimingHeader(response, {[HYDROGEN_SFAPI_PROXY_KEY]: '1'}); + } + } + + appendPoweredByHeader?.(response); + + return response; + }; +} + +/** + * Minimal storefront interface needed for proxy functionality. + * The full Storefront type is defined in ./storefront.ts. + */ +type StorefrontForProxy = { + isStorefrontApiUrl: (request: {url?: string}) => boolean; + forward: (request: Request) => Promise; + setCollectedSubrequestHeaders: (response: {headers: Headers}) => void; +}; diff --git a/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx b/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx index da658fd0de..abb5450a94 100644 --- a/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx +++ b/packages/hydrogen/src/customer-privacy/ShopifyCustomerPrivacy.tsx @@ -1,9 +1,18 @@ -import {useLoadScript} from '@shopify/hydrogen-react'; +import { + getTrackingValues, + useLoadScript, + useShopifyCookies, +} from '@shopify/hydrogen-react'; import { CountryCode, LanguageCode, } from '@shopify/hydrogen-react/storefront-api-types'; import {useEffect, useMemo, useRef, useState} from 'react'; +import {useRevalidator} from '@remix-run/react'; +import { + isSfapiProxyEnabled, + hasServerReturnedTrackingValues, +} from '../utils/server-timing'; export type ConsentStatus = boolean | undefined; @@ -46,18 +55,13 @@ export type SetConsentHeadlessParams = VisitorConsent & doesMerchantSupportGranularConsent firstPartyMarketingAllowed getCCPAConsent - getRegulation - getShopPrefs getTrackingConsent - isRegulationEnforced marketingAllowed preferencesProcessingAllowed saleOfDataAllowed saleOfDataRegion - setCCPAConsent setTrackingConsent shouldShowBanner - shouldShowCCPABanner shouldShowGDPRBanner thirdPartyMarketingAllowed **/ @@ -71,6 +75,7 @@ export type OriginalCustomerPrivacy = { consent: SetConsentHeadlessParams, callback: (data: {error: string} | undefined) => void, ) => void; + shouldShowBanner: () => boolean; }; export type CustomerPrivacy = Omit< @@ -111,10 +116,15 @@ export type CustomerPrivacyApiProps = { onVisitorConsentCollected?: (consent: VisitorConsentCollected) => void; /** Callback to be call when customer privacy api is ready. */ onReady?: () => void; + /** + * Whether consent libraries can use same-domain requests to the Storefront API. + * Defaults to true if the standard route proxy is enabled in Hydrogen server. + */ + sameDomainForStorefrontApi?: boolean; }; export const CONSENT_API = - 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.1/consent-tracking-api.js'; + 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js'; export const CONSENT_API_WITH_BANNER = 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js'; @@ -130,9 +140,38 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) { withPrivacyBanner = false, onVisitorConsentCollected, onReady, - ...consentConfig + checkoutDomain, + storefrontAccessToken, + country, + locale, + sameDomainForStorefrontApi, } = props; + /** Determine if SF API proxy is enabled in Hydrogen server */ + const hasSfapiProxy = useMemo( + () => sameDomainForStorefrontApi ?? isSfapiProxyEnabled(), + [sameDomainForStorefrontApi], + ); + + /** + * Determine if we need to fetch tracking values from the browser. + * This can happen if the server did not collect this information already (e.g. subrequests were cached). + */ + const fetchTrackingValuesFromBrowser = useMemo( + () => hasSfapiProxy && !hasServerReturnedTrackingValues(), + [hasSfapiProxy], + ); + + const cookiesReady = useShopifyCookies({ + fetchTrackingValues: fetchTrackingValuesFromBrowser, + storefrontAccessToken, + ignoreDeprecatedCookies: true, + }); + + // Store initial tracking values to compare later + const initialTrackingValues = useMemo(getTrackingValues, [cookiesReady]); + const {revalidate} = useRevalidator(); + // Load the Shopify customer privacy API with or without the privacy banner // NOTE: We no longer use the status because we need `ready` to be not when the script is loaded // but instead when both `privacyBanner` (optional) and customerPrivacy are loaded in the window @@ -142,14 +181,9 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) { }, }); - const {observing, setLoaded} = useApisLoaded({ - withPrivacyBanner, - onLoaded: onReady, - }); + const {observing, setLoaded, apisLoaded} = useApisLoaded({withPrivacyBanner}); const config = useMemo(() => { - const {checkoutDomain, storefrontAccessToken} = consentConfig; - if (!checkoutDomain) logMissingConfig('checkoutDomain'); if (!storefrontAccessToken) logMissingConfig('storefrontAccessToken'); @@ -164,23 +198,79 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) { ); } + const commonAncestorDomain = parseStoreDomain(checkoutDomain); + const sfapiDomain = + // Check if standard route proxy is enabled in Hydrogen server + // to use it instead of doing a cross-origin request to checkout. + hasSfapiProxy && typeof window !== 'undefined' + ? window.location.host + : checkoutDomain; + const config: CustomerPrivacyConsentConfig = { - checkoutRootDomain: checkoutDomain, + // This domain is used to send requests to SFAPI for setting and getting consent. + checkoutRootDomain: sfapiDomain, + // Prefix with a dot to ensure this domain is different from checkoutRootDomain. + // This will ensure old cookies are set for a cross-subdomain checkout setup + // so that we keep backward compatibility until new cookies are rolled out. + // Once consent-tracking-api is updated to not rely on cookies anymore, we can remove this. + storefrontRootDomain: commonAncestorDomain + ? '.' + commonAncestorDomain + : undefined, storefrontAccessToken, - storefrontRootDomain: parseStoreDomain(checkoutDomain), - country: consentConfig.country, - locale: consentConfig.locale, + country, + locale, }; return config; - }, [consentConfig, parseStoreDomain, logMissingConfig]); + }, [ + logMissingConfig, + checkoutDomain, + storefrontAccessToken, + country, + locale, + ]); // settings event listeners for visitorConsentCollected useEffect(() => { const consentCollectedHandler = ( event: CustomEvent, ) => { + const latestTrackingValues = getTrackingValues(); + if ( + initialTrackingValues.visitToken !== latestTrackingValues.visitToken || + initialTrackingValues.uniqueToken !== latestTrackingValues.uniqueToken + ) { + // Tracking has changed: revalidate data to get updated cart.checkoutUrl with new params. + revalidate(); + } + if (onVisitorConsentCollected) { + const customerPrivacy = getCustomerPrivacy(); + if (customerPrivacy?.shouldShowBanner()) { + // This type is plain wrong: + const consentValues = + customerPrivacy.currentVisitorConsent() as unknown as Record< + keyof VisitorConsent, + string + >; + + if (consentValues) { + // Mimic Privacy Banner SDK behavior to detect no-interaction: + const NO_VALUE = ''; + const noInteraction = + consentValues.marketing === NO_VALUE && + consentValues.analytics === NO_VALUE && + consentValues.preferences === NO_VALUE; + + if (noInteraction) { + // The banner is being shown but the user has not interacted yet. + // The fact that this event has been fired before interaction + // is likely a bug in Privacy Banner SDK. We ignore this event for now. + return; + } + } + } + onVisitorConsentCollected(event.detail); } }; @@ -219,20 +309,14 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) { 'showPreferences' in value && 'loadBanner' in value ) { - const privacyBanner = value as PrivacyBanner; - - // auto load the banner if applicable - privacyBanner.loadBanner(config); - // overwrite the privacyBanner methods customPrivacyBanner = overridePrivacyBannerMethods({ - privacyBanner, + privacyBanner: value as PrivacyBanner, config, }); // set the loaded state for the privacyBanner setLoaded.privacyBanner(); - emitCustomerPrivacyApiLoaded(); } }, }; @@ -287,6 +371,8 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) { // overwrite the tracking consent method customCustomerPrivacy = { ...customerPrivacy, + // Note: this method is not used by the privacy-banner, + // it bundles its own setTrackingConsent. setTrackingConsent: overrideCustomerPrivacySetTrackingConsent( {customerPrivacy, config}, ), @@ -298,7 +384,6 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) { }; setLoaded.customerPrivacy(); - emitCustomerPrivacyApiLoaded(); } }, }); @@ -311,6 +396,34 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) { setLoaded.customerPrivacy, ]); + useEffect(() => { + if (!apisLoaded || !cookiesReady) return; + + const customerPrivacy = getCustomerPrivacy(); + // @ts-expect-error Internal property + if (customerPrivacy && !customerPrivacy.cachedConsent) { + // Consent-tracking-api assumes consent if it doesn't have anything to work with. + // Since we have fetched the tracking values already, we set its cachedConsent here. + // This is a workaround until consent-tracking-api knows how to read server-timing for us. + const trackingValues = getTrackingValues(); + if (trackingValues.consent) { + // @ts-expect-error Internal property + customerPrivacy.cachedConsent = trackingValues.consent; + } + } + + if (withPrivacyBanner) { + const privacyBanner = getPrivacyBanner(); + if (privacyBanner) { + // auto load the banner if applicable + privacyBanner.loadBanner(config); + } + } + + emitCustomerPrivacyApiLoaded(); + onReady?.(); + }, [apisLoaded, cookiesReady]); + // return the customerPrivacy and privacyBanner (optional) modified APIs const result = { customerPrivacy: getCustomerPrivacy(), @@ -334,23 +447,17 @@ function emitCustomerPrivacyApiLoaded() { document.dispatchEvent(event); } -function useApisLoaded({ - withPrivacyBanner, - onLoaded, -}: { - withPrivacyBanner: boolean; - onLoaded?: () => void; -}) { +function useApisLoaded({withPrivacyBanner}: {withPrivacyBanner: boolean}) { // used to help run the watchers only once const observing = useRef({customerPrivacy: false, privacyBanner: false}); // [customerPrivacy, privacyBanner] - const [apisLoaded, setApisLoaded] = useState( + const [apisLoadedArray, setApisLoaded] = useState( withPrivacyBanner ? [false, false] : [false], ); // combined loaded state for both APIs - const loaded = apisLoaded.every(Boolean); + const apisLoaded = apisLoadedArray.every(Boolean); const setLoaded = { customerPrivacy: () => { @@ -368,14 +475,7 @@ function useApisLoaded({ }, }; - useEffect(() => { - if (loaded && onLoaded) { - // both APIs are loaded in the window - onLoaded(); - } - }, [loaded, onLoaded]); - - return {observing, setLoaded}; + return {observing, setLoaded, apisLoaded}; } /** @@ -384,7 +484,7 @@ function useApisLoaded({ function parseStoreDomain(checkoutDomain: string) { if (typeof window === 'undefined') return; - const host = window.document.location.host; + const host = window.location.host; const checkoutDomainParts = checkoutDomain.split('.').reverse(); const currentDomainParts = host.split('.').reverse(); const sameDomainParts: Array = []; @@ -394,7 +494,7 @@ function parseStoreDomain(checkoutDomain: string) { } }); - return sameDomainParts.reverse().join('.'); + return sameDomainParts.reverse().join('.') || undefined; } /** diff --git a/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.test.tsx b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.test.tsx index 53f919f444..a5628707d0 100644 --- a/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.test.tsx +++ b/packages/hydrogen/src/customer-privacy/useCustomerPrivacy.test.tsx @@ -6,6 +6,18 @@ import { CONSENT_API_WITH_BANNER, } from './ShopifyCustomerPrivacy.js'; +// Mock useRevalidator from @remix-run/react +vi.mock('@remix-run/react', async () => { + const actual = + await vi.importActual( + '@remix-run/react', + ); + return { + ...actual, + useRevalidator: () => ({revalidate: vi.fn()}), + }; +}); + let html: HTMLHtmlElement; let head: HTMLHeadElement; let body: HTMLBodyElement; diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index d562dce06d..424e9f7e7f 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -68,6 +68,7 @@ export { createHydrogenContext, type HydrogenContext, } from './createHydrogenContext'; +export {createRequestHandler} from './createRequestHandler'; export {createContentSecurityPolicy, useNonce} from './csp/csp'; export {Script} from './csp/Script'; export {createCustomerAccountClient} from './customer/customer'; @@ -144,6 +145,7 @@ export { getClientBrowserParameters, getProductOptions, getShopifyCookies, + getTrackingValues, Image, IMAGE_FRAGMENT, isOptionValueCombinationInEncodedVariant, diff --git a/packages/hydrogen/src/storefront.test.ts b/packages/hydrogen/src/storefront.test.ts index 45d8fabf35..305c3f7071 100644 --- a/packages/hydrogen/src/storefront.test.ts +++ b/packages/hydrogen/src/storefront.test.ts @@ -28,6 +28,7 @@ describe('createStorefrontClient', () => { const storefrontHeaders = { requestGroupId: '123', buyerIp: '::1', + buyerIpSig: null, purpose: 'test', cookie: '_shopify_y=123; other=456; _shopify_s=789', }; diff --git a/packages/hydrogen/src/storefront.ts b/packages/hydrogen/src/storefront.ts index c2e0ff4984..f89a9fad09 100644 --- a/packages/hydrogen/src/storefront.ts +++ b/packages/hydrogen/src/storefront.ts @@ -1,11 +1,10 @@ import { createStorefrontClient as createStorefrontUtilities, - getShopifyCookies, - SHOPIFY_S, - SHOPIFY_Y, SHOPIFY_STOREFRONT_ID_HEADER, SHOPIFY_STOREFRONT_Y_HEADER, SHOPIFY_STOREFRONT_S_HEADER, + SHOPIFY_UNIQUE_TOKEN_HEADER, + SHOPIFY_VISIT_TOKEN_HEADER, type StorefrontClientProps, } from '@shopify/hydrogen-react'; import type {WritableDeep} from 'type-fest'; @@ -16,6 +15,9 @@ import { SDK_VERSION_HEADER, STOREFRONT_ACCESS_TOKEN_HEADER, STOREFRONT_REQUEST_GROUP_ID_HEADER, + SHOPIFY_CLIENT_IP_HEADER, + SHOPIFY_CLIENT_IP_SIG_HEADER, + HYDROGEN_SERVER_TRACKING_KEY, } from './constants'; import { CacheNone, @@ -54,6 +56,12 @@ import { type StackInfo, } from './utils/callsites'; import type {WaitUntil, StorefrontHeaders} from './types'; +import {extractHeaders, getSafePathname, SFAPI_RE} from './utils/request'; +import { + appendServerTimingHeader, + extractServerTimingHeader, + TrackedTimingsRecord, +} from './utils/server-timing'; export type I18nBase = { language: LanguageCode; @@ -159,6 +167,25 @@ export type Storefront = { typeof createStorefrontUtilities >['getStorefrontApiUrl']; i18n: TI18n; + getHeaders: () => Record; + /** + * Checks if the request URL matches the Storefront API GraphQL endpoint. + */ + isStorefrontApiUrl: (request: {url?: string}) => boolean; + /** + * Forwards the request to the Storefront API. + * It reads the API version from the request URL. + */ + forward: ( + request: Request, + options?: Pick, + ) => Promise; + /** + * Sets the collected subrequest headers in the response. + * Useful to forward the cookies and server-timing headers + * from server subrequests to the browser. + */ + setCollectedSubrequestHeaders: (response: {headers: Headers}) => void; }; type HydrogenClientProps = { @@ -235,21 +262,60 @@ export function createStorefrontClient( buyerIp: storefrontHeaders?.buyerIp || '', }); + if (storefrontHeaders?.buyerIp) { + defaultHeaders[SHOPIFY_CLIENT_IP_HEADER] = storefrontHeaders.buyerIp; + } + + if (storefrontHeaders?.buyerIpSig) { + defaultHeaders[SHOPIFY_CLIENT_IP_SIG_HEADER] = storefrontHeaders.buyerIpSig; + } + defaultHeaders[STOREFRONT_REQUEST_GROUP_ID_HEADER] = storefrontHeaders?.requestGroupId || generateUUID(); if (storefrontId) defaultHeaders[SHOPIFY_STOREFRONT_ID_HEADER] = storefrontId; if (LIB_VERSION) defaultHeaders['user-agent'] = `Hydrogen ${LIB_VERSION}`; - if (storefrontHeaders && storefrontHeaders.cookie) { - const cookies = getShopifyCookies(storefrontHeaders.cookie ?? ''); + const requestCookie = storefrontHeaders?.cookie ?? ''; + if (requestCookie) defaultHeaders['cookie'] = requestCookie; + + let uniqueToken: string | undefined; + let visitToken: string | undefined; + + // If new cookies are not present, we need to check legacy cookies + // or generate new tokens, which we pass in the headers. This ensures + // we track the current session and SFAPI creates new cookies with + // the given tokens for subsequent requests. + if (!/\b_shopify_(analytics|marketing)=/.test(requestCookie)) { + const legacyUniqueToken = requestCookie.match(/\b_shopify_y=([^;]+)/)?.[1]; + const legacyVisitToken = requestCookie.match(/\b_shopify_s=([^;]+)/)?.[1]; + + // These legacy headers might not be needed since they are already + // passed via cookies, but it's better to be explicit just in case. + if (legacyUniqueToken) { + defaultHeaders[SHOPIFY_STOREFRONT_Y_HEADER] = legacyUniqueToken; + } + if (legacyVisitToken) { + defaultHeaders[SHOPIFY_STOREFRONT_S_HEADER] = legacyVisitToken; + } - if (cookies[SHOPIFY_Y]) - defaultHeaders[SHOPIFY_STOREFRONT_Y_HEADER] = cookies[SHOPIFY_Y]; - if (cookies[SHOPIFY_S]) - defaultHeaders[SHOPIFY_STOREFRONT_S_HEADER] = cookies[SHOPIFY_S]; + // Use legacy tokens if available for session migration, or generate + // new ones and set them in the new headers to let SFAPI create + // cookies and track the subrequests made in this session. + uniqueToken = legacyUniqueToken ?? generateUUID(); + visitToken = legacyVisitToken ?? generateUUID(); + + defaultHeaders[SHOPIFY_UNIQUE_TOKEN_HEADER] = uniqueToken; + defaultHeaders[SHOPIFY_VISIT_TOKEN_HEADER] = visitToken; } + let collectedSubrequestHeaders: + | undefined + | { + serverTiming: string; + setCookie: string[]; + }; + // Remove any headers that are identifiable to the user or request const cacheKeyHeader = JSON.stringify({ 'content-type': defaultHeaders['content-type'], @@ -329,6 +395,21 @@ export function createStorefrontClient( graphql: graphqlData, purpose: storefrontHeaders?.purpose, }, + onRawHeaders: (headers) => { + // Set this the first time we get a fresh response (cache miss) + // to increase the chance of returning it from the main server response. + // Note: a server response needs its headers set at the time of sending, + // while the body can be streamed later. Therefore, this value here + // might not be used if subrequests are not over before the main response is sent. + collectedSubrequestHeaders ??= { + // `getSetCookie` may not be available in all environments (e.g., classic Remix compiler) + setCookie: + typeof headers.getSetCookie === 'function' + ? headers.getSetCookie() + : [], + serverTiming: headers.get('server-timing') ?? '', + }; + }, }); const errorOptions: GraphQLErrorOptions = { @@ -442,9 +523,134 @@ export function createStorefrontClient( generateCacheControlHeader, getPublicTokenHeaders, getPrivateTokenHeaders, + getHeaders: () => ({...defaultHeaders}), getShopifyDomain, getApiUrl: getStorefrontApiUrl, i18n: (i18n ?? defaultI18n) as TI18n, + + /** + * Checks if the request is targeting the Storefront API endpoint. + */ + isStorefrontApiUrl(request) { + return SFAPI_RE.test(getSafePathname(request.url ?? '')); + }, + /** + * Forwards the request to the Storefront API. + */ + async forward(request, options) { + const forwardedHeaders = new Headers([ + // Forward only a selected set of headers to the Storefront API + // to avoid getting 403 errors due to unexpected headers. + ...extractHeaders( + (key) => request.headers.get(key), + [ + 'accept', + 'accept-encoding', + 'accept-language', + // Access-Control headers are used for CORS preflight requests. + 'access-control-request-headers', + 'access-control-request-method', + 'content-type', + 'content-length', + 'cookie', + 'origin', + 'referer', + 'user-agent', + STOREFRONT_ACCESS_TOKEN_HEADER, + SHOPIFY_UNIQUE_TOKEN_HEADER, + SHOPIFY_VISIT_TOKEN_HEADER, + ], + ), + // Add some headers to help with geolocalization and debugging + ...extractHeaders( + (key) => defaultHeaders[key], + [ + SHOPIFY_CLIENT_IP_HEADER, + SHOPIFY_CLIENT_IP_SIG_HEADER, + SHOPIFY_STOREFRONT_ID_HEADER, + STOREFRONT_REQUEST_GROUP_ID_HEADER, + ], + ), + ]); + + if (storefrontHeaders?.buyerIp) { + // Good for proxies to inform about the original client IP + forwardedHeaders.set('x-forwarded-for', storefrontHeaders.buyerIp); + } + + const storefrontApiVersion = + options?.storefrontApiVersion ?? + getSafePathname(request.url).match(SFAPI_RE)?.[1]; + + const sfapiResponse = await fetch( + getStorefrontApiUrl({storefrontApiVersion}), + { + method: request.method, + body: request.body, + headers: forwardedHeaders, + }, + ); + + // Create a new response to allow modifying headers + return new Response(sfapiResponse.body, sfapiResponse); + }, + + setCollectedSubrequestHeaders: (response: {headers: Headers}) => { + // Forward cookies + if (collectedSubrequestHeaders) { + for (const value of collectedSubrequestHeaders.setCookie) { + response.headers.append('Set-Cookie', value); + } + } + + const serverTiming = extractServerTimingHeader( + collectedSubrequestHeaders?.serverTiming, + ); + + const isDocumentResponse = response.headers + .get('content-type') + ?.startsWith('text/html'); + + // Only fallback to generated tokens for HTML responses. + // This ensures that non-HTML responses (e.g. JSON, Remix streaming) + // don't get unexpected _y/_s values. These values might be used by the + // browser to set cookies and track the user session if consent is allowed + // by default in their store settings (otherwise these values are dropped). + const fallbackValues = isDocumentResponse + ? ({_y: uniqueToken, _s: visitToken} satisfies TrackedTimingsRecord) + : undefined; + + // Forward tracking values via server-timing from subrequests, + // and fallback to the ones generated in the current request. + appendServerTimingHeader(response, { + ...fallbackValues, + ...serverTiming, + } satisfies TrackedTimingsRecord); + + // Optimization: We set this flag to indicate that the tracking work was done + // in the server to skip an extra request from the browser. Conditions: + // If any of these conditions are not met, the browser will perform + // a request to the SFAPI proxy to get all the necessary cookies and values. + if ( + isDocumentResponse && + collectedSubrequestHeaders && + // _shopify_essential cookie is always set, but we need more than that + collectedSubrequestHeaders.setCookie.length > 1 && + serverTiming?._y && + serverTiming?._s && + serverTiming?._cmp + ) { + // For all SF API requests (that aren't from cache), we expect _y and _s values, as well as the + // _shopify_essential cookie to be returned. Even if we get responses from SF API requests that + // are cache misses, we still may not get the _cmp value and the _shopify_marketing and _shopify_analytics cookies. + // Therefore, even if we had >=1 non-cache hit SF API request, we may still need to make the browser + // SF API request to the `consentManagement` endpoint in the SF API, which will give us the _cmp value + // and set the _shopify_marketing and _shopify_analytics cookies if they are missing. + appendServerTimingHeader(response, { + [HYDROGEN_SERVER_TRACKING_KEY]: '1', + }); + } + }, }, }; } diff --git a/packages/hydrogen/src/types.d.ts b/packages/hydrogen/src/types.d.ts index b24ebf026d..0b29317a2a 100644 --- a/packages/hydrogen/src/types.d.ts +++ b/packages/hydrogen/src/types.d.ts @@ -60,9 +60,11 @@ export type StorefrontHeaders = { requestGroupId: string | null; /** The IP address of the client. */ buyerIp: string | null; + /** The signature of the client's IP address for verification. */ + buyerIpSig: string | null; /** The cookie header from the client */ cookie: string | null; - /** The purpose header value for debugging */ + /** The sec-purpose or purpose header value */ purpose: string | null; }; diff --git a/packages/hydrogen/src/utils/request.ts b/packages/hydrogen/src/utils/request.ts index e6e47ac418..d393e550f8 100644 --- a/packages/hydrogen/src/utils/request.ts +++ b/packages/hydrogen/src/utils/request.ts @@ -1,3 +1,6 @@ +import {SHOPIFY_CLIENT_IP_SIG_HEADER} from '../constants'; +import type {StorefrontHeaders} from '../types'; + export type CrossRuntimeRequest = { url?: string; method?: string; @@ -25,3 +28,41 @@ export function getDebugHeaders(request?: CrossRuntimeRequest) { purpose: request ? getHeader(request, 'purpose') : undefined, }; } + +/** + * Extracts relevant Storefront headers from the given Oxygen request. + */ +export function getStorefrontHeaders( + request: CrossRuntimeRequest, +): StorefrontHeaders { + return { + requestGroupId: getHeader(request, 'request-id'), + buyerIp: getHeader(request, 'oxygen-buyer-ip'), + buyerIpSig: getHeader(request, SHOPIFY_CLIENT_IP_SIG_HEADER), + cookie: getHeader(request, 'cookie'), + // sec-purpose is added by browsers automatically when using link/prefetch or Speculation Rules + purpose: getHeader(request, 'sec-purpose') || getHeader(request, 'purpose'), + }; +} + +/** Regular expression to match Storefront API GraphQL endpoint paths */ +export const SFAPI_RE = /^\/api\/(unstable|2\d{3}-\d{2})\/graphql\.json$/; + +export const getSafePathname = (url: string) => { + try { + return new URL(url, 'http://e.c').pathname; + } catch { + return '/'; + } +}; + +export function extractHeaders( + extract: (key: string) => string | undefined | null, + keys: string[], +) { + return keys.reduce<[string, string][]>((acc, key) => { + const forwardedValue = extract(key); + if (forwardedValue) acc.push([key, forwardedValue]); + return acc; + }, []); +} diff --git a/packages/hydrogen/src/utils/server-timing.test.ts b/packages/hydrogen/src/utils/server-timing.test.ts new file mode 100644 index 0000000000..1bc5ffded7 --- /dev/null +++ b/packages/hydrogen/src/utils/server-timing.test.ts @@ -0,0 +1,74 @@ +import {describe, expect, it, vi} from 'vitest'; +import { + extractServerTimingHeader, + appendServerTimingHeader, + isSfapiProxyEnabled, + hasServerReturnedTrackingValues, +} from './server-timing'; +import { + HYDROGEN_SERVER_TRACKING_KEY, + HYDROGEN_SFAPI_PROXY_KEY, +} from '../constants'; + +describe('server-timing', () => { + describe('extractServerTimingHeader', () => { + it('parses tokens and consent information from the header value', () => { + const header = + '_y;desc="unique-token", _unused;desc="unused", _s;desc="visit-token", _cmp;desc="opt-in"'; + + expect(extractServerTimingHeader(header)).toEqual({ + _y: 'unique-token', + _s: 'visit-token', + _cmp: 'opt-in', + }); + }); + }); + + describe('appendServerTimingHeader', () => { + it('appends a Server-Timing header to the response', () => { + const response = {headers: new Headers()}; + + appendServerTimingHeader(response, { + _y: 'unique-token', + _s: 'visit-token', + _cmp: undefined, + }); + + expect(response.headers.get('Server-Timing')).toBe( + '_y;desc=unique-token, _s;desc=visit-token', + ); + }); + }); + + describe('performance api detection', () => { + it('detects if SFAPI proxy is enabled', () => { + expect(isSfapiProxyEnabled()).toBe(false); + + vi.stubGlobal('window', { + performance: { + getEntriesByType: () => [ + {serverTiming: [{name: HYDROGEN_SFAPI_PROXY_KEY}]}, + ], + }, + }); + + expect(isSfapiProxyEnabled()).toBe(true); + + vi.unstubAllGlobals(); + }); + + it('detects if backend fetched tracking is present', () => { + expect(hasServerReturnedTrackingValues()).toBe(false); + + vi.stubGlobal('window', { + performance: { + getEntriesByType: () => [ + {serverTiming: [{name: HYDROGEN_SERVER_TRACKING_KEY}]}, + ], + }, + }); + + expect(hasServerReturnedTrackingValues()).toBe(true); + }); + }); +}); diff --git a/packages/hydrogen/src/utils/server-timing.ts b/packages/hydrogen/src/utils/server-timing.ts new file mode 100644 index 0000000000..485cfeb5a5 --- /dev/null +++ b/packages/hydrogen/src/utils/server-timing.ts @@ -0,0 +1,84 @@ +import { + HYDROGEN_SFAPI_PROXY_KEY, + HYDROGEN_SERVER_TRACKING_KEY, +} from '../constants'; + +function buildServerTimingHeader(values: Record) { + return Object.entries(values) + .map(([key, value]) => (value ? `${key};desc=${value}` : undefined)) + .filter(Boolean) + .join(', '); +} + +/** + * Creates a Server-Timing header from the given values and appends it to the response. + */ +export function appendServerTimingHeader( + response: {headers: Headers}, + values: string | Parameters[0], +) { + const header = + typeof values === 'string' ? values : buildServerTimingHeader(values); + + if (header) { + response.headers.append('Server-Timing', header); + } +} + +// In order: unique token, visit token, and consent +const trackedTimings = ['_y', '_s', '_cmp'] as const; + +type TrackedTimingKeys = (typeof trackedTimings)[number]; +export type TrackedTimingsRecord = Partial>; + +export function extractServerTimingHeader( + serverTimingHeader?: string, +): TrackedTimingsRecord { + const values: TrackedTimingsRecord = {}; + if (!serverTimingHeader) return values; + + const re = new RegExp( + `\\b(${trackedTimings.join('|')});desc="?([^",]+)"?`, + 'g', + ); + + let match; + while ((match = re.exec(serverTimingHeader)) !== null) { + values[match[1] as TrackedTimingKeys] = match[2]; + } + + return values; +} + +/** + * Checks if a specific server-timing header is present in the navigation entry. + */ +function hasServerTimingInNavigationEntry(key: string): boolean { + if (typeof window === 'undefined') return false; + + try { + const navigationEntry = window.performance.getEntriesByType( + 'navigation', + )[0] as PerformanceNavigationTiming; + + return !!navigationEntry?.serverTiming?.some((entry) => entry.name === key); + } catch (e) { + return false; + } +} + +/** + * Checks if the SFAPI proxy is enabled by looking for the + * _sfapi_proxy server-timing header in the navigation entry. + */ +export function isSfapiProxyEnabled(): boolean { + return hasServerTimingInNavigationEntry(HYDROGEN_SFAPI_PROXY_KEY); +} + +/** + * Checks if the backend already fetched tracking values by looking for + * the _server_tracking server-timing header in the navigation entry. + */ +export function hasServerReturnedTrackingValues(): boolean { + return hasServerTimingInNavigationEntry(HYDROGEN_SERVER_TRACKING_KEY); +} diff --git a/packages/remix-oxygen/src/server.ts b/packages/remix-oxygen/src/server.ts index c6ed82b00c..1204fac6ed 100644 --- a/packages/remix-oxygen/src/server.ts +++ b/packages/remix-oxygen/src/server.ts @@ -11,19 +11,84 @@ Error.prototype.toString = function () { return this.stack || originalErrorToString.call(this); }; +/** Server-Timing header key to signal that the SFAPI proxy is enabled */ +const HYDROGEN_SFAPI_PROXY_KEY = '_sfapi_proxy'; + +let hasWarnedAboutStorefront = false; + +function warnOnce(message: string) { + if (!hasWarnedAboutStorefront) { + hasWarnedAboutStorefront = true; + console.warn(message); + } +} + +function buildServerTimingHeader(values: Record) { + return Object.entries(values) + .map(([key, value]) => (value ? `${key};desc=${value}` : undefined)) + .filter(Boolean) + .join(', '); +} + +function appendServerTimingHeader( + response: {headers: Headers}, + values: string | Record, +) { + const header = + typeof values === 'string' ? values : buildServerTimingHeader(values); + + if (header) { + response.headers.append('Server-Timing', header); + } +} + +type CreateRequestHandlerOptions = { + /** Remix's server build */ + build: ServerBuild; + /** Remix's mode */ + mode?: string; + /** + * Function to provide the load context for each request. + * It must contain Hydrogen's storefront client instance + * for other Hydrogen utilities to work properly. + */ + getLoadContext?: (request: Request) => Promise | Context; + /** + * Whether to include the `powered-by` header in responses + * @default true + */ + poweredByHeader?: boolean; + /** + * Collect tracking information from subrequests such as cookies + * and forward them to the browser. Disable this if you are not + * using Hydrogen's built-in analytics. + * @default true + */ + collectTrackingInformation?: boolean; + /** + * Whether to proxy standard routes such as `/api/.../graphql.json` (Storefront API). + * You can disable this if you are handling these routes yourself. Ensure that + * the proxy works if you rely on Hydrogen's built-in behaviors such as analytics. + * @default true + */ + proxyStandardRoutes?: boolean; +}; + export function createRequestHandler({ build, mode, poweredByHeader = true, getLoadContext, -}: { - build: ServerBuild; - mode?: string; - poweredByHeader?: boolean; - getLoadContext?: (request: Request) => Promise | Context; -}) { + collectTrackingInformation = true, + proxyStandardRoutes = true, +}: CreateRequestHandlerOptions) { const handleRequest = createRemixRequestHandler(build, mode); + const appendPoweredByHeader = poweredByHeader + ? (response: Response) => + response.headers.append('powered-by', 'Shopify, Hydrogen') + : undefined; + return async (request: Request) => { const method = request.method; @@ -48,6 +113,27 @@ export function createRequestHandler({ ? ((await getLoadContext(request)) as AppLoadContext) : undefined; + // Access storefront from context if available + const storefront = ( + context as {storefront?: StorefrontForProxy} | undefined + )?.storefront; + + if (proxyStandardRoutes) { + if (!storefront) { + // TODO: this should throw error in future major version + warnOnce( + '[h2:createRequestHandler] Storefront instance is required to proxy standard routes.', + ); + } + + // Proxy Storefront API requests + if (storefront?.isStorefrontApiUrl(request)) { + const response = await storefront.forward(request); + appendPoweredByHeader?.(response); + return response; + } + } + if (process.env.NODE_ENV === 'development' && context) { // Store logger in globalThis so it can be accessed from the worker. // The global property must be different from the binding name, @@ -59,10 +145,26 @@ export function createRequestHandler({ const response = await handleRequest(request, context); - if (poweredByHeader) { - response.headers.append('powered-by', 'Shopify, Hydrogen'); + if (storefront && proxyStandardRoutes) { + if (collectTrackingInformation) { + storefront.setCollectedSubrequestHeaders(response); + } + + // TODO: assume SFAPI proxy is available in future major version + // Signal that SFAPI proxy is enabled for document requests. + // Note: sec-fetch-dest is automatically added by modern browsers, + // but we also check the Accept header for other clients. + const fetchDest = request.headers.get('sec-fetch-dest'); + if ( + (fetchDest && fetchDest === 'document') || + request.headers.get('accept')?.includes('text/html') + ) { + appendServerTimingHeader(response, {[HYDROGEN_SFAPI_PROXY_KEY]: '1'}); + } } + appendPoweredByHeader?.(response); + if (process.env.NODE_ENV === 'development') { globalThis.__H2O_LOG_EVENT?.({ eventType: 'request', @@ -85,6 +187,7 @@ export function createRequestHandler({ type StorefrontHeaders = { requestGroupId: string | null; buyerIp: string | null; + buyerIpSig: string | null; cookie: string | null; purpose: string | null; }; @@ -94,7 +197,19 @@ export function getStorefrontHeaders(request: Request): StorefrontHeaders { return { requestGroupId: headers.get('request-id'), buyerIp: headers.get('oxygen-buyer-ip'), + buyerIpSig: headers.get('X-Shopify-Client-IP-Sig'), cookie: headers.get('cookie'), - purpose: headers.get('purpose'), + // sec-purpose is added by browsers automatically when using link/prefetch or Speculation Rules + purpose: headers.get('sec-purpose') || headers.get('purpose'), }; } + +/** + * Minimal storefront interface needed for proxy functionality. + * The full Storefront type is defined in @shopify/hydrogen. + */ +type StorefrontForProxy = { + isStorefrontApiUrl: (request: {url?: string}) => boolean; + forward: (request: Request) => Promise; + setCollectedSubrequestHeaders: (response: {headers: Headers}) => void; +}; diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..54917cba73 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import {defineConfig} from '@playwright/test'; + +export default defineConfig({ + testMatch: /\.spec\.ts$/, + retries: 0, + reporter: 'list', + workers: 1, + fullyParallel: true, + timeout: 60 * 1000, + projects: [ + { + name: 'smoke', + testDir: './e2e/specs/smoke', + }, + { + name: 'new-cookies', + testDir: './e2e/specs/new-cookies', + }, + { + // TODO: remove once new cookies are rolled out + name: 'old-cookies', + testDir: './e2e/specs/old-cookies', + }, + ], +}); diff --git a/templates/skeleton/server.ts b/templates/skeleton/server.ts index ccd8c3a432..48aa48a476 100644 --- a/templates/skeleton/server.ts +++ b/templates/skeleton/server.ts @@ -3,8 +3,7 @@ // @ts-ignore // eslint-disable-next-line import/no-unresolved import * as remixBuild from 'virtual:remix/server-build'; -import {storefrontRedirect} from '@shopify/hydrogen'; -import {createRequestHandler} from '@shopify/remix-oxygen'; +import {storefrontRedirect, createRequestHandler} from '@shopify/hydrogen'; import {createAppLoadContext} from '~/lib/context'; /**