diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index fb2180a152a..972aab37660 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7979,6 +7979,71 @@ "strict": true, "summary": "Renames an existing theme." }, + "theme:sandbox-preview": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/theme", + "description": "Starts an authless, one-shot sandbox preview using \"mock.shop\" (https://mock.shop/).\n\nThis prototype writes a local launcher page and opens it in your browser. The launcher then POSTs the override payload directly to the target storefront to render an initial preview.\n\n- No store authentication is required\n- No preview is persisted\n- Navigation after the first page load will not preserve overrides", + "descriptionWithMarkdown": "Starts an authless, one-shot sandbox preview using [mock.shop](https://mock.shop/).\n\nThis prototype writes a local launcher page and opens it in your browser. The launcher then POSTs the override payload directly to the target storefront to render an initial preview.\n\n- No store authentication is required\n- No preview is persisted\n- Navigation after the first page load will not preserve overrides", + "enableJsonFlag": false, + "flags": { + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "no-open": { + "allowNo": false, + "description": "Do not automatically launch the local storefront preview launcher in your default web browser.", + "env": "SHOPIFY_FLAG_NO_OPEN", + "name": "no-open", + "type": "boolean" + }, + "overrides": { + "description": "Path to a JSON overrides file.", + "env": "SHOPIFY_FLAG_OVERRIDES", + "hasDynamicHelp": false, + "multiple": false, + "name": "overrides", + "required": true, + "type": "option" + }, + "storefront-url": { + "description": "Override the storefront preview target. Useful for local SFR testing.", + "env": "SHOPIFY_FLAG_STOREFRONT_URL", + "hasDynamicHelp": false, + "multiple": false, + "name": "storefront-url", + "required": false, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "theme:sandbox-preview", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Opens a one-shot authless mock.shop sandbox preview from a JSON overrides file.", + "usage": [ + "theme sandbox-preview --overrides path/to/overrides.json" + ] + }, "theme:serve": { "aliases": [ ], diff --git a/packages/theme/src/cli/commands/theme/preview/mock.ts b/packages/theme/src/cli/commands/theme/preview/mock.ts new file mode 100644 index 00000000000..9674628ef61 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/preview/mock.ts @@ -0,0 +1,50 @@ +import {devWithOverrideFile} from '../../../services/dev-override.js' +import {Flags} from '@oclif/core' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' + +export default class PreviewMock extends Command { + static summary = 'Opens a one-shot authless mock.shop sandbox preview from a JSON overrides file.' + + static usage = ['theme preview mock --overrides path/to/overrides.json'] + + static descriptionWithMarkdown = `Starts an authless, one-shot sandbox preview using [mock.shop](https://mock.shop/). + +This prototype writes a local launcher page and opens it in your browser. The launcher then POSTs the override payload directly to the target storefront to render an initial preview. + +- No store authentication is required +- No preview is persisted +- Navigation after the first page load will not preserve overrides` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + overrides: Flags.string({ + description: 'Path to a JSON overrides file.', + env: 'SHOPIFY_FLAG_OVERRIDES', + required: true, + }), + open: Flags.boolean({ + description: 'Automatically launch the local storefront preview launcher in your default web browser.', + env: 'SHOPIFY_FLAG_OPEN', + default: true, + }), + 'storefront-url': Flags.string({ + description: 'Override the storefront preview target. Useful for local SFR testing.', + env: 'SHOPIFY_FLAG_STOREFRONT_URL', + required: false, + }), + } + + async run(): Promise { + const {flags} = await this.parse(PreviewMock) + + await devWithOverrideFile({ + overrideJson: flags.overrides, + open: flags.open, + mockShop: true, + mockShopStorefrontUrl: flags['storefront-url'], + }) + } +} diff --git a/packages/theme/src/cli/commands/theme/sandbox-preview.test.ts b/packages/theme/src/cli/commands/theme/sandbox-preview.test.ts new file mode 100644 index 00000000000..cfb015655ca --- /dev/null +++ b/packages/theme/src/cli/commands/theme/sandbox-preview.test.ts @@ -0,0 +1,53 @@ +import SandboxPreview from './sandbox-preview.js' +import {devWithOverrideFile} from '../../services/dev-override.js' +import {Config} from '@oclif/core' +import {describe, vi, expect, test, beforeEach} from 'vitest' + +vi.mock('../../services/dev-override.js') + +const CommandConfig = new Config({root: __dirname}) + +describe('SandboxPreview', () => { + beforeEach(() => { + vi.mocked(devWithOverrideFile).mockResolvedValue(undefined) + }) + + async function run(argv: string[]) { + await CommandConfig.load() + const command = new SandboxPreview(argv, CommandConfig) + await command.run() + } + + test('calls devWithOverrideFile in authless mock.shop mode', async () => { + await run(['--overrides=/path/to/overrides.json']) + + expect(devWithOverrideFile).toHaveBeenCalledWith({ + overrideJson: '/path/to/overrides.json', + open: true, + mockShop: true, + mockShopStorefrontUrl: undefined, + }) + }) + + test('passes through --no-open when provided', async () => { + await run(['--overrides=/path/to/overrides.json', '--no-open']) + + expect(devWithOverrideFile).toHaveBeenCalledWith({ + overrideJson: '/path/to/overrides.json', + open: false, + mockShop: true, + mockShopStorefrontUrl: undefined, + }) + }) + + test('passes through a custom storefront URL when provided', async () => { + await run(['--overrides=/path/to/overrides.json', '--storefront-url=http://localhost:3000']) + + expect(devWithOverrideFile).toHaveBeenCalledWith({ + overrideJson: '/path/to/overrides.json', + open: true, + mockShop: true, + mockShopStorefrontUrl: 'http://localhost:3000', + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/sandbox-preview.ts b/packages/theme/src/cli/commands/theme/sandbox-preview.ts new file mode 100644 index 00000000000..797c8b47525 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/sandbox-preview.ts @@ -0,0 +1,50 @@ +import {devWithOverrideFile} from '../../services/dev-override.js' +import {Flags} from '@oclif/core' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' + +export default class SandboxPreview extends Command { + static summary = 'Opens a one-shot authless mock.shop sandbox preview from a JSON overrides file.' + + static usage = ['theme sandbox-preview --overrides path/to/overrides.json'] + + static descriptionWithMarkdown = `Starts an authless, one-shot sandbox preview using [mock.shop](https://mock.shop/). + +This prototype writes a local launcher page and opens it in your browser. The launcher then POSTs the override payload directly to the target storefront to render an initial preview. + +- No store authentication is required +- No preview is persisted +- Navigation after the first page load will not preserve overrides` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + overrides: Flags.string({ + description: 'Path to a JSON overrides file.', + env: 'SHOPIFY_FLAG_OVERRIDES', + required: true, + }), + 'no-open': Flags.boolean({ + description: 'Do not automatically launch the local storefront preview launcher in your default web browser.', + env: 'SHOPIFY_FLAG_NO_OPEN', + default: false, + }), + 'storefront-url': Flags.string({ + description: 'Override the storefront preview target. Useful for local SFR testing.', + env: 'SHOPIFY_FLAG_STOREFRONT_URL', + required: false, + }), + } + + async run(): Promise { + const {flags} = await this.parse(SandboxPreview) + + await devWithOverrideFile({ + overrideJson: flags.overrides, + open: !flags['no-open'], + mockShop: true, + mockShopStorefrontUrl: flags['storefront-url'], + }) + } +} diff --git a/packages/theme/src/cli/services/dev-override.test.ts b/packages/theme/src/cli/services/dev-override.test.ts index 2a1b3742486..9ebe8733524 100644 --- a/packages/theme/src/cli/services/dev-override.test.ts +++ b/packages/theme/src/cli/services/dev-override.test.ts @@ -2,6 +2,7 @@ import {devWithOverrideFile} from './dev-override.js' import {openURLSafely} from './dev.js' import {fetchDevServerSession} from '../utilities/theme-environment/dev-server-session.js' import {createThemePreview, updateThemePreview} from '../utilities/theme-previews/preview.js' +import {startMockShopPreviewSession} from '../utilities/theme-previews/mock-shop.js' import {describe, expect, test, vi} from 'vitest' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {collectedLogs, clearCollectedLogs} from '@shopify/cli-kit/node/output' @@ -9,6 +10,7 @@ import {fileExistsSync, readFile} from '@shopify/cli-kit/node/fs' vi.mock('../utilities/theme-environment/dev-server-session.js') vi.mock('../utilities/theme-previews/preview.js') +vi.mock('../utilities/theme-previews/mock-shop.js') vi.mock('./dev.js', () => ({openURLSafely: vi.fn()})) vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/fs') @@ -22,6 +24,9 @@ const mockSession = { } const expectedPreviewUrl = 'https://abc123.shopifypreview.com' const expectedPreviewId = 'abc123' +const mockShopLauncherUrl = 'file:///tmp/mock-shop-preview.html' +const mockShopTargetUrl = 'https://demostore.mock.shop/?theme_preview' +const customMockShopTargetUrl = 'http://localhost:3000/?theme_preview' describe('devWithOverrideFile', () => { test('throws when override file does not exist', async () => { @@ -208,4 +213,86 @@ describe('devWithOverrideFile', () => { // Then expect(renderSuccess).toHaveBeenCalled() }) + + test('starts a one-shot mock.shop preview when mockShop is enabled', async () => { + vi.mocked(fileExistsSync).mockReturnValue(true) + vi.mocked(readFile).mockResolvedValue(Buffer.from(JSON.stringify({templates: {}}))) + vi.mocked(startMockShopPreviewSession).mockResolvedValue({ + launcherUrl: mockShopLauncherUrl, + targetUrl: mockShopTargetUrl, + completion: Promise.resolve(), + }) + + await devWithOverrideFile({adminSession, overrideJson: '/overrides.json', open: false, mockShop: true}) + + expect(startMockShopPreviewSession).toHaveBeenCalledWith(JSON.stringify({templates: {}}), { + storefrontUrl: undefined, + }) + expect(fetchDevServerSession).not.toHaveBeenCalled() + expect(createThemePreview).not.toHaveBeenCalled() + expect(updateThemePreview).not.toHaveBeenCalled() + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + body: [ + { + list: { + title: 'Mock.shop preview is ready', + items: [{link: {url: mockShopLauncherUrl}}, `Target: ${mockShopTargetUrl}`, 'This prototype opens an initial preview only.'], + }, + }, + ], + }), + ) + }) + + test('opens the launcher URL for a mock.shop preview', async () => { + vi.mocked(fileExistsSync).mockReturnValue(true) + vi.mocked(readFile).mockResolvedValue(Buffer.from(JSON.stringify({templates: {}}))) + vi.mocked(startMockShopPreviewSession).mockResolvedValue({ + launcherUrl: mockShopLauncherUrl, + targetUrl: mockShopTargetUrl, + completion: Promise.resolve(), + }) + + await devWithOverrideFile({adminSession, overrideJson: '/overrides.json', open: true, mockShop: true}) + + expect(openURLSafely).toHaveBeenCalledWith(mockShopLauncherUrl, 'mock.shop preview') + }) + + test('passes through a custom storefront URL in mock.shop mode', async () => { + vi.mocked(fileExistsSync).mockReturnValue(true) + vi.mocked(readFile).mockResolvedValue(Buffer.from(JSON.stringify({templates: {}}))) + vi.mocked(startMockShopPreviewSession).mockResolvedValue({ + launcherUrl: mockShopLauncherUrl, + targetUrl: customMockShopTargetUrl, + completion: Promise.resolve(), + }) + + await devWithOverrideFile({ + adminSession, + overrideJson: '/overrides.json', + open: false, + mockShop: true, + mockShopStorefrontUrl: 'http://localhost:3000', + }) + + expect(startMockShopPreviewSession).toHaveBeenCalledWith(JSON.stringify({templates: {}}), { + storefrontUrl: 'http://localhost:3000', + }) + }) + + test('rejects preview IDs in mock.shop mode', async () => { + vi.mocked(fileExistsSync).mockReturnValue(true) + vi.mocked(readFile).mockResolvedValue(Buffer.from(JSON.stringify({templates: {}}))) + + await expect( + devWithOverrideFile({ + adminSession, + overrideJson: '/overrides.json', + open: false, + mockShop: true, + previewIdentifier: 'abc123', + }), + ).rejects.toThrow('The --preview-id flag is not supported with --mock-shop.') + }) }) diff --git a/packages/theme/src/cli/services/dev-override.ts b/packages/theme/src/cli/services/dev-override.ts index be29c90c226..f5e73f62863 100644 --- a/packages/theme/src/cli/services/dev-override.ts +++ b/packages/theme/src/cli/services/dev-override.ts @@ -1,6 +1,7 @@ import {openURLSafely} from './dev.js' import {fetchDevServerSession} from '../utilities/theme-environment/dev-server-session.js' import {createThemePreview, updateThemePreview} from '../utilities/theme-previews/preview.js' +import {startMockShopPreviewSession} from '../utilities/theme-previews/mock-shop.js' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {outputInfo} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' @@ -12,13 +13,15 @@ interface ThemeOverrides { } interface DevWithOverrideFileOptions { - adminSession: AdminSession + adminSession?: AdminSession overrideJson: string - themeId: string + themeId?: string previewIdentifier?: string open: boolean password?: string json?: boolean + mockShop?: boolean + mockShopStorefrontUrl?: string } /** @@ -39,9 +42,54 @@ export async function devWithOverrideFile(options: DevWithOverrideFileOptions) { throw new AbortError(`Failed to parse override file: ${options.overrideJson}`, reason) } - const session = await fetchDevServerSession(options.themeId, options.adminSession, options.password) const overridesContent = JSON.stringify(overrides) + if (options.mockShop) { + if (options.previewIdentifier) { + throw new AbortError('The --preview-id flag is not supported with --mock-shop.') + } + + if (options.json) { + throw new AbortError('The --json flag is not supported with --mock-shop.') + } + + const preview = await startMockShopPreviewSession(overridesContent, { + storefrontUrl: options.mockShopStorefrontUrl, + }) + + renderSuccess({ + body: [ + { + list: { + title: 'Mock.shop preview is ready', + items: [ + {link: {url: preview.launcherUrl}}, + `Target: ${preview.targetUrl}`, + 'This prototype opens an initial preview only.', + ], + }, + }, + ], + }) + + if (options.open) { + openURLSafely(preview.launcherUrl, 'mock.shop preview') + } + + await preview.completion + return + } + + if (!options.themeId) { + throw new AbortError('A theme ID is required unless --mock-shop is used.') + } + + if (!options.adminSession) { + throw new AbortError('An admin session is required unless --mock-shop is used.') + } + + const session = await fetchDevServerSession(options.themeId, options.adminSession, options.password) + const preview = options.previewIdentifier ? await updateThemePreview({ session, diff --git a/packages/theme/src/cli/utilities/theme-previews/mock-shop.test.ts b/packages/theme/src/cli/utilities/theme-previews/mock-shop.test.ts new file mode 100644 index 00000000000..b28bc658be9 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-previews/mock-shop.test.ts @@ -0,0 +1,44 @@ +import {createMockShopLauncherPage, startMockShopPreviewSession} from './mock-shop.js' +import {describe, expect, test} from 'vitest' +import {readFile} from 'fs/promises' +import {fileURLToPath} from 'url' + +describe('createMockShopLauncherPage', () => { + test('creates an auto-submitting form that posts directly to the mock.shop storefront', () => { + const html = createMockShopLauncherPage({ + overridesContent: JSON.stringify({theme_changes: {}}), + targetUrl: 'https://demostore.mock.shop/?theme_preview', + }) + + expect(html).toContain('method="POST"') + expect(html).toContain('action="https://demostore.mock.shop/?theme_preview"') + expect(html).toContain('enctype="multipart/form-data"') + expect(html).toContain('name="overrides"') + expect(html).toContain('mock-shop-preview-form') + expect(html).toContain('.submit()') + }) +}) + +describe('startMockShopPreviewSession', () => { + test('writes a launcher page that posts directly to the mock.shop storefront', async () => { + const session = await startMockShopPreviewSession(JSON.stringify({theme_changes: {}})) + const launcherHtml = await readFile(fileURLToPath(session.launcherUrl), 'utf8') + + expect(session.launcherUrl.startsWith('file://')).toBe(true) + expect(session.targetUrl).toBe('https://demostore.mock.shop/?theme_preview') + expect(launcherHtml).toContain('action="https://demostore.mock.shop/?theme_preview"') + expect(launcherHtml).toContain('name="overrides"') + + await expect(session.completion).resolves.toBeUndefined() + }) + + test('supports overriding the storefront URL for local SFR testing', async () => { + const session = await startMockShopPreviewSession(JSON.stringify({theme_changes: {}}), { + storefrontUrl: 'http://localhost:3000', + }) + const launcherHtml = await readFile(fileURLToPath(session.launcherUrl), 'utf8') + + expect(session.targetUrl).toBe('http://localhost:3000/?theme_preview') + expect(launcherHtml).toContain('action="http://localhost:3000/?theme_preview"') + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-previews/mock-shop.ts b/packages/theme/src/cli/utilities/theme-previews/mock-shop.ts new file mode 100644 index 00000000000..baba14dc022 --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-previews/mock-shop.ts @@ -0,0 +1,93 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {tempDirectory, writeFile} from '@shopify/cli-kit/node/fs' + +import {join} from 'path' +import {pathToFileURL} from 'url' + +const DEFAULT_MOCK_SHOP_STOREFRONT_URL = 'https://demostore.mock.shop' +const MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 + +export interface MockShopPreviewSession { + launcherUrl: string + targetUrl: string + completion: Promise +} + +interface MockShopPreviewSessionOptions { + storefrontUrl?: string +} + +/** + * Writes a one-shot launcher page that posts theme overrides directly to the + * target storefront. This avoids a localhost proxy while still letting the + * browser perform a top-level POST navigation. + */ +export async function startMockShopPreviewSession( + overridesContent: string, + options: MockShopPreviewSessionOptions = {}, +): Promise { + const payloadBytes = Buffer.byteLength(overridesContent, 'utf8') + if (payloadBytes > MAX_PAYLOAD_BYTES) { + throw new AbortError(`Override payload exceeds the 2 MB mock.shop preview limit (${payloadBytes} bytes).`) + } + + const targetUrl = getMockShopThemePreviewUrl(options.storefrontUrl) + const launcherDirectory = tempDirectory() + const launcherPath = join(launcherDirectory, 'mock-shop-preview.html') + + await writeFile(launcherPath, createMockShopLauncherPage({overridesContent, targetUrl})) + + return { + launcherUrl: pathToFileURL(launcherPath).href, + targetUrl, + completion: Promise.resolve(), + } +} + +export function createMockShopLauncherPage({ + overridesContent, + targetUrl, +}: { + overridesContent: string + targetUrl: string +}) { + return ` + + + + + Opening mock.shop preview… + + +
+ + +
+ + +` +} + +function getMockShopThemePreviewUrl(storefrontUrl = DEFAULT_MOCK_SHOP_STOREFRONT_URL) { + return `${storefrontUrl.replace(/\/$/, '')}/?theme_preview` +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index c354592360c..8ef915edc41 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -13,6 +13,7 @@ import Profile from './cli/commands/theme/profile.js' import Publish from './cli/commands/theme/publish.js' import MetafieldsPull from './cli/commands/theme/metafields/pull.js' import Preview from './cli/commands/theme/preview.js' +import SandboxPreview from './cli/commands/theme/sandbox-preview.js' import Pull from './cli/commands/theme/pull.js' import Push from './cli/commands/theme/push.js' import Rename from './cli/commands/theme/rename.js' @@ -35,6 +36,7 @@ const COMMANDS = { 'theme:profile': Profile, 'theme:publish': Publish, 'theme:preview': Preview, + 'theme:sandbox-preview': SandboxPreview, 'theme:pull': Pull, 'theme:push': Push, 'theme:rename': Rename,