diff --git a/.env.example b/.env.example index 0ad45f41cf..8e463d2dfc 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,7 @@ BS_ACCESS_KEY=xxx SF_LWC_CLIENT_ID=xxx SF_LWC_USERNAME=xxx SF_LWC_INSTANCE_URL=xxx -SF_LWC_JWT_PRIVATE_KEY_B64=xxx \ No newline at end of file +SF_LWC_JWT_PRIVATE_KEY_B64=xxx + +# Shopify credentials +SHOPIFY_STORE_PASSWORD=xxx \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 36643b6f4c..d8eca7056a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -254,7 +254,8 @@ e2e: - yarn build:apps # Browsers are pre-installed in the CI image. If playwright (current or pinned 1.40.1) # is upgraded without rebuilding the image, this job will crash — rebuild the image to fix it. - - FORCE_COLOR=1 PW_BROWSER=$BROWSER yarn test:e2e --project=$BROWSER + # Shopify is excluded here: it only runs on a schedule (see e2e-shopify-scheduled) + - FORCE_COLOR=1 PW_BROWSER=$BROWSER yarn test:e2e --project=$BROWSER --grep-invert "shopify" after_script: - node ./scripts/test/export-test-result.ts e2e @@ -633,6 +634,30 @@ api-performance-benchmark: - yarn - node ./scripts/api-performance/index.ts +######################################################################################################################## +# Shopify e2e (scheduled, against a live external store) +######################################################################################################################## + +e2e-shopify-scheduled: + stage: task + extends: + - .base-configuration + - .resource-allocation-4-cpus + only: + variables: + - $TARGET_TASK_NAME == "e2e-shopify-scheduled" + artifacts: + when: always + reports: + junit: test-report/e2e/*.xml + script: + - yarn + - yarn build + - yarn build:apps + - FORCE_COLOR=1 PW_BROWSER=chromium yarn test:e2e --project=chromium -g "shopify" + after_script: + - node ./scripts/test/export-test-result.ts e2e + ######################################################################################################################## # Check expired telemetry ######################################################################################################################## diff --git a/scripts/lib/secrets.ts b/scripts/lib/secrets.ts index 6070aa9c88..8d429a6bd9 100644 --- a/scripts/lib/secrets.ts +++ b/scripts/lib/secrets.ts @@ -111,6 +111,10 @@ export function getSfLwcJwtPrivateKey(): string { return process.env.SF_LWC_JWT_PRIVATE_KEY_B64 ?? getSecretKey('ci.browser-sdk.sf_lwc_jwt_private_key_b64') } +export function getShopifyStorePassword(): string { + return process.env.SHOPIFY_STORE_PASSWORD ?? getSecretKey('ci.browser-sdk.shopify_store_password') +} + function getSecretKey(name: string): string { return command` aws ssm get-parameter --region=us-east-1 --with-decryption --query=Parameter.Value --out=text --name=${name} diff --git a/test/e2e/lib/framework/buildShopifyUrl.ts b/test/e2e/lib/framework/buildShopifyUrl.ts new file mode 100644 index 0000000000..23fbc82480 --- /dev/null +++ b/test/e2e/lib/framework/buildShopifyUrl.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test' +import { getShopifyStorePassword } from '../../../../scripts/lib/secrets.ts' + +// A Datadog-owned dev store, password-protected, used only to exercise browser-rum-shopify +// against a real storefront + checkout + Custom Pixel sandbox. +const SHOPIFY_STORE_URL = 'https://custom-pixel-e2e.myshopify.com/' + +export function buildShopifyUrl(): string { + return SHOPIFY_STORE_URL +} + +// Dev stores gate every page behind a storefront password until unlocked for the session. +const PASSWORD_PATH = /\/password\/?$/ + +export async function unlockShopifyStorePassword(page: Page): Promise { + if (!PASSWORD_PATH.test(new URL(page.url()).pathname)) { + return + } + + await page.getByRole('textbox', { name: /password/i }).fill(getShopifyStorePassword()) + await page.getByRole('button', { name: /enter/i }).click() + await page.waitForURL((url) => !PASSWORD_PATH.test(url.pathname)) +} diff --git a/test/e2e/lib/framework/createTest.ts b/test/e2e/lib/framework/createTest.ts index 21a501d1b6..fc4e880de8 100644 --- a/test/e2e/lib/framework/createTest.ts +++ b/test/e2e/lib/framework/createTest.ts @@ -23,12 +23,21 @@ import { } from '../helpers/playwright' import { buildSalesforceUrl } from './buildSalesforceUrl' import type { SalesforceApp } from './buildSalesforceUrl' +import { buildShopifyUrl, unlockShopifyStorePassword } from './buildShopifyUrl' import { IntakeRegistry } from './intakeRegistry' import { flushEvents } from './flushEvents' import type { Servers } from './httpServers' import { getTestServers, waitForServersIdle } from './httpServers' import type { CallerLocation, EventBridgeOptions, SetupFactory, SetupOptions, UrlHook } from './pageSetups' -import { html, DEFAULT_SETUPS, npmSetup, appSetup, formatConfiguration, salesforceSetup } from './pageSetups' +import { + html, + DEFAULT_SETUPS, + npmSetup, + appSetup, + formatConfiguration, + salesforceSetup, + shopifySetup, +} from './pageSetups' import { createDatadogHttpApi } from './serverApps/datadogHttpApi' import type { DatadogHttpApiControl } from './serverApps/datadogHttpApi' import { createMockServerApp } from './serverApps/mock' @@ -116,6 +125,7 @@ class TestBuilder { } = {} private worker: Worker | undefined private salesforceApp: SalesforceApp | undefined = undefined + private shopifyApp = false constructor( private title: string, @@ -283,6 +293,15 @@ class TestBuilder { return this } + withShopifyApp() { + this.shopifyApp = true + this.setups = [{ factory: shopifySetup }] + this.baseUrlHooks.push((baseUrl) => { + baseUrl.href = buildShopifyUrl() + }) + return this + } + withHostName(hostName: string) { this.baseUrlHooks.push((baseUrl) => { baseUrl.hostname = hostName @@ -313,6 +332,7 @@ class TestBuilder { callerLocation: this.callerLocation, mockClock: this.mockClock, salesforceApp: this.salesforceApp, + shopifyApp: this.shopifyApp, } if (this.alsoRunWithRumSlim) { @@ -534,7 +554,7 @@ function createTestContext( async function setUpTest( browserLogsManager: BrowserLogsManager, - { mockClock }: SetupOptions, + { mockClock, shopifyApp }: SetupOptions, { baseUrl, page, browserContext }: TestContext ) { browserContext.on('console', (msg) => { @@ -543,6 +563,7 @@ async function setUpTest( message: msg.text(), source: 'console', timestamp: Date.now(), + url: msg.location().url, }) }) @@ -563,6 +584,11 @@ async function setUpTest( } } await page.goto(baseUrl) + + if (shopifyApp) { + await unlockShopifyStorePassword(page) + } + await waitForServersIdle() } diff --git a/test/e2e/lib/framework/pageSetups.ts b/test/e2e/lib/framework/pageSetups.ts index d939baf4cf..8995e564aa 100644 --- a/test/e2e/lib/framework/pageSetups.ts +++ b/test/e2e/lib/framework/pageSetups.ts @@ -37,6 +37,7 @@ export interface SetupOptions { callerLocation?: CallerLocation mockClock: boolean salesforceApp: SalesforceApp | undefined + shopifyApp: boolean } export interface CallerLocation { @@ -343,6 +344,41 @@ export async function salesforceSetup(options: SetupOptions, servers: Servers, p return '' } +// Matches the CDN URL used by the store's Theme Liquid snippet and Custom Pixel for the main +// bundle and its dynamically-imported chunks (e.g. the session replay recorder), served from +// `https://www.datadoghq-browser-agent.com//v/[chunks/]]datadog-rum-shopify.js` +const SHOPIFY_ASSET_URL_PATTERN = + /datadoghq-browser-agent\.com\/[^/]+\/v\d+\/(chunks\/)?([\w-]*datadog-rum-shopify\.js)(?:[?#].*)?$/ + +// Shopify apps don't serve a locally-generated page body; this factory only intercepts the +// bootstrap script request and injects the RUM configuration read by the store's Theme Liquid +// snippet and Custom Pixel. +export async function shopifySetup(options: SetupOptions, servers: Servers, page: Page): Promise { + const shopifyBundleDir = resolve(__dirname, '../../../../packages/browser-rum-shopify/bundle') + + await page.route(SHOPIFY_ASSET_URL_PATTERN, async (route) => { + const [, chunksSegment, fileName] = SHOPIFY_ASSET_URL_PATTERN.exec(route.request().url()) || [] + const filePath = resolve(shopifyBundleDir, chunksSegment || '', fileName) + await route.fulfill({ + body: await readFile(filePath), + contentType: 'application/javascript', + // The snippets load the script with `crossOrigin = 'anonymous'`, so the browser enforces + // CORS on this response even though it never leaves the machine. + headers: { 'access-control-allow-origin': '*' }, + }) + }) + + if (options.rum) { + // addInitScript runs on every new document in the page, including the Custom Pixel's + // sandboxed iframe, so this reaches both the storefront and checkout injection points. + await page.addInitScript( + `window.RUM_CONFIGURATION = ${formatConfiguration(options.rum, servers)} + window.RUM_CONTEXT = ${JSON.stringify(options.context)}` + ) + } + return '' +} + function basePage({ header, body, footer }: { header?: string; body?: string; footer?: string }) { // prettier-ignore // The empty favicon avoids a /favicon.ico request from the browser. diff --git a/test/e2e/lib/helpers/browser.ts b/test/e2e/lib/helpers/browser.ts index 71214db896..f35855ff78 100644 --- a/test/e2e/lib/helpers/browser.ts +++ b/test/e2e/lib/helpers/browser.ts @@ -33,6 +33,7 @@ export interface BrowserLog { message: string source: string timestamp: number + url?: string } const IGNORE_LOG_MESSAGES = [ diff --git a/test/e2e/scenario/shopify.scenario.ts b/test/e2e/scenario/shopify.scenario.ts new file mode 100644 index 0000000000..6cb592e43d --- /dev/null +++ b/test/e2e/scenario/shopify.scenario.ts @@ -0,0 +1,206 @@ +import { expect, test } from '@playwright/test' +import { createTest } from '../lib/framework' + +// Skip non chromium browsers because --disable-web-security is chromium-only. +test.skip(() => test.info().project.name !== 'chromium', 'Shopify dev store is tested on chromium only') + +// Bypass CSP and CORS restrictions in the Shopify dev store. +test.use({ + bypassCSP: true, + launchOptions: { args: ['--disable-web-security'] }, +}) + +const baseShopifyRumConfiguration = { + trackUserInteractions: true, +} + +const STOREFRONT_URL = 'https://custom-pixel-e2e.myshopify.com/' +const PRODUCT_URL = 'https://custom-pixel-e2e.myshopify.com/products/the-multi-managed-snowboard' + +// Mirrors browser-rum-shopify's CHECKOUT_PATH (shopifyBindings.ts). Also matches the thank-you +// page, since its path still starts with /checkouts/. +const CHECKOUT_PATH = /\/(([a-z]{2}(-[a-z0-9]+)?)\/)?(checkouts?)(\/|$)/i +const THANK_YOU_PATH = /\/thank[_-]you/ + +const KNOWN_STORE_NOISE_URLS = [ + '/shopify-marketing_assets/static/ShopifySans--regular.woff', + '/.well-known/shopify/monorail/unstable/produce_batch', + '/private_access_tokens', + 'cloudfunctions.net/telemetry', + // Synthetic Chromium error document for a blocked cross-origin frame + 'chrome-error://chromewebdata/', +] + +const KNOWN_STORE_NOISE_MESSAGES = [ + 'No data returned from autocomplete query', + 'event_observer_reporter', + 'frame-ancestors', + // Transient store-side 5xx, unrelated to anything under test here. + 'responded with a status of 503', +] + +function isKnownStoreNoise(log: { url?: string; message: string }) { + return ( + KNOWN_STORE_NOISE_URLS.some((url) => log.url?.includes(url)) || + KNOWN_STORE_NOISE_MESSAGES.some((message) => log.message.includes(message)) + ) +} + +test.describe(() => { + // The live dev store's Cloudflare bot protection can trip a verification wall under request volume, + // even from rapid consecutive solo runs. Force this file's test(s) to run one at a time. + test.describe.configure({ mode: 'serial' }) + + // Cooldown so back-to-back invocations (manual loops, CI retries) can't hammer the store fast + // enough to trip Cloudflare's wall or Shopify's checkout rate limit + test.beforeAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 15_000)) + }) + + // Flow: add to cart -> checkout -> thank you -> back to storefront + createTest('shopify checkout views and actions') + .withRum(baseShopifyRumConfiguration) + .withShopifyApp() + .run(async ({ page, intakeRegistry, flushEvents, withBrowserLogs, flushBrowserLogs }) => { + // The full live-store checkout flow runs close to the default 30s test timeout. + test.slow() + + await page.locator('div').filter({ hasText: 'Generated test data A theme' }).nth(1).click() + await page.getByRole('link', { name: 'The Multi-managed Snowboard' }).click() + + // Wait for the product form's custom element to hydrate its AJAX submit handler, otherwise + // clicking "Add to cart" below can fall through to a native form submission instead. + await page.waitForLoadState('networkidle') + + // Poll the cart API directly instead of a fixed delay: the checkout session Shopify creates + // can lag slightly behind the /cart/add response with no other client-visible signal. + await Promise.all([ + page.waitForResponse((res) => res.url().includes('/cart/add') && res.ok()), + page.getByRole('button', { name: 'Add to cart' }).click(), + ]) + const getCartItemCount = () => + page.evaluate(async () => { + const response = await fetch('/cart.js') + // Treat a transient store-side 5xx as "not there yet" so expect.poll retries instead of + // failing on a JSON parse error. + if (!response.ok) { + return 0 + } + const cart = (await response.json()) as { item_count: number } + return cart.item_count + }) + await expect.poll(getCartItemCount, { timeout: 10000 }).toBeGreaterThan(0) + await page.getByRole('button', { name: 'Check out' }).click() + + // Wait for the checkout page's Web Pixel to finish loading and subscribe to click events, + // otherwise the first form field interactions below can go untracked. + await page.waitForLoadState('networkidle') + + const emailInput = page.getByRole('textbox', { name: 'Email' }) + await emailInput.click() + await emailInput.fill('ex@example.com') + + const firstNameInput = page.getByRole('textbox', { name: 'First name' }) + await firstNameInput.click() + await firstNameInput.fill('xxx') + + const lastNameInput = page.getByRole('textbox', { name: 'Last name' }) + await lastNameInput.click() + await lastNameInput.fill('xxx') + + const addressInput = page.getByRole('combobox', { name: 'Address' }) + await addressInput.click() + await addressInput.fill('123 Main St') + await page.keyboard.press('Escape') + + const cityInput = page.getByRole('textbox', { name: 'City' }) + await cityInput.click() + await cityInput.fill('New York') + + await page.getByRole('combobox', { name: 'State' }).selectOption('New York') + + const zipInput = page.getByRole('textbox', { name: 'ZIP code' }) + await zipInput.click() + await zipInput.fill('10001') + + // Bogus Gateway (Shopify's test payment gateway): card number "1" always succeeds. Card + // fields are rendered in per-session iframes, so match on the stable name prefix. + await page + .frameLocator('iframe[name^="card-fields-number-"]') + .getByRole('textbox', { name: 'Card number' }) + .fill('1') + await page + .frameLocator('iframe[name^="card-fields-expiry-"]') + .getByRole('textbox', { name: 'Expiration date (MM / YY)' }) + .fill('12 / 99') + await page + .frameLocator('iframe[name^="card-fields-verification_value-"]') + .getByRole('textbox', { name: 'Security code' }) + .fill('123') + + // Card fields sync back to the parent form via an async postMessage; give it a moment before + // clicking "Pay now" so the click doesn't land before the form is submittable. + await page.waitForTimeout(500) + + await page.getByRole('button', { name: 'Pay now' }).click() + + await expect(page.getByRole('heading', { name: /thank you/i })).toBeVisible({ timeout: 30000 }) + + // flushEvents() navigates to /flush and leaves the page there, so it must run while still on + // the thank-you page, before navigating anywhere else. + await flushEvents() + + // Navigate back to the storefront to confirm the Theme Liquid DD_RUM instance picks the + // session back up. + await page.goto(STOREFRONT_URL) + await flushEvents() + + // The Theme Liquid and Custom Pixel SDK instances share one browser session. + const sessionIds = new Set([ + ...intakeRegistry.rumViewEvents.map((e) => e.session.id), + ...intakeRegistry.rumActionEvents.map((e) => e.session.id), + ]) + expect(sessionIds.size).toBe(1) + + const orderedViews = [...intakeRegistry.rumViewEvents] + .sort((a, b) => a.date - b.date) + .filter((event, index, events) => events.findIndex((e) => e.view.id === event.view.id) === index) + + expect(orderedViews.map((event) => event.view.url)).toEqual([ + STOREFRONT_URL, + PRODUCT_URL, + expect.stringMatching(CHECKOUT_PATH), + expect.stringMatching(THANK_YOU_PATH), + STOREFRONT_URL, + ]) + + const orderedActions = [...intakeRegistry.rumActionEvents].sort((a, b) => a.date - b.date) + const actionSummaries = orderedActions.map((event) => ({ + target: event.action.target?.name, + viewUrl: event.view.url, + })) + + expect(actionSummaries).toEqual([ + { + target: 'A theme and populated test store by Shopify to help you test commerce primitives.', + viewUrl: STOREFRONT_URL, + }, + { target: 'The Multi-managed Snowboard', viewUrl: STOREFRONT_URL }, + { target: 'Add to cart', viewUrl: PRODUCT_URL }, + { target: 'Check out', viewUrl: PRODUCT_URL }, + { target: 'contact_email', viewUrl: expect.stringMatching(CHECKOUT_PATH) }, + { target: 'delivery_first_name', viewUrl: expect.stringMatching(CHECKOUT_PATH) }, + { target: 'delivery_last_name', viewUrl: expect.stringMatching(CHECKOUT_PATH) }, + { target: 'delivery_address1', viewUrl: expect.stringMatching(CHECKOUT_PATH) }, + { target: 'delivery_city', viewUrl: expect.stringMatching(CHECKOUT_PATH) }, + { target: 'delivery_postal_code', viewUrl: expect.stringMatching(CHECKOUT_PATH) }, + { target: 'summary_pay_button', viewUrl: expect.stringMatching(CHECKOUT_PATH) }, + ]) + + withBrowserLogs((logs) => { + const unexpected = logs.filter((log) => log.level === 'error' && !isKnownStoreNoise(log)) + expect(unexpected).toHaveLength(0) + }) + flushBrowserLogs() + }) +})