diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension-next.test.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension-next.test.ts new file mode 100644 index 0000000000..e2feff45e8 --- /dev/null +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension-next.test.ts @@ -0,0 +1,115 @@ +import {setupPreviewThemeAppExtensionsProcess, findOrCreateHostTheme} from './theme-app-extension-next.js' +import {HostThemeManager} from '../../../utilities/host-theme-manager.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' +import {DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js' +import {AdminSession, ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {fetchTheme} from '@shopify/cli-kit/node/themes/api' +import {AbortError} from '@shopify/cli-kit/node/error' +import {Theme} from '@shopify/cli-kit/node/themes/types' +import {vi, describe, test, expect, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('../../../utilities/host-theme-manager') +vi.mock('@shopify/cli-kit/node/output') + +describe('setupPreviewThemeAppExtensionsProcess', () => { + const mockAdminSession = {storeFqdn: 'test.myshopify.com'} as any as AdminSession + const mockDeveloperPlatformClient = {} as DeveloperPlatformClient + const mockThemeExtension = {isThemeExtension: true} as ExtensionInstance + + beforeEach(() => { + vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession) + }) + + test('Returns undefined if no theme extensions are present', async () => { + // Given + const allExtensions: ExtensionInstance[] = [] + const storeFqdn = 'test.myshopify.com' + const developerPlatformClient = mockDeveloperPlatformClient + + // When + const result = await setupPreviewThemeAppExtensionsProcess({ + allExtensions, + storeFqdn, + developerPlatformClient, + }) + + // Then + expect(result).toBeUndefined() + }) + + test('Returns PreviewThemeAppExtensionsOptions if theme extensions are present', async () => { + // Given + const mockTheme = {id: 123} as Theme + vi.mocked(fetchTheme).mockResolvedValue(mockTheme) + + const allExtensions = [mockThemeExtension] + const storeFqdn = 'test.myshopify.com' + const theme = '123' + const developerPlatformClient = mockDeveloperPlatformClient + + // When + const result = await setupPreviewThemeAppExtensionsProcess({ + allExtensions, + storeFqdn, + theme, + developerPlatformClient, + }) + + // Then + expect(result).toBeDefined() + expect(result?.options.themeId).toBe('123') + }) +}) + +describe('findOrCreateHostTheme', () => { + const mockAdminSession = {storeFqdn: 'test.myshopify.com'} as any as AdminSession + + test('Returns theme id if theme is provided and found', async () => { + // Given + const mockTheme = {id: 123} as Theme + vi.mocked(fetchTheme).mockResolvedValue(mockTheme) + const theme = '123' + + // When + const result = await findOrCreateHostTheme(mockAdminSession, theme) + + // Then + expect(result).toBe('123') + expect(HostThemeManager).not.toHaveBeenCalled() + }) + + test('Throws error if theme is provided and not found', async () => { + // Given + vi.mocked(fetchTheme).mockResolvedValue(undefined) + const theme = '123' + + // When + await expect(findOrCreateHostTheme(mockAdminSession, theme)).rejects.toThrow(AbortError) + + // Then + expect(HostThemeManager).not.toHaveBeenCalled() + }) + + test('Returns new theme id if theme is not provided', async () => { + // Given + const mockTheme = {id: 123} as Theme + vi.mocked(HostThemeManager.prototype.findOrCreate).mockResolvedValue(mockTheme) + + // When + const result = await findOrCreateHostTheme(mockAdminSession) + + // Then + expect(result).toBe('123') + }) + + test('Throws error if findOrCreateHostTheme fails', async () => { + // Given + vi.mocked(HostThemeManager.prototype.findOrCreate).mockRejectedValue(new Error('error')) + + // When + // Then + await expect(findOrCreateHostTheme(mockAdminSession)).rejects.toThrow(Error) + }) +}) diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension-next.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension-next.ts index 7d9156e7f7..d02b5e5758 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension-next.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension-next.ts @@ -1,49 +1,39 @@ import {BaseProcess, DevProcessFunction} from './types.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js' -import {outputInfo} from '@shopify/cli-kit/node/output' +import {HostThemeManager} from '../../../utilities/host-theme-manager.js' +import {outputDebug, outputInfo} from '@shopify/cli-kit/node/output' import {AdminSession, ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {fetchTheme} from '@shopify/cli-kit/node/themes/api' +import {AbortError} from '@shopify/cli-kit/node/error' +import {Theme} from '@shopify/cli-kit/node/themes/types' +import {renderInfo, renderTasks, Task} from '@shopify/cli-kit/node/ui' -interface PreviewThemeAppExtensionsOptions { +interface ThemeAppExtensionServerOptions { adminSession: AdminSession developerPlatformClient: DeveloperPlatformClient + themeId?: string + themeExtensionPort?: number +} + +interface HostThemeSetupOptions { + allExtensions: ExtensionInstance[] + storeFqdn: string theme?: string themeExtensionPort?: number + developerPlatformClient: DeveloperPlatformClient } -export interface PreviewThemeAppExtensionsProcess extends BaseProcess { +export interface PreviewThemeAppExtensionsProcess extends BaseProcess { type: 'theme-app-extensions' } -const runThemeAppExtensionsServerNext: DevProcessFunction = async ( - {stdout: _stdout, stderr: _stderr, abortSignal: _abortSignal}, - { - adminSession: _adminSession, - developerPlatformClient: _developerPlatformClient, - theme: _theme, - themeExtensionPort: _themeExtensionPort, - }, -) => { +export async function setupPreviewThemeAppExtensionsProcess( + options: HostThemeSetupOptions, +): Promise { outputInfo('This feature is currently in development and is not ready for use or testing yet.') - await findOrCreateHostTheme() - await initializeFSWatcher() - await startThemeAppExtensionDevelopmentServer() -} - -export async function setupPreviewThemeAppExtensionsProcess({ - allExtensions, - storeFqdn, - theme, - themeExtensionPort, - developerPlatformClient, -}: Pick & { - allExtensions: ExtensionInstance[] - storeFqdn: string - theme?: string - themeExtensionPort?: number -}): Promise { - outputInfo('This feature is currently in development and is not ready for use or testing yet.') + const {allExtensions, storeFqdn, theme, themeExtensionPort, developerPlatformClient} = options const themeExtensions = allExtensions.filter((ext) => ext.isThemeExtension) if (themeExtensions.length === 0) { @@ -52,6 +42,16 @@ export async function setupPreviewThemeAppExtensionsProcess({ const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + const themeId = await findOrCreateHostTheme(adminSession, theme) + + renderInfo({ + headline: {info: 'Setup your theme app extension in the host theme:'}, + link: { + label: `https://${adminSession.storeFqdn}/admin/themes/${themeId}/editor`, + url: `https://${adminSession.storeFqdn}/admin/themes/${themeId}/editor`, + }, + }) + return { type: 'theme-app-extensions', prefix: 'theme-extensions', @@ -59,13 +59,44 @@ export async function setupPreviewThemeAppExtensionsProcess({ options: { adminSession, developerPlatformClient, - theme, + themeId, themeExtensionPort, }, } } -async function findOrCreateHostTheme() {} +export async function findOrCreateHostTheme(adminSession: AdminSession, theme?: string): Promise { + let hostTheme: Theme | undefined + if (theme) { + outputDebug(`Fetching theme with provided id ${theme}`) + hostTheme = await fetchTheme(parseInt(theme, 10), adminSession) + } else { + const themeManager = new HostThemeManager(adminSession, {devPreview: true}) + const tasks: Task[] = [ + { + title: 'Configuring host theme for theme app extension', + task: async () => { + outputDebug('Finding or creating host theme for theme app extensions') + hostTheme = await themeManager.findOrCreate() + }, + }, + ] + await renderTasks(tasks) + } + + if (!hostTheme) { + throw new AbortError(`Could not find or create a host theme for theme app extensions`) + } + + return hostTheme.id.toString() +} +const runThemeAppExtensionsServerNext: DevProcessFunction = async ( + {stdout: _stdout, stderr: _stderr, abortSignal: _abortSignal}, + _PreviewThemeAppExtensionsOptions, +) => { + await initializeFSWatcher() + await startThemeAppExtensionDevelopmentServer() +} async function initializeFSWatcher() {} diff --git a/packages/app/src/cli/utilities/host-theme-manager.test.ts b/packages/app/src/cli/utilities/host-theme-manager.test.ts new file mode 100644 index 0000000000..492fb51d6e --- /dev/null +++ b/packages/app/src/cli/utilities/host-theme-manager.test.ts @@ -0,0 +1,183 @@ +import {DEFAULT_THEME_ZIP, FALLBACK_THEME_ZIP, HostThemeManager} from './host-theme-manager.js' +import {waitForThemeToBeProcessed} from './host-theme-watcher.js' +import {createTheme} from '@shopify/cli-kit/node/themes/api' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {ThemeManager} from '@shopify/cli-kit/node/themes/theme-manager' + +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('./host-theme-watcher.js') + +describe('HostThemeManager', () => { + let themeManager: HostThemeManager + const adminSession: AdminSession = {token: 'token', storeFqdn: 'storeFqdn'} + + beforeEach(() => { + themeManager = new HostThemeManager(adminSession, {devPreview: true}) + vi.spyOn(ThemeManager.prototype, 'generateThemeName').mockImplementation(() => 'App Ext. Host Name') + }) + + test('should call createTheme with the provided name and src param', async () => { + vi.mocked(createTheme).mockResolvedValue({ + id: 12345, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + + // When + await themeManager.findOrCreate() + + // Then + expect(createTheme).toHaveBeenCalledWith( + { + name: 'App Ext. Host Name', + role: DEVELOPMENT_THEME_ROLE, + src: DEFAULT_THEME_ZIP, + }, + {storeFqdn: 'storeFqdn', token: 'token'}, + ) + }) + + describe('dev preview', () => { + test('should call createTheme with the provided name and src param', async () => { + // Given + vi.mocked(createTheme).mockResolvedValue({ + id: 12345, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + + // When + await themeManager.findOrCreate() + + // Then + expect(createTheme).toHaveBeenCalledWith( + { + name: 'App Ext. Host Name', + role: DEVELOPMENT_THEME_ROLE, + src: DEFAULT_THEME_ZIP, + }, + {storeFqdn: 'storeFqdn', token: 'token'}, + ) + }) + + test('should wait for the theme to be processed', async () => { + // Given + vi.mocked(createTheme).mockResolvedValue({ + id: 12345, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + vi.mocked(waitForThemeToBeProcessed).mockResolvedValue() + + // When + await themeManager.findOrCreate() + + // Then + expect(waitForThemeToBeProcessed).toHaveBeenCalledTimes(1) + }) + + test('should retry creating the theme if the first attempt fails', async () => { + // Given + vi.mocked(createTheme).mockResolvedValueOnce(undefined).mockResolvedValueOnce({ + id: 12345, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + + // When + await themeManager.findOrCreate() + + // Then + expect(createTheme).toHaveBeenCalledTimes(2) + expect(createTheme).toHaveBeenNthCalledWith( + 1, + { + role: DEVELOPMENT_THEME_ROLE, + name: 'App Ext. Host Name', + src: DEFAULT_THEME_ZIP, + }, + adminSession, + ) + expect(createTheme).toHaveBeenNthCalledWith( + 2, + { + role: DEVELOPMENT_THEME_ROLE, + name: 'App Ext. Host Name', + src: DEFAULT_THEME_ZIP, + }, + adminSession, + ) + }) + + test('should gracefully handle a 422 from the server during theme creation', async () => { + // Given + vi.mocked(createTheme) + .mockRejectedValueOnce(new Error('API request unprocessable content: {"src":["is empty"]}')) + .mockRejectedValueOnce(new Error('API request unprocessable content: {"src":["is empty"]}')) + .mockResolvedValueOnce({ + id: 12345, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + + // When + await themeManager.findOrCreate() + + // Then + expect(createTheme).toHaveBeenCalledTimes(3) + }) + + test('should retry creating the theme with the Fallback theme zip after 3 failed retry attempts', async () => { + // Given + vi.mocked(createTheme) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ + id: 12345, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + + // When + await themeManager.findOrCreate() + + // Then + expect(createTheme).toHaveBeenCalledTimes(4) + expect(createTheme).toHaveBeenLastCalledWith( + { + role: DEVELOPMENT_THEME_ROLE, + name: 'App Ext. Host Name', + src: FALLBACK_THEME_ZIP, + }, + adminSession, + ) + }) + + test('should throw a BugError if the theme cannot be created', async () => { + // Given + vi.mocked(createTheme).mockResolvedValue(undefined) + + // When + // Then + await expect(themeManager.findOrCreate()).rejects.toThrow( + 'Could not create theme with name "App Ext. Host Name" and role "development"', + ) + expect(createTheme).toHaveBeenCalledTimes(4) + }) + }) +}) diff --git a/packages/app/src/cli/utilities/host-theme-manager.ts b/packages/app/src/cli/utilities/host-theme-manager.ts index 02544eec11..9c55f907bd 100644 --- a/packages/app/src/cli/utilities/host-theme-manager.ts +++ b/packages/app/src/cli/utilities/host-theme-manager.ts @@ -1,13 +1,33 @@ +import {waitForThemeToBeProcessed} from './host-theme-watcher.js' import {getHostTheme, removeHostTheme, setHostTheme} from '@shopify/cli-kit/node/themes/conf' import {ThemeManager} from '@shopify/cli-kit/node/themes/theme-manager' import {AdminSession} from '@shopify/cli-kit/node/session' +import {Theme} from '@shopify/cli-kit/node/themes/types' +import {createTheme} from '@shopify/cli-kit/node/themes/api' +import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' +import {BugError} from '@shopify/cli-kit/node/error' +import {outputDebug} from '@shopify/cli-kit/node/output' + +export const DEFAULT_THEME_ZIP = 'https://codeload.github.com/Shopify/dawn/zip/refs/tags/v15.0.0' +export const FALLBACK_THEME_ZIP = 'https://cdn.shopify.com/theme-store/uhrdefhlndzaoyrgylhto59sx2i7.jpg' +const retryAttemps = 3 export class HostThemeManager extends ThemeManager { protected context = 'App Ext. Host' + protected devPreview: boolean - constructor(adminSession: AdminSession) { + constructor(adminSession: AdminSession, config = {devPreview: false}) { super(adminSession) this.themeId = getHostTheme(adminSession.storeFqdn) + this.devPreview = config.devPreview + } + + async findOrCreate(): Promise { + let theme = await this.fetch() + if (!theme) { + theme = this.devPreview ? await this.createHostTheme() : await this.create() + } + return theme } protected setTheme(themeId: string): void { @@ -17,4 +37,43 @@ export class HostThemeManager extends ThemeManager { protected removeTheme(): void { removeHostTheme(this.adminSession.storeFqdn) } + + private async createHostTheme(): Promise { + const options = { + role: DEVELOPMENT_THEME_ROLE, + name: this.generateThemeName(this.context), + src: DEFAULT_THEME_ZIP, + } + + for (let attempt = 0; attempt < retryAttemps; attempt++) { + outputDebug( + `Attempt ${attempt}/${retryAttemps}: Creating theme with name "${options.name}" and role "${options.role}"`, + ) + + try { + // eslint-disable-next-line no-await-in-loop + const theme = await createTheme(options, this.adminSession) + if (theme) { + this.setTheme(theme.id.toString()) + outputDebug(`Waiting for theme with id "${theme.id}" to be processed`) + // eslint-disable-next-line no-await-in-loop + await waitForThemeToBeProcessed(theme.id, this.adminSession) + return theme + } else { + throw new Error() + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Failed to create theme with name "${options.name}" and role "${options.role}". Retrying...`) + } + } + + outputDebug(`Theme creation failed after ${retryAttemps} retries. Creating theme using fallback theme zip`) + const theme = await createTheme({...options, src: FALLBACK_THEME_ZIP}, this.adminSession) + if (!theme) { + outputDebug(`Theme creation failed. Exiting process.`) + throw new BugError(`Could not create theme with name "${options.name}" and role "${options.role}"`) + } + return theme + } } diff --git a/packages/app/src/cli/utilities/host-theme-watcher.test.ts b/packages/app/src/cli/utilities/host-theme-watcher.test.ts new file mode 100644 index 0000000000..107784d2c0 --- /dev/null +++ b/packages/app/src/cli/utilities/host-theme-watcher.test.ts @@ -0,0 +1,67 @@ +import {FAILED_TO_CREATE_THEME_MESSAGE, UPDATER_TIMEOUT, waitForThemeToBeProcessed} from './host-theme-watcher.js' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {sleep} from '@shopify/cli-kit/node/system' +import {fetchTheme} from '@shopify/cli-kit/node/themes/api' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('@shopify/cli-kit/node/system') + +describe('HostThemeWatcher', () => { + const themeId = 12345 + const adminSession: AdminSession = {token: 'token', storeFqdn: 'storeFqdn'} + + test('should wait for the theme to be processed', async () => { + // Given + vi.mocked(sleep).mockResolvedValue() + vi.mocked(fetchTheme) + .mockResolvedValueOnce({ + processing: true, + id: themeId, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + }) + .mockResolvedValue({ + processing: false, + id: themeId, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + }) + + // When + await waitForThemeToBeProcessed(themeId, adminSession, Date.now()) + + // Then + expect(fetchTheme).toHaveBeenCalledTimes(2) + }) + + test('should throw an error if the theme is not processed within the timeout', async () => { + // Given + vi.mocked(fetchTheme).mockResolvedValue({ + processing: true, + id: themeId, + name: 'Theme', + role: 'development', + createdAtRuntime: true, + }) + + // When + const promise = waitForThemeToBeProcessed(themeId, adminSession, Date.now() - UPDATER_TIMEOUT) + + // Then + await expect(promise).rejects.toThrowError(FAILED_TO_CREATE_THEME_MESSAGE) + }) + + test('should throw an error if the theme is not found', async () => { + // Given + vi.mocked(fetchTheme).mockResolvedValue(undefined) + + // When + const promise = waitForThemeToBeProcessed(themeId, adminSession, Date.now()) + + // Then + await expect(promise).rejects.toThrowError(FAILED_TO_CREATE_THEME_MESSAGE) + }) +}) diff --git a/packages/app/src/cli/utilities/host-theme-watcher.ts b/packages/app/src/cli/utilities/host-theme-watcher.ts new file mode 100644 index 0000000000..54747b5370 --- /dev/null +++ b/packages/app/src/cli/utilities/host-theme-watcher.ts @@ -0,0 +1,32 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {sleep} from '@shopify/cli-kit/node/system' +import {fetchTheme} from '@shopify/cli-kit/node/themes/api' +import {Theme} from '@shopify/cli-kit/node/themes/types' + +// 5 minutes +export const UPDATER_TIMEOUT = 5 * 60 * 1000 +export const FAILED_TO_CREATE_THEME_MESSAGE = + 'The host theme could not be created to host your theme app extension. Please try again or use the "--theme" flag to use an existing theme as the host theme.' + +export async function waitForThemeToBeProcessed(themeId: number, adminSession: AdminSession, startTime = Date.now()) { + // Each iteration must wait for the response before the next poll is initiated. + // eslint-disable-next-line no-await-in-loop, no-empty + while (await themeIsProcessing(themeId, adminSession, startTime)) {} +} + +async function themeIsProcessing(themeId: number, session: AdminSession, startTime: number) { + const theme = await fetchTheme(themeId, session) + + if (!theme || themeProcessingTimedOut(theme, startTime)) { + throw new AbortError(FAILED_TO_CREATE_THEME_MESSAGE) + } + + // Sleep for 3 seconds before polling again + await sleep(3) + + return theme.processing +} +function themeProcessingTimedOut(theme: Theme, startTime: number): boolean { + return theme.processing && Date.now() - startTime >= UPDATER_TIMEOUT +} diff --git a/packages/cli-kit/src/public/node/themes/api.ts b/packages/cli-kit/src/public/node/themes/api.ts index 2f5f40ec77..74ea499fc2 100644 --- a/packages/cli-kit/src/public/node/themes/api.ts +++ b/packages/cli-kit/src/public/node/themes/api.ts @@ -11,7 +11,7 @@ import { } from '@shopify/cli-kit/node/themes/factories' import {Result, Checksum, Key, Theme, ThemeAsset} from '@shopify/cli-kit/node/themes/types' -export type ThemeParams = Partial> +export type ThemeParams = Partial> export type AssetParams = Pick & Partial> export async function fetchTheme(id: number, session: AdminSession): Promise { diff --git a/packages/cli-kit/src/public/node/themes/theme-manager.ts b/packages/cli-kit/src/public/node/themes/theme-manager.ts index 01672c42f2..564575b7b5 100644 --- a/packages/cli-kit/src/public/node/themes/theme-manager.ts +++ b/packages/cli-kit/src/public/node/themes/theme-manager.ts @@ -32,6 +32,10 @@ export abstract class ThemeManager { return theme } + generateThemeName(context: string) { + return generateThemeName(context) + } + async create(themeRole?: Role, themeName?: string) { const name = themeName || generateThemeName(this.context) const role = themeRole || DEVELOPMENT_THEME_ROLE diff --git a/packages/cli-kit/src/public/node/themes/types.ts b/packages/cli-kit/src/public/node/themes/types.ts index 60b2010862..1f913f34a0 100644 --- a/packages/cli-kit/src/public/node/themes/types.ts +++ b/packages/cli-kit/src/public/node/themes/types.ts @@ -69,6 +69,11 @@ export interface Theme { * The remote role of the theme. */ role: string + + /** + * A public URL where Shopify can access the theme code. + */ + src?: string } /**