From 4ce5479d655a8b1e53124cc7cd568810c037224c Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Sat, 21 Mar 2026 20:41:53 +0100 Subject: [PATCH 01/11] theme: add standard events support to dev Add the --standard-events flow to theme dev and wire the option through the dev server context. Prepare assets/standard-events.d.ts in the background, refresh it when possible, create assets/global.d.ts when missing, and create or update assets/jsconfig.json so the new type files are wired in without clobbering unrelated config. Rewrite standard-events runtime URLs to use the dev bundle in HTML and JS content, inject the events inspector at the start of the head element with defer, and cover proxy, local asset, startup, and error-page paths with tests. Also make jsconfig wiring robust when existing configs exclude *.d.ts files by forcing the new definitions through the files list. Co-authored-by: Codex --- packages/theme/src/cli/commands/theme/dev.ts | 7 + packages/theme/src/cli/services/dev.test.ts | 196 +++++++++++++- packages/theme/src/cli/services/dev.ts | 13 +- .../hot-reload/server.test.ts | 1 + .../cli/utilities/theme-environment/html.ts | 9 +- .../theme-environment/local-assets.ts | 2 +- .../utilities/theme-environment/proxy.test.ts | 39 ++- .../cli/utilities/theme-environment/proxy.ts | 6 + .../theme-environment/standard-events.test.ts | 253 ++++++++++++++++++ .../theme-environment/standard-events.ts | 174 ++++++++++++ .../theme-environment.test.ts | 87 +++++- .../cli/utilities/theme-environment/types.ts | 5 + .../theme-ext-environment/theme-ext-server.ts | 1 + 13 files changed, 776 insertions(+), 17 deletions(-) create mode 100644 packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-environment/standard-events.ts diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index 99eb6c25b7f..d5e19ef090b 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -77,6 +77,12 @@ You can run this command only in a directory that matches the [default Shopify t description: 'Synchronize Theme Editor updates in the local theme files.', env: 'SHOPIFY_FLAG_THEME_EDITOR_SYNC', }), + 'standard-events': Flags.boolean({ + description: + 'Enable standard events types in assets and inject the standard events inspector into storefront HTML.', + env: 'SHOPIFY_FLAG_STANDARD_EVENTS', + default: false, + }), port: Flags.string({ description: 'Local port to serve theme preview from.', env: 'SHOPIFY_FLAG_PORT', @@ -179,6 +185,7 @@ You can run this command only in a directory that matches the [default Shopify t force: flags.force, open: flags.open, 'theme-editor-sync': flags['theme-editor-sync'], + 'standard-events': flags['standard-events'], noDelete: flags.nodelete, ignore, only, diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index f0ebf6f3e90..fc0d13988d2 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -1,9 +1,67 @@ -import {openURLSafely, renderLinks, createKeypressHandler} from './dev.js' -import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' -import {buildTheme} from '@shopify/cli-kit/node/themes/factories' -import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' -import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {dev, openURLSafely, renderLinks, createKeypressHandler} from './dev.js' +import {prepareStandardEventsSupport} from '../utilities/theme-environment/standard-events.js' +import {setupDevServer} from '../utilities/theme-environment/theme-environment.js' +import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' +import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' +import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' +import {emptyThemeExtFileSystem} from '../utilities/theme-fs-empty.js' +import {initializeDevServerSession} from '../utilities/theme-environment/dev-server-session.js' +import {ensureListingExists} from '../utilities/theme-listing.js' +import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' +import {AdminSession} from '@shopify/cli-kit/node/session' import {openURL} from '@shopify/cli-kit/node/system' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' + +vi.mock('../utilities/theme-fs.js', () => ({ + hasRequiredThemeDirectories: vi.fn().mockResolvedValue(true), + mountThemeFileSystem: vi.fn().mockReturnValue({files: new Map(), uploadErrors: new Map()}), +})) +vi.mock('../utilities/theme-ui.js', () => ({ + ensureDirectoryConfirmed: vi.fn().mockResolvedValue(true), +})) +vi.mock('../utilities/theme-environment/theme-environment.js', () => ({ + setupDevServer: vi.fn(() => ({ + workPromise: Promise.resolve(), + serverStart: vi.fn().mockResolvedValue({close: vi.fn().mockResolvedValue(undefined)}), + dispatchEvent: vi.fn(), + renderDevSetupProgress: vi.fn().mockResolvedValue(undefined), + backgroundJobPromise: Promise.resolve(undefined as never), + })), +})) +vi.mock('../utilities/theme-environment/standard-events.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + prepareStandardEventsSupport: vi.fn().mockResolvedValue(undefined), + } +}) +vi.mock('../utilities/theme-environment/storefront-session.js', () => ({ + isStorefrontPasswordProtected: vi.fn().mockResolvedValue(false), +})) +vi.mock('../utilities/theme-environment/storefront-password-prompt.js', () => ({ + ensureValidPassword: vi.fn(), +})) +vi.mock('../utilities/theme-fs-empty.js', () => ({ + emptyThemeExtFileSystem: vi.fn(() => ({files: new Map()})), +})) +vi.mock('../utilities/theme-environment/dev-server-session.js', () => ({ + initializeDevServerSession: vi.fn().mockResolvedValue({ + token: 'token', + storefrontToken: 'storefront-token', + storeFqdn: 'my-store.myshopify.com', + sessionCookies: {}, + }), +})) +vi.mock('../utilities/theme-listing.js', () => ({ + ensureListingExists: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@shopify/cli-kit/node/tcp', () => ({ + checkPortAvailability: vi.fn().mockResolvedValue(true), + getAvailableTCPPort: vi.fn().mockResolvedValue(9292), +})) vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/colors', () => ({ @@ -20,6 +78,134 @@ vi.mock('@shopify/cli-kit/node/system', () => ({ const store = 'my-store.myshopify.com' const theme = buildTheme({id: 123, name: 'My Theme', role: DEVELOPMENT_THEME_ROLE})! +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return {promise, resolve, reject} +} + +beforeEach(() => { + vi.mocked(hasRequiredThemeDirectories).mockResolvedValue(true) + vi.mocked(mountThemeFileSystem).mockReturnValue({files: new Map(), uploadErrors: new Map()} as never) + vi.mocked(ensureDirectoryConfirmed).mockResolvedValue(true) + vi.mocked(setupDevServer).mockReturnValue({ + workPromise: Promise.resolve(), + serverStart: vi.fn().mockResolvedValue({close: vi.fn().mockResolvedValue(undefined)}), + dispatchEvent: vi.fn(), + renderDevSetupProgress: vi.fn().mockResolvedValue(undefined), + backgroundJobPromise: Promise.resolve(undefined as never), + }) + vi.mocked(prepareStandardEventsSupport).mockResolvedValue(undefined) + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) + vi.mocked(emptyThemeExtFileSystem).mockReturnValue({files: new Map()} as never) + vi.mocked(initializeDevServerSession).mockResolvedValue({ + token: 'token', + storefrontToken: 'storefront-token', + storeFqdn: 'my-store.myshopify.com', + sessionCookies: {}, + } as never) + vi.mocked(ensureListingExists).mockResolvedValue(undefined) + vi.mocked(checkPortAvailability).mockResolvedValue(true) + vi.mocked(getAvailableTCPPort).mockResolvedValue(9292) +}) + +describe('dev', () => { + test('prepares standard events support and propagates the option to the dev server context', async () => { + const adminSession = {storeFqdn: store} as AdminSession + + await dev({ + adminSession, + directory: '/tmp/theme', + store, + open: false, + theme, + force: false, + 'standard-events': true, + 'theme-editor-sync': false, + 'live-reload': 'hot-reload', + 'error-overlay': 'default', + noDelete: false, + ignore: [], + only: [], + }) + + expect(prepareStandardEventsSupport).toHaveBeenCalledWith('/tmp/theme') + expect(setupDevServer).toHaveBeenCalledWith( + theme, + expect.objectContaining({ + options: expect.objectContaining({standardEvents: true}), + }), + ) + }) + + test('does not block dev startup on standard events setup', async () => { + const adminSession = {storeFqdn: store} as AdminSession + const standardEventsDeferred = createDeferred() + + vi.mocked(prepareStandardEventsSupport).mockReturnValue(standardEventsDeferred.promise) + + const devPromise = dev({ + adminSession, + directory: '/tmp/theme', + store, + open: false, + theme, + force: false, + 'standard-events': true, + 'theme-editor-sync': false, + 'live-reload': 'hot-reload', + 'error-overlay': 'default', + noDelete: false, + ignore: [], + only: [], + }) + + await vi.waitFor(() => { + expect(setupDevServer).toHaveBeenCalled() + expect(prepareStandardEventsSupport).toHaveBeenCalledWith('/tmp/theme') + expect(renderSuccess).toHaveBeenCalled() + }) + + standardEventsDeferred.resolve() + await devPromise + }) + + test('warns when background standard events setup fails', async () => { + const adminSession = {storeFqdn: store} as AdminSession + const error = new Error('refresh failed') + vi.mocked(prepareStandardEventsSupport).mockRejectedValue(error) + + await dev({ + adminSession, + directory: '/tmp/theme', + store, + open: false, + theme, + force: false, + 'standard-events': true, + 'theme-editor-sync': false, + 'live-reload': 'hot-reload', + 'error-overlay': 'default', + noDelete: false, + ignore: [], + only: [], + }) + + await vi.waitFor(() => { + expect(renderWarning).toHaveBeenCalledWith({ + headline: 'Failed to update standard events support.', + body: error.stack ?? error.message, + }) + }) + expect(setupDevServer).toHaveBeenCalled() + }) +}) + describe('renderLinks', () => { test('renders "dev" command links', async () => { // Given diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index 47ad2a174a7..61f06d7d20f 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -1,6 +1,7 @@ import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {setupDevServer} from '../utilities/theme-environment/theme-environment.js' +import {prepareStandardEventsSupport} from '../utilities/theme-environment/standard-events.js' import {DevServerContext, ErrorOverlayMode, LiveReload} from '../utilities/theme-environment/types.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' import {ensureValidPassword} from '../utilities/theme-environment/storefront-password-prompt.js' @@ -32,6 +33,7 @@ interface DevOptions { host?: string port?: string force: boolean + 'standard-events': boolean 'theme-editor-sync': boolean 'live-reload': LiveReload 'error-overlay': ErrorOverlayMode @@ -121,6 +123,7 @@ export async function dev(options: DevOptions) { port, open: options.open, liveReload: options['live-reload'], + standardEvents: options['standard-events'], noDelete: options.noDelete, ignore: options.ignore, only: options.only, @@ -139,7 +142,7 @@ export async function dev(options: DevOptions) { backgroundJobPromise, renderDevSetupProgress() .then(serverStart) - .then(() => { + .then(async () => { if (process.stdin.isTTY) { process.stdin.setRawMode(true) } @@ -148,6 +151,14 @@ export async function dev(options: DevOptions) { openURLSafely(urls.local, 'development server') } }), + options['standard-events'] + ? prepareStandardEventsSupport(options.directory).catch((error) => { + renderWarning({ + headline: 'Failed to update standard events support.', + body: error instanceof Error ? (error.stack ?? error.message) : String(error), + }) + }) + : undefined, ]) } diff --git a/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts b/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts index 3d2905a369b..e6297245297 100644 --- a/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts @@ -557,6 +557,7 @@ function createTestContext(options?: {files?: [string, string][]}) { host: '', port: '', liveReload: 'hot-reload', + standardEvents: false, open: false, themeEditorSync: false, errorOverlay: 'default', diff --git a/packages/theme/src/cli/utilities/theme-environment/html.ts b/packages/theme/src/cli/utilities/theme-environment/html.ts index 909b6ba3d1c..c15addbf993 100644 --- a/packages/theme/src/cli/utilities/theme-environment/html.ts +++ b/packages/theme/src/cli/utilities/theme-environment/html.ts @@ -2,6 +2,7 @@ import {getErrorPage} from './hot-reload/error-page.js' import {render} from './storefront-renderer.js' import {getInMemoryTemplates, handleHotReloadScriptInjection} from './hot-reload/server.js' import {getProxyStorefrontHeaders, patchRenderingResponse, proxyStorefrontRequest} from './proxy.js' +import {injectStandardEventsInspector} from './standard-events.js' import {extractFetchErrorInfo} from '../errors.js' import {logRequestLine} from '../log-request-line.js' import {getExtensionInMemoryTemplates} from '../theme-ext-environment/theme-ext-server.js' @@ -144,7 +145,8 @@ function createErrorPageResponse( responseInit: ResponseInit, options: Parameters[0], ) { - const errorPageHtml = handleHotReloadScriptInjection(getErrorPage(options), ctx) + let errorPageHtml = handleHotReloadScriptInjection(getErrorPage(options), ctx) + if (ctx.options.standardEvents) errorPageHtml = injectStandardEventsInspector(errorPageHtml) recordEvent('theme-service:error-page:rendered') @@ -180,6 +182,11 @@ async function tryProxyRequest(event: H3Event, ctx: DevServerContext, response: if (proxyResponse.status < 400) { outputDebug(`Proxy status: ${proxyResponse.status}. Returning proxy response.`) + + if ((proxyResponse.headers.get('content-type') ?? '').includes('text/html')) { + return patchRenderingResponse(ctx, proxyResponse) + } + return proxyResponse } else { outputDebug(`Proxy status: ${proxyResponse.status}. Returning render error.`) diff --git a/packages/theme/src/cli/utilities/theme-environment/local-assets.ts b/packages/theme/src/cli/utilities/theme-environment/local-assets.ts index 4fcccf818e2..c69e1e70f8c 100644 --- a/packages/theme/src/cli/utilities/theme-environment/local-assets.ts +++ b/packages/theme/src/cli/utilities/theme-environment/local-assets.ts @@ -219,7 +219,7 @@ function getTagContent(file: ThemeAsset, tag: 'javascript' | 'stylesheet') { const contents = [`/* ${file.key} */`] - const tagContent = getLiquidTagContent(file.value ?? '', tag) + const tagContent = getLiquidTagContent(file.value ?? '', tag)?.trimEnd() if (tagContent) { contents.push(tagContent) } diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts index 9279b397814..27b35631bef 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts @@ -5,6 +5,12 @@ import { patchRenderingResponse, proxyStorefrontRequest, } from './proxy.js' +import { + standardEventsInspectorScriptId, + standardEventsInspectorUrl, + standardEventsRuntimeDevUrl, + standardEventsRuntimeUrl, +} from './standard-events.js' import {describe, test, expect} from 'vitest' import {createEvent} from 'h3' import {IncomingMessage, ServerResponse} from 'node:http' @@ -29,7 +35,7 @@ describe('dev proxy', () => { const ctx = { session: {storeFqdn: 'my-store.myshopify.com', sessionCookies: {}}, - options: {host: 'localhost', port: '1337'}, + options: {host: 'localhost', port: '1337', standardEvents: false}, localThemeFileSystem: {files: new Map([['assets/file1', 'content']])}, localThemeExtensionFileSystem: {files: new Map([['assets/file-ext', 'content']])}, } as unknown as DevServerContext @@ -115,10 +121,24 @@ describe('dev proxy', () => { const url = "/cdn/path/to/assets/file1#zzz"; fetch(\`/cdn/path/to/assets/file1?q=123\`); " - `, + `, ) }) + test('rewrites standard events runtime URLs in JS files when enabled', () => { + const standardEventsCtx = { + ...ctx, + options: {...ctx.options, standardEvents: true}, + } as unknown as DevServerContext + const content = ` + const runtimeUrl = "${standardEventsRuntimeUrl}"; + import("${standardEventsRuntimeUrl}"); + ` + + expect(injectCdnProxy(content, standardEventsCtx)).toContain(standardEventsRuntimeDevUrl) + expect(injectCdnProxy(content, standardEventsCtx)).not.toContain(standardEventsRuntimeUrl) + }) + test('proxies urls in Link header', () => { const linkHeader = `; rel="preconnect", ; rel="preconnect"; crossorigin,` + @@ -250,6 +270,21 @@ describe('dev proxy', () => { expect(ctx.session.sessionCookies).toHaveProperty('_shopify_essential', ':AZFbAlZ..yAAH:') }) + test('injects the standard events inspector at the beginning of the head when enabled', async () => { + const standardEventsCtx = { + ...ctx, + options: {...ctx.options, standardEvents: true}, + } as unknown as DevServerContext + + const renderingResponse = new Response('') + + const patchedResponse = await patchRenderingResponse(standardEventsCtx, renderingResponse) + + await expect(patchedResponse.text()).resolves.toBe( + ``, + ) + }) + test('handles 304 Not Modified responses without crashing', async () => { // Create 304 response with no body as per HTTP spec const notModifiedResponse = new Response(null, { diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.ts index debfc8e6d94..170c3de6577 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.ts @@ -1,5 +1,6 @@ import {cleanHeader, defaultHeaders} from './storefront-utils.js' import {buildCookies} from './storefront-renderer.js' +import {injectStandardEventsInspector, rewriteStandardEventsRuntimeReferences} from './standard-events.js' import {logRequestLine} from '../log-request-line.js' import {createFetchError, extractFetchErrorInfo} from '../errors.js' @@ -158,6 +159,10 @@ export function injectCdnProxy(originalContent: string, ctx: DevServerContext) { return matchedUrl }) + if (ctx.options.standardEvents) { + content = rewriteStandardEventsRuntimeReferences(content) + } + return content } @@ -202,6 +207,7 @@ export async function patchRenderingResponse( let html = await response.text() html = injectCdnProxy(html, ctx) html = patchBaseUrlAttributes(html, ctx) + if (ctx.options.standardEvents) html = injectStandardEventsInspector(html) if (patchCallback) html = patchCallback(html) ?? html return new Response(html, response) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts new file mode 100644 index 00000000000..f789abfa297 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts @@ -0,0 +1,253 @@ +import { + injectStandardEventsInspector, + prepareStandardEventsSupport, + standardEventsDefinitionsUrl, + standardEventsInspectorScriptId, + standardEventsInspectorUrl, + standardEventsRuntimeDevUrl, + standardEventsRuntimeUrl, +} from './standard-events.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fetch, Response} from '@shopify/cli-kit/node/http' +import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' + +vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetch: vi.fn(), + } +}) + +beforeEach(() => { + vi.mocked(fetch).mockResolvedValue( + new Response('existing definitions\n', {status: 200, headers: {'content-type': 'text/plain; charset=utf-8'}}), + ) +}) + +describe('prepareStandardEventsSupport', () => { + test('creates standard events assets and jsconfig when missing', async () => { + const typesContent = 'declare global {\n interface Window { standardEvent: unknown }\n}\n' + vi.mocked(fetch).mockResolvedValue( + new Response(typesContent, {status: 200, headers: {'content-type': 'text/plain; charset=utf-8'}}), + ) + + await inTemporaryDirectory(async (tmpDir) => { + await mkdir(joinPath(tmpDir, 'assets')) + + await prepareStandardEventsSupport(tmpDir) + + await expect(readFile(joinPath(tmpDir, 'assets', 'standard-events.d.ts'))).resolves.toEqual(typesContent) + await expect(readFile(joinPath(tmpDir, 'assets', 'global.d.ts'))).resolves.toEqual( + '// Add custom global types here\n', + ) + await expect(readFile(joinPath(tmpDir, 'assets', 'jsconfig.json'))).resolves.toEqual( + `${JSON.stringify( + { + checkJs: false, + include: ['./**/*.js', './**/*.mjs', './**/*.cjs', './**/*.ts', './**/*.d.ts'], + files: ['./standard-events.d.ts', './global.d.ts'], + }, + null, + 2, + )}\n`, + ) + }) + + expect(fetch).toHaveBeenCalledWith(standardEventsDefinitionsUrl, undefined, 'slow-request') + }) + + test('preserves an existing include list and wires types through files', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const assetsDirectory = joinPath(tmpDir, 'assets') + await mkdir(assetsDirectory) + await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'existing definitions\n') + await writeFile(joinPath(assetsDirectory, 'global.d.ts'), '// Existing globals\n') + await writeFile( + joinPath(assetsDirectory, 'jsconfig.json'), + JSON.stringify({include: ['./**/*.js'], exclude: ['./**/*.d.ts']}, null, 2), + ) + + await prepareStandardEventsSupport(tmpDir) + + await expect(readFile(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toEqual( + 'existing definitions\n', + ) + await expect(readFile(joinPath(assetsDirectory, 'global.d.ts'))).resolves.toEqual('// Existing globals\n') + await expect(readFile(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toEqual( + `${JSON.stringify( + { + include: ['./**/*.js'], + exclude: ['./**/*.d.ts'], + files: ['./standard-events.d.ts', './global.d.ts'], + }, + null, + 2, + )}\n`, + ) + }) + + expect(fetch).toHaveBeenCalledWith(standardEventsDefinitionsUrl, undefined, 'slow-request') + }) + + test('appends type entries to an existing files list without changing other config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const assetsDirectory = joinPath(tmpDir, 'assets') + await mkdir(assetsDirectory) + await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'existing definitions\n') + await writeFile( + joinPath(assetsDirectory, 'jsconfig.json'), + JSON.stringify( + { + checkJs: true, + files: ['./main.js'], + compilerOptions: {strict: true}, + }, + null, + 2, + ), + ) + + await prepareStandardEventsSupport(tmpDir) + + await expect(readFile(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toEqual( + `${JSON.stringify( + { + checkJs: true, + files: ['./main.js', './standard-events.d.ts', './global.d.ts'], + compilerOptions: {strict: true}, + }, + null, + 2, + )}\n`, + ) + }) + }) + + test('adds default includes and type files when an existing config has neither include nor files', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const assetsDirectory = joinPath(tmpDir, 'assets') + await mkdir(assetsDirectory) + await writeFile( + joinPath(assetsDirectory, 'jsconfig.json'), + JSON.stringify( + { + checkJs: true, + compilerOptions: {strict: true}, + }, + null, + 2, + ), + ) + + await prepareStandardEventsSupport(tmpDir) + + await expect(readFile(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toEqual( + `${JSON.stringify( + { + checkJs: true, + compilerOptions: {strict: true}, + include: ['./**/*.js', './**/*.mjs', './**/*.cjs', './**/*.ts', './**/*.d.ts'], + files: ['./standard-events.d.ts', './global.d.ts'], + }, + null, + 2, + )}\n`, + ) + }) + }) + + test('refreshes standard events definitions when a newer version is available', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('new definitions\n', {status: 200, headers: {'content-type': 'text/plain; charset=utf-8'}}), + ) + + await inTemporaryDirectory(async (tmpDir) => { + const assetsDirectory = joinPath(tmpDir, 'assets') + await mkdir(assetsDirectory) + await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'old definitions\n') + + await prepareStandardEventsSupport(tmpDir) + + await expect(readFile(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toEqual('new definitions\n') + }) + }) + + test('keeps existing definitions when refreshing them fails', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('Sign in', { + status: 200, + headers: {'content-type': 'text/html; charset=utf-8'}, + }), + ) + + await inTemporaryDirectory(async (tmpDir) => { + const assetsDirectory = joinPath(tmpDir, 'assets') + await mkdir(assetsDirectory) + await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'existing definitions\n') + + await expect(prepareStandardEventsSupport(tmpDir)).resolves.toBeUndefined() + await expect(readFile(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toEqual( + 'existing definitions\n', + ) + await expect(readFile(joinPath(assetsDirectory, 'global.d.ts'))).resolves.toEqual( + '// Add custom global types here\n', + ) + }) + }) + + test('fails without writing files when the downloaded asset is HTML', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('Sign in', { + status: 200, + headers: {'content-type': 'text/html; charset=utf-8'}, + }), + ) + + await inTemporaryDirectory(async (tmpDir) => { + const assetsDirectory = joinPath(tmpDir, 'assets') + await mkdir(assetsDirectory) + + await expect(prepareStandardEventsSupport(tmpDir)).rejects.toThrow( + 'Failed to download standard events definitions.', + ) + + await expect(fileExists(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toBe(false) + await expect(fileExists(joinPath(assetsDirectory, 'global.d.ts'))).resolves.toBe(false) + await expect(fileExists(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toBe(false) + }) + }) +}) + +describe('injectStandardEventsInspector', () => { + test('injects the inspector script at the beginning of the head element', () => { + const html = '' + + expect(injectStandardEventsInspector(html)).toBe( + ``, + ) + }) + + test('rewrites standard-events.js to standard-events.dev.js', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe( + ``, + ) + }) + + test('does not inject the inspector twice', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe(html) + }) + + test('still rewrites standard-events.js when the inspector is already present', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe( + ``, + ) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts new file mode 100644 index 00000000000..e39807584bf --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts @@ -0,0 +1,174 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {fetch} from '@shopify/cli-kit/node/http' +import {joinPath} from '@shopify/cli-kit/node/path' +import {parseJSON} from '@shopify/theme-check-node' + +interface JsConfigFile { + include?: string[] + files?: string[] + [key: string]: unknown +} + +const standardEventsDefinitionFile = 'standard-events.d.ts' +const globalTypesFile = 'global.d.ts' +const jsConfigFile = 'jsconfig.json' +const globalTypesComment = '// Add custom global types here\n' +const defaultJsConfigIncludes = ['./**/*.js', './**/*.mjs', './**/*.cjs', './**/*.ts', './**/*.d.ts'] +const wiredTypeFiles = [`./${standardEventsDefinitionFile}`, `./${globalTypesFile}`] +const htmlDocumentRE = /^\s*<(?:!doctype\s+html|html)\b/i +const headTagRE = /]*)?>/i + +export const standardEventsBaseUrl = 'https://standard-events.quick.shopify.io' +export const standardEventsDefinitionsUrl = `${standardEventsBaseUrl}/standard-events.d.ts` +export const standardEventsRuntimeUrl = `${standardEventsBaseUrl}/standard-events.js` +export const standardEventsRuntimeDevUrl = `${standardEventsBaseUrl}/standard-events.dev.js` +export const standardEventsInspectorUrl = `${standardEventsBaseUrl}/events-inspector.js` +export const standardEventsInspectorScriptId = 'shopify-cli-standard-events-inspector' +const standardEventsRuntimeRE = new RegExp(escapeRegExp(standardEventsRuntimeUrl), 'g') + +export async function prepareStandardEventsSupport(themeDirectory: string) { + const assetsDirectory = joinPath(themeDirectory, 'assets') + const standardEventsDefinitionPath = joinPath(assetsDirectory, standardEventsDefinitionFile) + const globalTypesPath = joinPath(assetsDirectory, globalTypesFile) + const jsConfigPath = joinPath(assetsDirectory, jsConfigFile) + + const [hasStandardEventsDefinitions, hasGlobalTypes, hasJsConfig] = await Promise.all([ + fileExists(standardEventsDefinitionPath), + fileExists(globalTypesPath), + fileExists(jsConfigPath), + ]) + + const [existingStandardEventsDefinitions, existingJsConfig, standardEventsDefinitionsResult] = await Promise.all([ + hasStandardEventsDefinitions ? readFile(standardEventsDefinitionPath) : undefined, + hasJsConfig ? readFile(jsConfigPath) : undefined, + downloadStandardEventsDefinitions().then( + (content) => ({ok: true as const, content}), + (error: unknown) => ({ok: false as const, error}), + ), + mkdir(assetsDirectory), + ]) + + if (!standardEventsDefinitionsResult.ok && !hasStandardEventsDefinitions) { + throw standardEventsDefinitionsResult.error + } + + const nextJsConfig = updateJsConfig(existingJsConfig) + const nextJsConfigContent = `${JSON.stringify(nextJsConfig, null, 2)}\n` + const writePromises = [] + + if (existingJsConfig !== nextJsConfigContent) { + writePromises.push(writeFile(jsConfigPath, nextJsConfigContent)) + } + + if ( + standardEventsDefinitionsResult.ok && + existingStandardEventsDefinitions !== standardEventsDefinitionsResult.content + ) { + writePromises.push(writeFile(standardEventsDefinitionPath, standardEventsDefinitionsResult.content)) + } + + if (!hasGlobalTypes) { + writePromises.push(writeFile(globalTypesPath, globalTypesComment)) + } + + await Promise.all(writePromises) +} + +export function injectStandardEventsInspector(html: string) { + const patchedHtml = rewriteStandardEventsRuntimeReferences(html) + + if (patchedHtml.includes(standardEventsInspectorScriptId) || patchedHtml.includes(standardEventsInspectorUrl)) { + return patchedHtml + } + + return patchedHtml.replace( + headTagRE, + (headTag: string) => + `${headTag}`, + ) +} + +export function rewriteStandardEventsRuntimeReferences(content: string) { + return content.replace(standardEventsRuntimeRE, standardEventsRuntimeDevUrl) +} + +async function downloadStandardEventsDefinitions() { + const response = await fetch(standardEventsDefinitionsUrl, undefined, 'slow-request') + + if (!response.ok) { + throw new AbortError( + 'Failed to download standard events definitions.', + `${response.status} ${response.statusText}`.trim(), + ) + } + + const content = await response.text() + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('text/html') || htmlDocumentRE.test(content)) { + throw new AbortError( + 'Failed to download standard events definitions.', + `Received HTML instead of TypeScript from ${standardEventsDefinitionsUrl}.`, + ) + } + + return content.endsWith('\n') ? content : `${content}\n` +} + +function updateJsConfig(fileContent?: string) { + if (!fileContent) { + return { + checkJs: false, + include: defaultJsConfigIncludes, + files: wiredTypeFiles, + } + } + + const parsed = parseJSON(fileContent, null, true) as JsConfigFile | undefined + + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new AbortError(`Failed to update assets/${jsConfigFile}.`, 'The existing file is not valid JSON.') + } + + const jsConfig: JsConfigFile = {...parsed} + + if (jsConfig.files !== undefined) { + jsConfig.files = appendTypeEntries(jsConfig.files, 'files') + return jsConfig + } + + if (jsConfig.include !== undefined) { + validateTypeEntries(jsConfig.include, 'include') + jsConfig.files = [...wiredTypeFiles] + return jsConfig + } + + jsConfig.include = defaultJsConfigIncludes + jsConfig.files = [...wiredTypeFiles] + return jsConfig +} + +function appendTypeEntries(entries: unknown, fieldName: 'include' | 'files') { + validateTypeEntries(entries, fieldName) + + const nextEntries = [...entries] + + for (const entry of wiredTypeFiles) { + if (!nextEntries.includes(entry)) { + nextEntries.push(entry) + } + } + + return nextEntries +} + +function validateTypeEntries(entries: unknown, fieldName: 'include' | 'files'): asserts entries is string[] { + if (!Array.isArray(entries) || !entries.every((entry) => typeof entry === 'string')) { + throw new AbortError(`Failed to update assets/${jsConfigFile}.`, `The "${fieldName}" field must be a string array.`) + } +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts b/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts index e6bc84ba15c..803dd64e9c8 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts @@ -3,6 +3,11 @@ import {setupDevServer} from './theme-environment.js' import {render} from './storefront-renderer.js' import {reconcileAndPollThemeEditorChanges} from './remote-theme-watcher.js' import {hotReloadScriptId} from './hot-reload/server.js' +import { + standardEventsInspectorScriptId, + standardEventsRuntimeDevUrl, + standardEventsRuntimeUrl, +} from './standard-events.js' import {uploadTheme} from '../theme-uploader.js' import {fakeThemeFileSystem} from '../theme-fs/theme-fs-mock-factory.js' import {emptyThemeExtFileSystem} from '../theme-fs-empty.js' @@ -78,6 +83,14 @@ describe('setupDevServer', () => { value: 'const x = "Hello\u00A0World";', }, ], + [ + 'assets/file-standard-events.js', + { + checksum: '1', + key: 'assets/file-standard-events.js', + value: `window.__standardEventsRuntime__ = "${standardEventsRuntimeUrl}";`, + }, + ], ]) const localThemeFileSystem = fakeThemeFileSystem('tmp', localFiles) @@ -101,6 +114,7 @@ describe('setupDevServer', () => { host: '127.0.0.1', port: '9292', liveReload: 'hot-reload', + standardEvents: false, open: false, themeEditorSync: false, errorOverlay: 'default', @@ -224,6 +238,7 @@ describe('setupDevServer', () => { const dispatchEvent = async ( url: string, headers?: Record, + targetServer: typeof server = server, ): Promise<{res: ServerResponse; status: number; body: string | Buffer}> => { const event = createH3Event({url, headers}) const {res} = event.node @@ -240,7 +255,7 @@ describe('setupDevServer', () => { return resEnd(content) } - await server.dispatchEvent(event) + await targetServer.dispatchEvent(event) if (!body && '_data' in res) { // When returning a Response from H3, we get the body here: @@ -300,6 +315,23 @@ describe('setupDevServer', () => { expect(res.getHeader('content-length')).toEqual(25) }) + test('rewrites standard events runtime URLs in served local JS assets when enabled', async () => { + const standardEventsContext: DevServerContext = { + ...defaultServerContext, + options: { + ...defaultServerContext.options, + standardEvents: true, + }, + } + const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) + + const {res, body} = await dispatchEvent('/assets/file-standard-events.js', undefined, standardEventsServer) + + expect(res.getHeader('content-type')).toEqual('application/javascript') + expect(body.toString()).toContain(standardEventsRuntimeDevUrl) + expect(body.toString()).not.toContain(standardEventsRuntimeUrl) + }) + test('serves compiled_assets/styles.css by aggregating liquid stylesheets in a fault tolerant way', async () => { const sectionFile = { key: 'sections/test-section.liquid', @@ -450,7 +482,6 @@ describe('setupDevServer', () => { /* blocks/another-block.liquid */ console.log('This is another block script'); - } catch (e) { console.error(e); } @@ -462,7 +493,6 @@ describe('setupDevServer', () => { /* blocks/test-block.liquid */ console.log('This is block script'); - } catch (e) { console.error(e); } @@ -535,7 +565,6 @@ describe('setupDevServer', () => { /* snippets/another-snippet.liquid */ console.log('This is another snippet script'); - } catch (e) { console.error(e); } @@ -547,7 +576,6 @@ describe('setupDevServer', () => { /* snippets/test-snippet.liquid */ console.log('This is snippet script'); - } catch (e) { console.error(e); } @@ -619,7 +647,6 @@ describe('setupDevServer', () => { /* sections/another-section.liquid */ console.log('This is another section script'); - } catch (e) { console.error(e); } @@ -631,7 +658,6 @@ describe('setupDevServer', () => { /* sections/test-section.liquid */ console.log('This is section script'); - } catch (e) { console.error(e); } @@ -831,6 +857,34 @@ describe('setupDevServer', () => { await expect(eventPromise).resolves.toHaveProperty('status', 302) }) + test('patches HTML proxy fallback responses with standard events when enabled', async () => { + const standardEventsContext: DevServerContext = { + ...defaultServerContext, + options: { + ...defaultServerContext.options, + standardEvents: true, + }, + } + const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) + + const fetchStub = vi.fn() + vi.stubGlobal('fetch', fetchStub) + fetchStub.mockResolvedValueOnce( + new Response(``, { + status: 200, + headers: {'content-type': 'text/html; charset=utf-8'}, + }), + ) + vi.mocked(render).mockResolvedValueOnce(new Response(null, {status: 401})) + + const {body, status} = await dispatchEvent('/non-renderable-path', undefined, standardEventsServer) + + expect(status).toBe(200) + expect(body).toMatch(standardEventsInspectorScriptId) + expect(body).toMatch(standardEventsRuntimeDevUrl) + expect(body).not.toMatch(standardEventsRuntimeUrl) + }) + test('forwards rendering error after proxy failure', async () => { const fetchStub = vi.fn() vi.stubGlobal('fetch', fetchStub) @@ -930,6 +984,25 @@ describe('setupDevServer', () => { expect(body).toMatch(hotReloadScriptId) }) + test('renders error page with the standard events inspector when enabled', async () => { + const standardEventsContext: DevServerContext = { + ...defaultServerContext, + options: { + ...defaultServerContext.options, + standardEvents: true, + }, + } + const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) + + vi.mocked(render).mockRejectedValueOnce(new Error('Network error')) + + const {res, body} = await dispatchEvent('/', undefined, standardEventsServer) + + expect(res.getHeader('content-type')).toEqual('text/html; charset=utf-8') + expect(body).toMatch(standardEventsInspectorScriptId) + expect(body).toMatch(hotReloadScriptId) + }) + test('renders error page on upload errors with hot reload script injected', async () => { const fetchStub = vi.fn() vi.stubGlobal('fetch', fetchStub) diff --git a/packages/theme/src/cli/utilities/theme-environment/types.ts b/packages/theme/src/cli/utilities/theme-environment/types.ts index 9b689c7daf8..6be9cbd9769 100644 --- a/packages/theme/src/cli/utilities/theme-environment/types.ts +++ b/packages/theme/src/cli/utilities/theme-environment/types.ts @@ -130,6 +130,11 @@ export interface DevServerContext { */ liveReload: LiveReload + /** + * Enables standard events types and inspector injection. + */ + standardEvents: boolean + /** * Automatically open the theme preview in the default browser. */ diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts index 0ff73ab674d..36f5d420e13 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts @@ -93,6 +93,7 @@ async function contextDevServerContext( host, port: port.toString(), liveReload: 'hot-reload', + standardEvents: false, open: false, errorOverlay: 'default', }, From 30bf0ae79bf4123abfb3130f0aeb96d1d8dd7823 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Sat, 21 Mar 2026 21:17:00 +0100 Subject: [PATCH 02/11] theme: split standard events dev tooling Use the standard events dev bundle by default in theme dev, while making inspector injection and local type setup explicit opt-in features. Keep the type setup asynchronous so server startup is not blocked. Co-authored-by: OpenAI Codex --- packages/theme/src/cli/commands/theme/dev.ts | 15 ++++-- packages/theme/src/cli/services/dev.test.ts | 50 ++++++++++++++++--- packages/theme/src/cli/services/dev.ts | 10 ++-- .../hot-reload/server.test.ts | 3 +- .../cli/utilities/theme-environment/html.ts | 2 +- .../utilities/theme-environment/proxy.test.ts | 23 +++++++-- .../cli/utilities/theme-environment/proxy.ts | 4 +- .../theme-environment/standard-events.test.ts | 27 ++++++++-- .../theme-environment/standard-events.ts | 8 ++- .../theme-environment.test.ts | 10 ++-- .../cli/utilities/theme-environment/types.ts | 9 +++- .../theme-ext-environment/theme-ext-server.ts | 3 +- 12 files changed, 125 insertions(+), 39 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index d5e19ef090b..52a26826647 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -77,10 +77,14 @@ You can run this command only in a directory that matches the [default Shopify t description: 'Synchronize Theme Editor updates in the local theme files.', env: 'SHOPIFY_FLAG_THEME_EDITOR_SYNC', }), - 'standard-events': Flags.boolean({ - description: - 'Enable standard events types in assets and inject the standard events inspector into storefront HTML.', - env: 'SHOPIFY_FLAG_STANDARD_EVENTS', + 'standard-events-inspector': Flags.boolean({ + description: 'Inject the standard events inspector into storefront HTML.', + env: 'SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR', + default: false, + }), + 'standard-events-types': Flags.boolean({ + description: 'Download standard events type definitions into assets and wire them into assets/jsconfig.json.', + env: 'SHOPIFY_FLAG_STANDARD_EVENTS_TYPES', default: false, }), port: Flags.string({ @@ -185,7 +189,8 @@ You can run this command only in a directory that matches the [default Shopify t force: flags.force, open: flags.open, 'theme-editor-sync': flags['theme-editor-sync'], - 'standard-events': flags['standard-events'], + 'standard-events-inspector': flags['standard-events-inspector'], + 'standard-events-types': flags['standard-events-types'], noDelete: flags.nodelete, ignore, only, diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index fc0d13988d2..842b6a4ab24 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -115,7 +115,7 @@ beforeEach(() => { }) describe('dev', () => { - test('prepares standard events support and propagates the option to the dev server context', async () => { + test('enables the standard events dev bundle by default without preparing types', async () => { const adminSession = {storeFqdn: store} as AdminSession await dev({ @@ -125,7 +125,40 @@ describe('dev', () => { open: false, theme, force: false, - 'standard-events': true, + 'standard-events-inspector': false, + 'standard-events-types': false, + 'theme-editor-sync': false, + 'live-reload': 'hot-reload', + 'error-overlay': 'default', + noDelete: false, + ignore: [], + only: [], + }) + + expect(prepareStandardEventsSupport).not.toHaveBeenCalled() + expect(setupDevServer).toHaveBeenCalledWith( + theme, + expect.objectContaining({ + options: expect.objectContaining({ + standardEventsDevBundle: true, + standardEventsInspector: false, + }), + }), + ) + }) + + test('prepares standard events types and propagates the inspector option to the dev server context', async () => { + const adminSession = {storeFqdn: store} as AdminSession + + await dev({ + adminSession, + directory: '/tmp/theme', + store, + open: false, + theme, + force: false, + 'standard-events-inspector': true, + 'standard-events-types': true, 'theme-editor-sync': false, 'live-reload': 'hot-reload', 'error-overlay': 'default', @@ -138,7 +171,10 @@ describe('dev', () => { expect(setupDevServer).toHaveBeenCalledWith( theme, expect.objectContaining({ - options: expect.objectContaining({standardEvents: true}), + options: expect.objectContaining({ + standardEventsDevBundle: true, + standardEventsInspector: true, + }), }), ) }) @@ -156,7 +192,8 @@ describe('dev', () => { open: false, theme, force: false, - 'standard-events': true, + 'standard-events-inspector': false, + 'standard-events-types': true, 'theme-editor-sync': false, 'live-reload': 'hot-reload', 'error-overlay': 'default', @@ -187,7 +224,8 @@ describe('dev', () => { open: false, theme, force: false, - 'standard-events': true, + 'standard-events-inspector': false, + 'standard-events-types': true, 'theme-editor-sync': false, 'live-reload': 'hot-reload', 'error-overlay': 'default', @@ -198,7 +236,7 @@ describe('dev', () => { await vi.waitFor(() => { expect(renderWarning).toHaveBeenCalledWith({ - headline: 'Failed to update standard events support.', + headline: 'Failed to update standard events types.', body: error.stack ?? error.message, }) }) diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index 61f06d7d20f..072485603c5 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -33,7 +33,8 @@ interface DevOptions { host?: string port?: string force: boolean - 'standard-events': boolean + 'standard-events-inspector': boolean + 'standard-events-types': boolean 'theme-editor-sync': boolean 'live-reload': LiveReload 'error-overlay': ErrorOverlayMode @@ -123,7 +124,8 @@ export async function dev(options: DevOptions) { port, open: options.open, liveReload: options['live-reload'], - standardEvents: options['standard-events'], + standardEventsDevBundle: true, + standardEventsInspector: options['standard-events-inspector'], noDelete: options.noDelete, ignore: options.ignore, only: options.only, @@ -151,10 +153,10 @@ export async function dev(options: DevOptions) { openURLSafely(urls.local, 'development server') } }), - options['standard-events'] + options['standard-events-types'] ? prepareStandardEventsSupport(options.directory).catch((error) => { renderWarning({ - headline: 'Failed to update standard events support.', + headline: 'Failed to update standard events types.', body: error instanceof Error ? (error.stack ?? error.message) : String(error), }) }) diff --git a/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts b/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts index e6297245297..c8b83ac6efe 100644 --- a/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/hot-reload/server.test.ts @@ -557,7 +557,8 @@ function createTestContext(options?: {files?: [string, string][]}) { host: '', port: '', liveReload: 'hot-reload', - standardEvents: false, + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, themeEditorSync: false, errorOverlay: 'default', diff --git a/packages/theme/src/cli/utilities/theme-environment/html.ts b/packages/theme/src/cli/utilities/theme-environment/html.ts index c15addbf993..043dcc96603 100644 --- a/packages/theme/src/cli/utilities/theme-environment/html.ts +++ b/packages/theme/src/cli/utilities/theme-environment/html.ts @@ -146,7 +146,7 @@ function createErrorPageResponse( options: Parameters[0], ) { let errorPageHtml = handleHotReloadScriptInjection(getErrorPage(options), ctx) - if (ctx.options.standardEvents) errorPageHtml = injectStandardEventsInspector(errorPageHtml) + if (ctx.options.standardEventsInspector) errorPageHtml = injectStandardEventsInspector(errorPageHtml) recordEvent('theme-service:error-page:rendered') diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts index 27b35631bef..7d663eadf67 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts @@ -35,7 +35,7 @@ describe('dev proxy', () => { const ctx = { session: {storeFqdn: 'my-store.myshopify.com', sessionCookies: {}}, - options: {host: 'localhost', port: '1337', standardEvents: false}, + options: {host: 'localhost', port: '1337', standardEventsDevBundle: false, standardEventsInspector: false}, localThemeFileSystem: {files: new Map([['assets/file1', 'content']])}, localThemeExtensionFileSystem: {files: new Map([['assets/file-ext', 'content']])}, } as unknown as DevServerContext @@ -128,7 +128,7 @@ describe('dev proxy', () => { test('rewrites standard events runtime URLs in JS files when enabled', () => { const standardEventsCtx = { ...ctx, - options: {...ctx.options, standardEvents: true}, + options: {...ctx.options, standardEventsDevBundle: true}, } as unknown as DevServerContext const content = ` const runtimeUrl = "${standardEventsRuntimeUrl}"; @@ -273,7 +273,7 @@ describe('dev proxy', () => { test('injects the standard events inspector at the beginning of the head when enabled', async () => { const standardEventsCtx = { ...ctx, - options: {...ctx.options, standardEvents: true}, + options: {...ctx.options, standardEventsInspector: true}, } as unknown as DevServerContext const renderingResponse = new Response('') @@ -285,6 +285,23 @@ describe('dev proxy', () => { ) }) + test('does not rewrite standard events runtime URLs when only the inspector is enabled', async () => { + const standardEventsCtx = { + ...ctx, + options: {...ctx.options, standardEventsInspector: true}, + } as unknown as DevServerContext + + const renderingResponse = new Response( + ``, + ) + + const patchedResponse = await patchRenderingResponse(standardEventsCtx, renderingResponse) + + await expect(patchedResponse.text()).resolves.toBe( + ``, + ) + }) + test('handles 304 Not Modified responses without crashing', async () => { // Create 304 response with no body as per HTTP spec const notModifiedResponse = new Response(null, { diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.ts index 170c3de6577..5f019aea868 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.ts @@ -159,7 +159,7 @@ export function injectCdnProxy(originalContent: string, ctx: DevServerContext) { return matchedUrl }) - if (ctx.options.standardEvents) { + if (ctx.options.standardEventsDevBundle) { content = rewriteStandardEventsRuntimeReferences(content) } @@ -207,7 +207,7 @@ export async function patchRenderingResponse( let html = await response.text() html = injectCdnProxy(html, ctx) html = patchBaseUrlAttributes(html, ctx) - if (ctx.options.standardEvents) html = injectStandardEventsInspector(html) + if (ctx.options.standardEventsInspector) html = injectStandardEventsInspector(html) if (patchCallback) html = patchCallback(html) ?? html return new Response(html, response) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts index f789abfa297..95a99429b77 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts @@ -1,6 +1,7 @@ import { injectStandardEventsInspector, prepareStandardEventsSupport, + rewriteStandardEventsRuntimeReferences, standardEventsDefinitionsUrl, standardEventsInspectorScriptId, standardEventsInspectorUrl, @@ -229,11 +230,11 @@ describe('injectStandardEventsInspector', () => { ) }) - test('rewrites standard-events.js to standard-events.dev.js', () => { + test('does not rewrite standard-events.js when injecting the inspector', () => { const html = `` expect(injectStandardEventsInspector(html)).toBe( - ``, + ``, ) }) @@ -243,11 +244,27 @@ describe('injectStandardEventsInspector', () => { expect(injectStandardEventsInspector(html)).toBe(html) }) - test('still rewrites standard-events.js when the inspector is already present', () => { + test('leaves standard-events.js unchanged when the inspector is already present', () => { const html = `` - expect(injectStandardEventsInspector(html)).toBe( - ``, + expect(injectStandardEventsInspector(html)).toBe(html) + }) +}) + +describe('rewriteStandardEventsRuntimeReferences', () => { + test('rewrites standard-events.js to standard-events.dev.js', () => { + const html = `` + + expect(rewriteStandardEventsRuntimeReferences(html)).toBe( + ``, + ) + }) + + test('rewrites multiple standard-events.js references', () => { + const content = `"${standardEventsRuntimeUrl}" import("${standardEventsRuntimeUrl}")` + + expect(rewriteStandardEventsRuntimeReferences(content)).toBe( + `"${standardEventsRuntimeDevUrl}" import("${standardEventsRuntimeDevUrl}")`, ) }) }) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts index e39807584bf..01fe65a8bbf 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts @@ -76,13 +76,11 @@ export async function prepareStandardEventsSupport(themeDirectory: string) { } export function injectStandardEventsInspector(html: string) { - const patchedHtml = rewriteStandardEventsRuntimeReferences(html) - - if (patchedHtml.includes(standardEventsInspectorScriptId) || patchedHtml.includes(standardEventsInspectorUrl)) { - return patchedHtml + if (html.includes(standardEventsInspectorScriptId) || html.includes(standardEventsInspectorUrl)) { + return html } - return patchedHtml.replace( + return html.replace( headTagRE, (headTag: string) => `${headTag}`, diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts b/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts index 803dd64e9c8..62f4786e945 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts @@ -114,7 +114,8 @@ describe('setupDevServer', () => { host: '127.0.0.1', port: '9292', liveReload: 'hot-reload', - standardEvents: false, + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, themeEditorSync: false, errorOverlay: 'default', @@ -320,7 +321,7 @@ describe('setupDevServer', () => { ...defaultServerContext, options: { ...defaultServerContext.options, - standardEvents: true, + standardEventsDevBundle: true, }, } const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) @@ -862,7 +863,8 @@ describe('setupDevServer', () => { ...defaultServerContext, options: { ...defaultServerContext.options, - standardEvents: true, + standardEventsDevBundle: true, + standardEventsInspector: true, }, } const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) @@ -989,7 +991,7 @@ describe('setupDevServer', () => { ...defaultServerContext, options: { ...defaultServerContext.options, - standardEvents: true, + standardEventsInspector: true, }, } const standardEventsServer = setupDevServer(developmentTheme, standardEventsContext) diff --git a/packages/theme/src/cli/utilities/theme-environment/types.ts b/packages/theme/src/cli/utilities/theme-environment/types.ts index 6be9cbd9769..45b0b10ca11 100644 --- a/packages/theme/src/cli/utilities/theme-environment/types.ts +++ b/packages/theme/src/cli/utilities/theme-environment/types.ts @@ -131,9 +131,14 @@ export interface DevServerContext { liveReload: LiveReload /** - * Enables standard events types and inspector injection. + * Rewrites standard events runtime URLs to use the dev bundle. */ - standardEvents: boolean + standardEventsDevBundle: boolean + + /** + * Injects the standard events inspector into rendered HTML. + */ + standardEventsInspector: boolean /** * Automatically open the theme preview in the default browser. diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts index 36f5d420e13..fd145857ad2 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts @@ -93,7 +93,8 @@ async function contextDevServerContext( host, port: port.toString(), liveReload: 'hot-reload', - standardEvents: false, + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, errorOverlay: 'default', }, From d2290088b86dcbbf16164483bb4a3d79d81f0681 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Sat, 21 Mar 2026 21:29:47 +0100 Subject: [PATCH 03/11] theme: tighten standard events inspector dedupe Only treat the inspector as already injected when an actual inspector script tag is present, and add a regression test for plain URL strings in inline scripts. Co-authored-by: OpenAI Codex --- .../utilities/theme-environment/standard-events.test.ts | 8 ++++++++ .../cli/utilities/theme-environment/standard-events.ts | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts index 95a99429b77..c44a7643b9b 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts @@ -244,6 +244,14 @@ describe('injectStandardEventsInspector', () => { expect(injectStandardEventsInspector(html)).toBe(html) }) + test('does not treat a plain inspector URL string as an existing inspector script', () => { + const html = `` + + expect(injectStandardEventsInspector(html)).toBe( + ``, + ) + }) + test('leaves standard-events.js unchanged when the inspector is already present', () => { const html = `` diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts index 01fe65a8bbf..e939059f837 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts @@ -25,6 +25,12 @@ export const standardEventsRuntimeUrl = `${standardEventsBaseUrl}/standard-event export const standardEventsRuntimeDevUrl = `${standardEventsBaseUrl}/standard-events.dev.js` export const standardEventsInspectorUrl = `${standardEventsBaseUrl}/events-inspector.js` export const standardEventsInspectorScriptId = 'shopify-cli-standard-events-inspector' +const standardEventsInspectorScriptRE = new RegExp( + `]*(?:\\bid=["']${escapeRegExp(standardEventsInspectorScriptId)}["']|\\bsrc=["']${escapeRegExp( + standardEventsInspectorUrl, + )}["'])[^>]*>`, + 'i', +) const standardEventsRuntimeRE = new RegExp(escapeRegExp(standardEventsRuntimeUrl), 'g') export async function prepareStandardEventsSupport(themeDirectory: string) { @@ -76,7 +82,7 @@ export async function prepareStandardEventsSupport(themeDirectory: string) { } export function injectStandardEventsInspector(html: string) { - if (html.includes(standardEventsInspectorScriptId) || html.includes(standardEventsInspectorUrl)) { + if (standardEventsInspectorScriptRE.test(html)) { return html } From b18b2f6ab211961c91fe1b5ad795b93b4b79138f Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 15 Apr 2026 14:03:00 +0200 Subject: [PATCH 04/11] theme: remove standard events types setup from dev Remove the --standard-events-types flag and all associated logic (d.ts download, global.d.ts creation, jsconfig.json wiring) from theme dev. This feature was too intrusive for the dev command. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/theme/src/cli/commands/theme/dev.ts | 6 - packages/theme/src/cli/services/dev.test.ts | 92 +------- packages/theme/src/cli/services/dev.ts | 10 - .../theme-environment/standard-events.test.ts | 215 +----------------- .../theme-environment/standard-events.ts | 144 ------------ 5 files changed, 3 insertions(+), 464 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index 52a26826647..d5d17edfd8a 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -82,11 +82,6 @@ You can run this command only in a directory that matches the [default Shopify t env: 'SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR', default: false, }), - 'standard-events-types': Flags.boolean({ - description: 'Download standard events type definitions into assets and wire them into assets/jsconfig.json.', - env: 'SHOPIFY_FLAG_STANDARD_EVENTS_TYPES', - default: false, - }), port: Flags.string({ description: 'Local port to serve theme preview from.', env: 'SHOPIFY_FLAG_PORT', @@ -190,7 +185,6 @@ You can run this command only in a directory that matches the [default Shopify t open: flags.open, 'theme-editor-sync': flags['theme-editor-sync'], 'standard-events-inspector': flags['standard-events-inspector'], - 'standard-events-types': flags['standard-events-types'], noDelete: flags.nodelete, ignore, only, diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index 842b6a4ab24..2e02596e7f0 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -1,5 +1,4 @@ import {dev, openURLSafely, renderLinks, createKeypressHandler} from './dev.js' -import {prepareStandardEventsSupport} from '../utilities/theme-environment/standard-events.js' import {setupDevServer} from '../utilities/theme-environment/theme-environment.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' @@ -31,13 +30,6 @@ vi.mock('../utilities/theme-environment/theme-environment.js', () => ({ backgroundJobPromise: Promise.resolve(undefined as never), })), })) -vi.mock('../utilities/theme-environment/standard-events.js', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - prepareStandardEventsSupport: vi.fn().mockResolvedValue(undefined), - } -}) vi.mock('../utilities/theme-environment/storefront-session.js', () => ({ isStorefrontPasswordProtected: vi.fn().mockResolvedValue(false), })) @@ -78,17 +70,6 @@ vi.mock('@shopify/cli-kit/node/system', () => ({ const store = 'my-store.myshopify.com' const theme = buildTheme({id: 123, name: 'My Theme', role: DEVELOPMENT_THEME_ROLE})! -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - - return {promise, resolve, reject} -} - beforeEach(() => { vi.mocked(hasRequiredThemeDirectories).mockResolvedValue(true) vi.mocked(mountThemeFileSystem).mockReturnValue({files: new Map(), uploadErrors: new Map()} as never) @@ -100,7 +81,6 @@ beforeEach(() => { renderDevSetupProgress: vi.fn().mockResolvedValue(undefined), backgroundJobPromise: Promise.resolve(undefined as never), }) - vi.mocked(prepareStandardEventsSupport).mockResolvedValue(undefined) vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) vi.mocked(emptyThemeExtFileSystem).mockReturnValue({files: new Map()} as never) vi.mocked(initializeDevServerSession).mockResolvedValue({ @@ -115,7 +95,7 @@ beforeEach(() => { }) describe('dev', () => { - test('enables the standard events dev bundle by default without preparing types', async () => { + test('enables the standard events dev bundle by default', async () => { const adminSession = {storeFqdn: store} as AdminSession await dev({ @@ -126,7 +106,6 @@ describe('dev', () => { theme, force: false, 'standard-events-inspector': false, - 'standard-events-types': false, 'theme-editor-sync': false, 'live-reload': 'hot-reload', 'error-overlay': 'default', @@ -135,7 +114,6 @@ describe('dev', () => { only: [], }) - expect(prepareStandardEventsSupport).not.toHaveBeenCalled() expect(setupDevServer).toHaveBeenCalledWith( theme, expect.objectContaining({ @@ -147,7 +125,7 @@ describe('dev', () => { ) }) - test('prepares standard events types and propagates the inspector option to the dev server context', async () => { + test('propagates the inspector option to the dev server context', async () => { const adminSession = {storeFqdn: store} as AdminSession await dev({ @@ -158,7 +136,6 @@ describe('dev', () => { theme, force: false, 'standard-events-inspector': true, - 'standard-events-types': true, 'theme-editor-sync': false, 'live-reload': 'hot-reload', 'error-overlay': 'default', @@ -167,7 +144,6 @@ describe('dev', () => { only: [], }) - expect(prepareStandardEventsSupport).toHaveBeenCalledWith('/tmp/theme') expect(setupDevServer).toHaveBeenCalledWith( theme, expect.objectContaining({ @@ -178,70 +154,6 @@ describe('dev', () => { }), ) }) - - test('does not block dev startup on standard events setup', async () => { - const adminSession = {storeFqdn: store} as AdminSession - const standardEventsDeferred = createDeferred() - - vi.mocked(prepareStandardEventsSupport).mockReturnValue(standardEventsDeferred.promise) - - const devPromise = dev({ - adminSession, - directory: '/tmp/theme', - store, - open: false, - theme, - force: false, - 'standard-events-inspector': false, - 'standard-events-types': true, - 'theme-editor-sync': false, - 'live-reload': 'hot-reload', - 'error-overlay': 'default', - noDelete: false, - ignore: [], - only: [], - }) - - await vi.waitFor(() => { - expect(setupDevServer).toHaveBeenCalled() - expect(prepareStandardEventsSupport).toHaveBeenCalledWith('/tmp/theme') - expect(renderSuccess).toHaveBeenCalled() - }) - - standardEventsDeferred.resolve() - await devPromise - }) - - test('warns when background standard events setup fails', async () => { - const adminSession = {storeFqdn: store} as AdminSession - const error = new Error('refresh failed') - vi.mocked(prepareStandardEventsSupport).mockRejectedValue(error) - - await dev({ - adminSession, - directory: '/tmp/theme', - store, - open: false, - theme, - force: false, - 'standard-events-inspector': false, - 'standard-events-types': true, - 'theme-editor-sync': false, - 'live-reload': 'hot-reload', - 'error-overlay': 'default', - noDelete: false, - ignore: [], - only: [], - }) - - await vi.waitFor(() => { - expect(renderWarning).toHaveBeenCalledWith({ - headline: 'Failed to update standard events types.', - body: error.stack ?? error.message, - }) - }) - expect(setupDevServer).toHaveBeenCalled() - }) }) describe('renderLinks', () => { diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index 072485603c5..f6abef4685d 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -1,7 +1,6 @@ import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {setupDevServer} from '../utilities/theme-environment/theme-environment.js' -import {prepareStandardEventsSupport} from '../utilities/theme-environment/standard-events.js' import {DevServerContext, ErrorOverlayMode, LiveReload} from '../utilities/theme-environment/types.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' import {ensureValidPassword} from '../utilities/theme-environment/storefront-password-prompt.js' @@ -34,7 +33,6 @@ interface DevOptions { port?: string force: boolean 'standard-events-inspector': boolean - 'standard-events-types': boolean 'theme-editor-sync': boolean 'live-reload': LiveReload 'error-overlay': ErrorOverlayMode @@ -153,14 +151,6 @@ export async function dev(options: DevOptions) { openURLSafely(urls.local, 'development server') } }), - options['standard-events-types'] - ? prepareStandardEventsSupport(options.directory).catch((error) => { - renderWarning({ - headline: 'Failed to update standard events types.', - body: error instanceof Error ? (error.stack ?? error.message) : String(error), - }) - }) - : undefined, ]) } diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts index c44a7643b9b..8934785e664 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.test.ts @@ -1,225 +1,12 @@ import { injectStandardEventsInspector, - prepareStandardEventsSupport, rewriteStandardEventsRuntimeReferences, - standardEventsDefinitionsUrl, standardEventsInspectorScriptId, standardEventsInspectorUrl, standardEventsRuntimeDevUrl, standardEventsRuntimeUrl, } from './standard-events.js' -import {beforeEach, describe, expect, test, vi} from 'vitest' -import {fetch, Response} from '@shopify/cli-kit/node/http' -import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' - -vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - fetch: vi.fn(), - } -}) - -beforeEach(() => { - vi.mocked(fetch).mockResolvedValue( - new Response('existing definitions\n', {status: 200, headers: {'content-type': 'text/plain; charset=utf-8'}}), - ) -}) - -describe('prepareStandardEventsSupport', () => { - test('creates standard events assets and jsconfig when missing', async () => { - const typesContent = 'declare global {\n interface Window { standardEvent: unknown }\n}\n' - vi.mocked(fetch).mockResolvedValue( - new Response(typesContent, {status: 200, headers: {'content-type': 'text/plain; charset=utf-8'}}), - ) - - await inTemporaryDirectory(async (tmpDir) => { - await mkdir(joinPath(tmpDir, 'assets')) - - await prepareStandardEventsSupport(tmpDir) - - await expect(readFile(joinPath(tmpDir, 'assets', 'standard-events.d.ts'))).resolves.toEqual(typesContent) - await expect(readFile(joinPath(tmpDir, 'assets', 'global.d.ts'))).resolves.toEqual( - '// Add custom global types here\n', - ) - await expect(readFile(joinPath(tmpDir, 'assets', 'jsconfig.json'))).resolves.toEqual( - `${JSON.stringify( - { - checkJs: false, - include: ['./**/*.js', './**/*.mjs', './**/*.cjs', './**/*.ts', './**/*.d.ts'], - files: ['./standard-events.d.ts', './global.d.ts'], - }, - null, - 2, - )}\n`, - ) - }) - - expect(fetch).toHaveBeenCalledWith(standardEventsDefinitionsUrl, undefined, 'slow-request') - }) - - test('preserves an existing include list and wires types through files', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const assetsDirectory = joinPath(tmpDir, 'assets') - await mkdir(assetsDirectory) - await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'existing definitions\n') - await writeFile(joinPath(assetsDirectory, 'global.d.ts'), '// Existing globals\n') - await writeFile( - joinPath(assetsDirectory, 'jsconfig.json'), - JSON.stringify({include: ['./**/*.js'], exclude: ['./**/*.d.ts']}, null, 2), - ) - - await prepareStandardEventsSupport(tmpDir) - - await expect(readFile(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toEqual( - 'existing definitions\n', - ) - await expect(readFile(joinPath(assetsDirectory, 'global.d.ts'))).resolves.toEqual('// Existing globals\n') - await expect(readFile(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toEqual( - `${JSON.stringify( - { - include: ['./**/*.js'], - exclude: ['./**/*.d.ts'], - files: ['./standard-events.d.ts', './global.d.ts'], - }, - null, - 2, - )}\n`, - ) - }) - - expect(fetch).toHaveBeenCalledWith(standardEventsDefinitionsUrl, undefined, 'slow-request') - }) - - test('appends type entries to an existing files list without changing other config', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const assetsDirectory = joinPath(tmpDir, 'assets') - await mkdir(assetsDirectory) - await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'existing definitions\n') - await writeFile( - joinPath(assetsDirectory, 'jsconfig.json'), - JSON.stringify( - { - checkJs: true, - files: ['./main.js'], - compilerOptions: {strict: true}, - }, - null, - 2, - ), - ) - - await prepareStandardEventsSupport(tmpDir) - - await expect(readFile(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toEqual( - `${JSON.stringify( - { - checkJs: true, - files: ['./main.js', './standard-events.d.ts', './global.d.ts'], - compilerOptions: {strict: true}, - }, - null, - 2, - )}\n`, - ) - }) - }) - - test('adds default includes and type files when an existing config has neither include nor files', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const assetsDirectory = joinPath(tmpDir, 'assets') - await mkdir(assetsDirectory) - await writeFile( - joinPath(assetsDirectory, 'jsconfig.json'), - JSON.stringify( - { - checkJs: true, - compilerOptions: {strict: true}, - }, - null, - 2, - ), - ) - - await prepareStandardEventsSupport(tmpDir) - - await expect(readFile(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toEqual( - `${JSON.stringify( - { - checkJs: true, - compilerOptions: {strict: true}, - include: ['./**/*.js', './**/*.mjs', './**/*.cjs', './**/*.ts', './**/*.d.ts'], - files: ['./standard-events.d.ts', './global.d.ts'], - }, - null, - 2, - )}\n`, - ) - }) - }) - - test('refreshes standard events definitions when a newer version is available', async () => { - vi.mocked(fetch).mockResolvedValue( - new Response('new definitions\n', {status: 200, headers: {'content-type': 'text/plain; charset=utf-8'}}), - ) - - await inTemporaryDirectory(async (tmpDir) => { - const assetsDirectory = joinPath(tmpDir, 'assets') - await mkdir(assetsDirectory) - await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'old definitions\n') - - await prepareStandardEventsSupport(tmpDir) - - await expect(readFile(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toEqual('new definitions\n') - }) - }) - - test('keeps existing definitions when refreshing them fails', async () => { - vi.mocked(fetch).mockResolvedValue( - new Response('Sign in', { - status: 200, - headers: {'content-type': 'text/html; charset=utf-8'}, - }), - ) - - await inTemporaryDirectory(async (tmpDir) => { - const assetsDirectory = joinPath(tmpDir, 'assets') - await mkdir(assetsDirectory) - await writeFile(joinPath(assetsDirectory, 'standard-events.d.ts'), 'existing definitions\n') - - await expect(prepareStandardEventsSupport(tmpDir)).resolves.toBeUndefined() - await expect(readFile(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toEqual( - 'existing definitions\n', - ) - await expect(readFile(joinPath(assetsDirectory, 'global.d.ts'))).resolves.toEqual( - '// Add custom global types here\n', - ) - }) - }) - - test('fails without writing files when the downloaded asset is HTML', async () => { - vi.mocked(fetch).mockResolvedValue( - new Response('Sign in', { - status: 200, - headers: {'content-type': 'text/html; charset=utf-8'}, - }), - ) - - await inTemporaryDirectory(async (tmpDir) => { - const assetsDirectory = joinPath(tmpDir, 'assets') - await mkdir(assetsDirectory) - - await expect(prepareStandardEventsSupport(tmpDir)).rejects.toThrow( - 'Failed to download standard events definitions.', - ) - - await expect(fileExists(joinPath(assetsDirectory, 'standard-events.d.ts'))).resolves.toBe(false) - await expect(fileExists(joinPath(assetsDirectory, 'global.d.ts'))).resolves.toBe(false) - await expect(fileExists(joinPath(assetsDirectory, 'jsconfig.json'))).resolves.toBe(false) - }) - }) -}) +import {describe, expect, test} from 'vitest' describe('injectStandardEventsInspector', () => { test('injects the inspector script at the beginning of the head element', () => { diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts index e939059f837..669e884c510 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts @@ -1,26 +1,6 @@ -import {AbortError} from '@shopify/cli-kit/node/error' -import {fileExists, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {fetch} from '@shopify/cli-kit/node/http' -import {joinPath} from '@shopify/cli-kit/node/path' -import {parseJSON} from '@shopify/theme-check-node' - -interface JsConfigFile { - include?: string[] - files?: string[] - [key: string]: unknown -} - -const standardEventsDefinitionFile = 'standard-events.d.ts' -const globalTypesFile = 'global.d.ts' -const jsConfigFile = 'jsconfig.json' -const globalTypesComment = '// Add custom global types here\n' -const defaultJsConfigIncludes = ['./**/*.js', './**/*.mjs', './**/*.cjs', './**/*.ts', './**/*.d.ts'] -const wiredTypeFiles = [`./${standardEventsDefinitionFile}`, `./${globalTypesFile}`] -const htmlDocumentRE = /^\s*<(?:!doctype\s+html|html)\b/i const headTagRE = /]*)?>/i export const standardEventsBaseUrl = 'https://standard-events.quick.shopify.io' -export const standardEventsDefinitionsUrl = `${standardEventsBaseUrl}/standard-events.d.ts` export const standardEventsRuntimeUrl = `${standardEventsBaseUrl}/standard-events.js` export const standardEventsRuntimeDevUrl = `${standardEventsBaseUrl}/standard-events.dev.js` export const standardEventsInspectorUrl = `${standardEventsBaseUrl}/events-inspector.js` @@ -33,54 +13,6 @@ const standardEventsInspectorScriptRE = new RegExp( ) const standardEventsRuntimeRE = new RegExp(escapeRegExp(standardEventsRuntimeUrl), 'g') -export async function prepareStandardEventsSupport(themeDirectory: string) { - const assetsDirectory = joinPath(themeDirectory, 'assets') - const standardEventsDefinitionPath = joinPath(assetsDirectory, standardEventsDefinitionFile) - const globalTypesPath = joinPath(assetsDirectory, globalTypesFile) - const jsConfigPath = joinPath(assetsDirectory, jsConfigFile) - - const [hasStandardEventsDefinitions, hasGlobalTypes, hasJsConfig] = await Promise.all([ - fileExists(standardEventsDefinitionPath), - fileExists(globalTypesPath), - fileExists(jsConfigPath), - ]) - - const [existingStandardEventsDefinitions, existingJsConfig, standardEventsDefinitionsResult] = await Promise.all([ - hasStandardEventsDefinitions ? readFile(standardEventsDefinitionPath) : undefined, - hasJsConfig ? readFile(jsConfigPath) : undefined, - downloadStandardEventsDefinitions().then( - (content) => ({ok: true as const, content}), - (error: unknown) => ({ok: false as const, error}), - ), - mkdir(assetsDirectory), - ]) - - if (!standardEventsDefinitionsResult.ok && !hasStandardEventsDefinitions) { - throw standardEventsDefinitionsResult.error - } - - const nextJsConfig = updateJsConfig(existingJsConfig) - const nextJsConfigContent = `${JSON.stringify(nextJsConfig, null, 2)}\n` - const writePromises = [] - - if (existingJsConfig !== nextJsConfigContent) { - writePromises.push(writeFile(jsConfigPath, nextJsConfigContent)) - } - - if ( - standardEventsDefinitionsResult.ok && - existingStandardEventsDefinitions !== standardEventsDefinitionsResult.content - ) { - writePromises.push(writeFile(standardEventsDefinitionPath, standardEventsDefinitionsResult.content)) - } - - if (!hasGlobalTypes) { - writePromises.push(writeFile(globalTypesPath, globalTypesComment)) - } - - await Promise.all(writePromises) -} - export function injectStandardEventsInspector(html: string) { if (standardEventsInspectorScriptRE.test(html)) { return html @@ -97,82 +29,6 @@ export function rewriteStandardEventsRuntimeReferences(content: string) { return content.replace(standardEventsRuntimeRE, standardEventsRuntimeDevUrl) } -async function downloadStandardEventsDefinitions() { - const response = await fetch(standardEventsDefinitionsUrl, undefined, 'slow-request') - - if (!response.ok) { - throw new AbortError( - 'Failed to download standard events definitions.', - `${response.status} ${response.statusText}`.trim(), - ) - } - - const content = await response.text() - const contentType = response.headers.get('content-type') ?? '' - - if (contentType.includes('text/html') || htmlDocumentRE.test(content)) { - throw new AbortError( - 'Failed to download standard events definitions.', - `Received HTML instead of TypeScript from ${standardEventsDefinitionsUrl}.`, - ) - } - - return content.endsWith('\n') ? content : `${content}\n` -} - -function updateJsConfig(fileContent?: string) { - if (!fileContent) { - return { - checkJs: false, - include: defaultJsConfigIncludes, - files: wiredTypeFiles, - } - } - - const parsed = parseJSON(fileContent, null, true) as JsConfigFile | undefined - - if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { - throw new AbortError(`Failed to update assets/${jsConfigFile}.`, 'The existing file is not valid JSON.') - } - - const jsConfig: JsConfigFile = {...parsed} - - if (jsConfig.files !== undefined) { - jsConfig.files = appendTypeEntries(jsConfig.files, 'files') - return jsConfig - } - - if (jsConfig.include !== undefined) { - validateTypeEntries(jsConfig.include, 'include') - jsConfig.files = [...wiredTypeFiles] - return jsConfig - } - - jsConfig.include = defaultJsConfigIncludes - jsConfig.files = [...wiredTypeFiles] - return jsConfig -} - -function appendTypeEntries(entries: unknown, fieldName: 'include' | 'files') { - validateTypeEntries(entries, fieldName) - - const nextEntries = [...entries] - - for (const entry of wiredTypeFiles) { - if (!nextEntries.includes(entry)) { - nextEntries.push(entry) - } - } - - return nextEntries -} - -function validateTypeEntries(entries: unknown, fieldName: 'include' | 'files'): asserts entries is string[] { - if (!Array.isArray(entries) || !entries.every((entry) => typeof entry === 'string')) { - throw new AbortError(`Failed to update assets/${jsConfigFile}.`, `The "${fieldName}" field must be a string array.`) - } -} - function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } From 6a16b98a9e9a9c76eafa12d26c045c0c73aeb995 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Tue, 16 Jun 2026 08:46:32 +0900 Subject: [PATCH 05/11] Fix theme standard events test types --- packages/theme/src/cli/services/dev.test.ts | 4 ++-- .../utilities/theme-ext-environment/theme-ext-server.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index edf4060f9c9..f9425c7350f 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -14,7 +14,6 @@ import {openURL} from '@shopify/cli-kit/node/system' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' import {getAvailableTCPPort, checkPortAvailability} from '@shopify/cli-kit/node/tcp' -import {AdminSession} from '@shopify/cli-kit/node/session' import {Config} from '@oclif/core' vi.mock('@shopify/cli-kit/node/ui') @@ -75,9 +74,10 @@ beforeEach(() => { vi.mocked(setupDevServer).mockReturnValue({ workPromise: Promise.resolve(), serverStart: vi.fn().mockResolvedValue({close: vi.fn().mockResolvedValue(undefined)}), - dispatchEvent: vi.fn(), + dispatchEvent: vi.fn() as ReturnType['dispatchEvent'], renderDevSetupProgress: vi.fn().mockResolvedValue(undefined), backgroundJobPromise: Promise.resolve(undefined as never), + resolveBackgroundJob: vi.fn(), }) vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) vi.mocked(emptyThemeExtFileSystem).mockReturnValue({files: new Map()} as never) diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts index e365662567d..2ee66bcdbe1 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.test.ts @@ -72,6 +72,8 @@ describe('createDevelopmentExtensionServer', () => { host: '127.0.0.1', port: 9293, liveReload: 'hot-reload', + standardEventsDevBundle: false, + standardEventsInspector: false, open: false, themeEditorSync: false, errorOverlay: 'default', From 7a9dd47283ae80659969e2d4811a14e387a48d1f Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Tue, 16 Jun 2026 13:57:02 +0900 Subject: [PATCH 06/11] Update url --- .../src/cli/utilities/theme-environment/standard-events.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts index 669e884c510..dee5ac70acb 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts @@ -1,9 +1,9 @@ const headTagRE = /]*)?>/i -export const standardEventsBaseUrl = 'https://standard-events.quick.shopify.io' +export const standardEventsBaseUrl = 'https://cdn.shopify.com/storefront' export const standardEventsRuntimeUrl = `${standardEventsBaseUrl}/standard-events.js` export const standardEventsRuntimeDevUrl = `${standardEventsBaseUrl}/standard-events.dev.js` -export const standardEventsInspectorUrl = `${standardEventsBaseUrl}/events-inspector.js` +export const standardEventsInspectorUrl = `${standardEventsBaseUrl}/standard-events-inspector.js` export const standardEventsInspectorScriptId = 'shopify-cli-standard-events-inspector' const standardEventsInspectorScriptRE = new RegExp( `]*(?:\\bid=["']${escapeRegExp(standardEventsInspectorScriptId)}["']|\\bsrc=["']${escapeRegExp( From 772e32ac0d7d0c6384a44ce50207f91b17059ab8 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Tue, 16 Jun 2026 14:06:24 +0900 Subject: [PATCH 07/11] Simplify standard events dev tests Remove merge-era mock setup from the theme dev service test now that the Standard Events assertions use the existing dev command test harness. This keeps the test focused on the new option plumbing without duplicating unrelated setup. Co-authored-by: OpenAI Codex --- packages/theme/src/cli/services/dev.test.ts | 42 +-------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index f9425c7350f..a0ca32ca763 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -1,11 +1,8 @@ import {dev, openURLSafely, renderLinks, createKeypressHandler, reportDevAnalytics} from './dev.js' import {setupDevServer} from '../utilities/theme-environment/theme-environment.js' -import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' -import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' +import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' -import {emptyThemeExtFileSystem} from '../utilities/theme-fs-empty.js' import {initializeDevServerSession} from '../utilities/theme-environment/dev-server-session.js' -import {ensureListingExists} from '../utilities/theme-listing.js' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' import {describe, expect, test, vi, beforeEach, afterEach, type MockInstance} from 'vitest' import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' @@ -45,9 +42,6 @@ vi.mock('../utilities/theme-fs.js', () => ({ hasRequiredThemeDirectories: vi.fn(), mountThemeFileSystem: vi.fn().mockReturnValue({}), })) -vi.mock('../utilities/theme-ui.js', () => ({ - ensureDirectoryConfirmed: vi.fn(), -})) vi.mock('../utilities/theme-fs-empty.js', () => ({ emptyThemeExtFileSystem: vi.fn().mockReturnValue({}), })) @@ -57,41 +51,10 @@ vi.mock('../utilities/theme-environment/storefront-session.js', () => ({ vi.mock('../utilities/theme-environment/dev-server-session.js', () => ({ initializeDevServerSession: vi.fn(), })) -vi.mock('../utilities/theme-environment/storefront-password-prompt.js', () => ({ - ensureValidPassword: vi.fn(), -})) -vi.mock('../utilities/theme-listing.js', () => ({ - ensureListingExists: vi.fn(), -})) const store = 'my-store.myshopify.com' const theme = buildTheme({id: 123, name: 'My Theme', role: DEVELOPMENT_THEME_ROLE})! -beforeEach(() => { - vi.mocked(hasRequiredThemeDirectories).mockResolvedValue(true) - vi.mocked(mountThemeFileSystem).mockReturnValue({files: new Map(), uploadErrors: new Map()} as never) - vi.mocked(ensureDirectoryConfirmed).mockResolvedValue(true) - vi.mocked(setupDevServer).mockReturnValue({ - workPromise: Promise.resolve(), - serverStart: vi.fn().mockResolvedValue({close: vi.fn().mockResolvedValue(undefined)}), - dispatchEvent: vi.fn() as ReturnType['dispatchEvent'], - renderDevSetupProgress: vi.fn().mockResolvedValue(undefined), - backgroundJobPromise: Promise.resolve(undefined as never), - resolveBackgroundJob: vi.fn(), - }) - vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) - vi.mocked(emptyThemeExtFileSystem).mockReturnValue({files: new Map()} as never) - vi.mocked(initializeDevServerSession).mockResolvedValue({ - token: 'token', - storefrontToken: 'storefront-token', - storeFqdn: 'my-store.myshopify.com', - sessionCookies: {}, - } as never) - vi.mocked(ensureListingExists).mockResolvedValue(undefined) - vi.mocked(checkPortAvailability).mockResolvedValue(true) - vi.mocked(getAvailableTCPPort).mockResolvedValue(9292) -}) - describe('renderLinks', () => { test('renders "dev" command links', async () => { // Given @@ -331,9 +294,6 @@ describe('dev() Ctrl-C analytics', () => { } beforeEach(() => { - vi.mocked(reportAnalyticsEvent).mockClear() - vi.mocked(addPublicMetadata).mockClear() - vi.mocked(addSensitiveMetadata).mockClear() vi.mocked(hasRequiredThemeDirectories).mockResolvedValue(true) vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) vi.mocked(initializeDevServerSession).mockResolvedValue({ From f2c1f50a2f52c0c234906d934f591ad2fac11977 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Tue, 16 Jun 2026 14:09:12 +0900 Subject: [PATCH 08/11] Changesets --- .changeset/sweet-areas-live.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sweet-areas-live.md diff --git a/.changeset/sweet-areas-live.md b/.changeset/sweet-areas-live.md new file mode 100644 index 00000000000..91c7534dc64 --- /dev/null +++ b/.changeset/sweet-areas-live.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': minor +--- + +Add a new `--standard-events-inspector` flag to the `shopify theme dev` command to inject the Standard Storefront Events inspector into storefront previews. From 73e832be3dea8028ebc858a4b14bcb85f89c1f4d Mon Sep 17 00:00:00 2001 From: Lucy Xiang Date: Tue, 16 Jun 2026 11:08:42 -0400 Subject: [PATCH 09/11] Fix CI: regenerate oclif manifest/readme and drop unused export Regenerate the oclif manifest and README to include the new --standard-events-inspector flag, and remove the unused export on standardEventsBaseUrl flagged by knip. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/README.md | 7 +++++-- packages/cli/oclif.manifest.json | 7 +++++++ .../src/cli/utilities/theme-environment/standard-events.ts | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 02da54b2d35..c13a042ffca 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2321,8 +2321,8 @@ Uploads the current theme as a development theme to the connected store, then pr USAGE $ shopify theme dev [-a] [-e ...] [--error-overlay silent|default] [--host ] [-x ...] [--listing ] [--live-reload hot-reload|full-page|off] [--no-color] [-n] [--notify ] [-o ...] - [--open] [--password ] [--path ] [--port ] [-s ] [--store-password ] [-t ] - [--theme-editor-sync] [--verbose] + [--open] [--password ] [--path ] [--port ] [--standard-events-inspector] [-s ] + [--store-password ] [-t ] [--theme-editor-sync] [--verbose] FLAGS -a, --allow-live @@ -2391,6 +2391,9 @@ FLAGS --port= [env: SHOPIFY_FLAG_PORT] Local port to serve theme preview from. Must be between 1 and 65535. + --standard-events-inspector + [env: SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR] Inject the standard events inspector into storefront HTML. + --store-password= [env: SHOPIFY_FLAG_STORE_PASSWORD] The password for storefronts with password protection. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a5922db0fd3..f0449b188f3 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6503,6 +6503,13 @@ "name": "port", "type": "option" }, + "standard-events-inspector": { + "allowNo": false, + "description": "Inject the standard events inspector into storefront HTML.", + "env": "SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR", + "name": "standard-events-inspector", + "type": "boolean" + }, "store": { "char": "s", "description": "Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).", diff --git a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts index dee5ac70acb..58cf8f85384 100644 --- a/packages/theme/src/cli/utilities/theme-environment/standard-events.ts +++ b/packages/theme/src/cli/utilities/theme-environment/standard-events.ts @@ -1,6 +1,6 @@ const headTagRE = /]*)?>/i -export const standardEventsBaseUrl = 'https://cdn.shopify.com/storefront' +const standardEventsBaseUrl = 'https://cdn.shopify.com/storefront' export const standardEventsRuntimeUrl = `${standardEventsBaseUrl}/standard-events.js` export const standardEventsRuntimeDevUrl = `${standardEventsBaseUrl}/standard-events.dev.js` export const standardEventsInspectorUrl = `${standardEventsBaseUrl}/standard-events-inspector.js` From f4444756d08dcb6f362b646ad9a5f57e16f67eef Mon Sep 17 00:00:00 2001 From: Lucy Xiang Date: Tue, 16 Jun 2026 11:09:47 -0400 Subject: [PATCH 10/11] Only patch proxied HTML when standard events inspector is enabled Gate the proxy-fallback patching on ctx.options.standardEventsInspector so the normal theme dev proxy path returns raw HTML as before, avoiding an unintended behavior change for responses unrelated to the inspector. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/theme/src/cli/utilities/theme-environment/html.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/utilities/theme-environment/html.ts b/packages/theme/src/cli/utilities/theme-environment/html.ts index 043dcc96603..53099012931 100644 --- a/packages/theme/src/cli/utilities/theme-environment/html.ts +++ b/packages/theme/src/cli/utilities/theme-environment/html.ts @@ -183,7 +183,7 @@ async function tryProxyRequest(event: H3Event, ctx: DevServerContext, response: if (proxyResponse.status < 400) { outputDebug(`Proxy status: ${proxyResponse.status}. Returning proxy response.`) - if ((proxyResponse.headers.get('content-type') ?? '').includes('text/html')) { + if (ctx.options.standardEventsInspector && (proxyResponse.headers.get('content-type') ?? '').includes('text/html')) { return patchRenderingResponse(ctx, proxyResponse) } From babb7f4aabb967a49fb79c2cf66b29d8de053f17 Mon Sep 17 00:00:00 2001 From: Lucy Xiang Date: Tue, 16 Jun 2026 11:27:27 -0400 Subject: [PATCH 11/11] Fix CI: prettier formatting and regenerate dev docs for new flag - Wrap the proxy-fallback condition to satisfy prettier/max-len - Regenerate shopify.dev docs so --standard-events-inspector is documented Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commands/interfaces/theme-dev.interface.ts | 6 ++++++ .../generated/generated_docs_data_v2.json | 11 ++++++++++- packages/theme/src/cli/services/dev.ts | 2 +- .../theme/src/cli/utilities/theme-environment/html.ts | 5 ++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts b/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts index 0945a3a9d46..6cca4461c16 100644 --- a/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts @@ -100,6 +100,12 @@ export interface themedev { */ '--port '?: string + /** + * Inject the standard events inspector into storefront HTML. + * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR + */ + '--standard-events-inspector'?: '' + /** * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). * @environment SHOPIFY_FLAG_STORE diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 3c9205db032..b83f906791c 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4739,6 +4739,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_PORT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--standard-events-inspector", + "value": "''", + "description": "Inject the standard events inspector into storefront HTML.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -4830,7 +4839,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Inject the standard events inspector into storefront HTML.\n * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR\n */\n '--standard-events-inspector'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themeduplicate": { diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index 08a79e8c190..f9f440bcc69 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -156,7 +156,7 @@ export async function dev(options: DevOptions) { backgroundJobPromise, renderDevSetupProgress() .then(serverStart) - .then(async () => { + .then(() => { if (process.stdin.isTTY) { process.stdin.setRawMode(true) } diff --git a/packages/theme/src/cli/utilities/theme-environment/html.ts b/packages/theme/src/cli/utilities/theme-environment/html.ts index 53099012931..8420869055d 100644 --- a/packages/theme/src/cli/utilities/theme-environment/html.ts +++ b/packages/theme/src/cli/utilities/theme-environment/html.ts @@ -183,7 +183,10 @@ async function tryProxyRequest(event: H3Event, ctx: DevServerContext, response: if (proxyResponse.status < 400) { outputDebug(`Proxy status: ${proxyResponse.status}. Returning proxy response.`) - if (ctx.options.standardEventsInspector && (proxyResponse.headers.get('content-type') ?? '').includes('text/html')) { + if ( + ctx.options.standardEventsInspector && + (proxyResponse.headers.get('content-type') ?? '').includes('text/html') + ) { return patchRenderingResponse(ctx, proxyResponse) }