diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 616e8563d78..02d2b05fd03 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -8,11 +8,12 @@ import { listCurrentStoredStoreAppSessions, } from '@shopify/cli-kit/node/store-auth-session' import {loadEnvironment} from '@shopify/cli-kit/node/environments' -import {fileExistsSync} from '@shopify/cli-kit/node/fs' -import {resolvePath} from '@shopify/cli-kit/node/path' +import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs' +import {resolvePath, joinPath, cwd} from '@shopify/cli-kit/node/path' import {renderConcurrent, renderConfirmationPrompt, renderError, renderWarning} from '@shopify/cli-kit/node/ui' import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' import {hashString} from '@shopify/cli-kit/node/crypto' +import * as pathUtils from '@shopify/cli-kit/node/path' import type {Writable} from 'stream' @@ -25,7 +26,6 @@ vi.mock('@shopify/cli-kit/node/metadata', () => ({ addSensitiveMetadata: vi.fn(), })) vi.mock('./theme-store.js') -vi.mock('@shopify/cli-kit/node/fs') const CommandConfig = new Config({root: __dirname}) @@ -44,7 +44,7 @@ class TestThemeCommand extends ThemeCommand { }), path: Flags.string({ env: 'SHOPIFY_FLAG_PATH', - default: 'current/working/directory', + default: () => Promise.resolve(cwd()), }), 'no-color': Flags.boolean({ env: 'SHOPIFY_FLAG_NO_COLOR', @@ -122,6 +122,10 @@ class TestUnauthenticatedThemeCommand extends ThemeCommand { store: Flags.string({ env: 'SHOPIFY_FLAG_STORE', }), + path: Flags.string({ + env: 'SHOPIFY_FLAG_PATH', + default: () => Promise.resolve(cwd()), + }), } static multiEnvironmentsFlags: RequiredFlags = ['store'] @@ -152,7 +156,7 @@ class TestThemeCommandWithoutStoreRequired extends ThemeCommand { }), path: Flags.string({ env: 'SHOPIFY_FLAG_PATH', - default: 'current/working/directory', + default: () => Promise.resolve(cwd()), }), store: Flags.string({ env: 'SHOPIFY_FLAG_STORE', @@ -180,6 +184,13 @@ class TestThemeCommandWithoutStoreRequired extends ThemeCommand { } } +async function withThemeEnvironment(callback: (tmpDir: string) => Promise) { + await inTemporaryDirectory(async (tmpDir) => { + vi.spyOn(pathUtils, 'cwd').mockReturnValue(tmpDir) + await callback(tmpDir) + }) +} + describe('ThemeCommand', () => { let mockSession: AdminSession @@ -192,923 +203,1030 @@ describe('ThemeCommand', () => { vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([]) - vi.mocked(fileExistsSync).mockReturnValue(true) }) describe('run', () => { test('no environment provided', async () => { - // Given - await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) - // When - await command.run() - - // Then - expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() - expect(loadEnvironment).not.toHaveBeenCalled() - expect(renderConcurrent).not.toHaveBeenCalled() - expect(command.commandCalls).toHaveLength(1) - expect(command.commandCalls[0]).toMatchObject({ - flags: {environment: []}, - session: mockSession, - multiEnvironment: false, - args: {}, - context: undefined, + await withThemeEnvironment(async () => { + // Given + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + // When + await command.run() + + // Then + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + expect(loadEnvironment).not.toHaveBeenCalled() + expect(renderConcurrent).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: {environment: []}, + session: mockSession, + multiEnvironment: false, + args: {}, + context: undefined, + }) }) }) test('single environment provided', async () => { - // Given - const environmentConfig = {store: 'env-store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - - await CommandConfig.load() - const command = new TestThemeCommand(['--environment', 'development'], CommandConfig) - - // When - await command.run() - - // Then - expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', { - from: 'current/working/directory', - }) - expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(1) - expect(renderConcurrent).not.toHaveBeenCalled() - expect(command.commandCalls).toHaveLength(1) - expect(command.commandCalls[0]).toMatchObject({ - flags: { - environment: ['development'], - store: 'env-store.myshopify.com', - }, - session: mockSession, - multiEnvironment: false, - args: {}, - context: undefined, + await withThemeEnvironment(async (tmpDir) => { + // Given + const environmentConfig = {store: 'env-store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', { + from: tmpDir, + }) + expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(1) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: { + environment: ['development'], + store: 'env-store.myshopify.com', + }, + session: mockSession, + multiEnvironment: false, + args: {}, + context: undefined, + }) + const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(publicMetadata).toContainEqual( + expect.objectContaining({ + store_fqdn_hash: hashString(mockSession.storeFqdn), + store_domain: mockSession.storeFqdn, + }), + ) + const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(sensitiveMetadata).toContainEqual({store_fqdn: mockSession.storeFqdn}) }) - const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) - expect(publicMetadata).toContainEqual( - expect.objectContaining({ - store_fqdn_hash: hashString(mockSession.storeFqdn), - store_domain: mockSession.storeFqdn, - }), - ) - const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) - expect(sensitiveMetadata).toContainEqual({store_fqdn: mockSession.storeFqdn}) }) test('uses a matching store auth cache session when no password is provided', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ - store: 'test-store.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: [], - acquiredAt: '2026-06-08T11:00:00.000Z', - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) - await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) - await command.run() + await command.run() - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(command.commandCalls[0]).toMatchObject({ - session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) }) }) test('uses the password flag instead of a matching store auth cache session', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ - store: 'test-store.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: [], - acquiredAt: '2026-06-08T11:00:00.000Z', - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) - await CommandConfig.load() - const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig) + await CommandConfig.load() + const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig) - await command.run() + await command.run() - expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() - expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', 'shptka_password') - expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', 'shptka_password') + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) }) test('falls back to theme authentication when no matching store auth cache session exists', async () => { - await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) + await withThemeEnvironment(async () => { + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) - await command.run() + await command.run() - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') - expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) - expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) }) test('checks required scopes from the stored session before using a matching store auth cache session', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ - store: 'test-store.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: ['read_themes'], - acquiredAt: '2026-06-08T11:00:00.000Z', - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['read_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) - await CommandConfig.load() - const command = new TestScopedThemeCommand([], CommandConfig) + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) - await command.run() + await command.run() - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(command.commandCalls[0]).toMatchObject({ - session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) }) }) test('treats a matching write scope in the stored session as satisfying a required read scope', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ - store: 'test-store.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: ['write_themes'], - acquiredAt: '2026-06-08T11:00:00.000Z', - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['write_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) - await CommandConfig.load() - const command = new TestScopedThemeCommand([], CommandConfig) + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) - await command.run() + await command.run() - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(command.commandCalls[0]).toMatchObject({ - session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) }) }) test('uses a matching store auth cache session when stored scopes are empty', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ - store: 'test-store.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: [], - acquiredAt: '2026-06-08T11:00:00.000Z', - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) - await CommandConfig.load() - const command = new TestScopedThemeCommand([], CommandConfig) + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) - await command.run() + await command.run() - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(command.commandCalls[0]).toMatchObject({ - session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) }) }) test('falls back to theme authentication when matching store auth session lacks required scopes', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ - store: 'test-store.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: ['read_products'], - acquiredAt: '2026-06-08T11:00:00.000Z', - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['read_products'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) - await CommandConfig.load() - const command = new TestScopedThemeCommand([], CommandConfig) + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) - await command.run() + await command.run() - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') - expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) - expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) }) test('does not check stored store auth cache session expiry', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ - store: 'test-store.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: ['read_themes'], - acquiredAt: '2026-06-08T11:00:00.000Z', - expiresAt: '2026-06-08T11:30:00.000Z', - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['read_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + expiresAt: '2026-06-08T11:30:00.000Z', + }) - await CommandConfig.load() - const command = new TestScopedThemeCommand([], CommandConfig) + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) - await command.run() + await command.run() - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(command.commandCalls[0]).toMatchObject({ - session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) }) }) test('rethrows unexpected store auth cache errors', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockImplementationOnce(() => { - throw new Error('cache read failed') - }) + await withThemeEnvironment(async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockImplementationOnce(() => { + throw new Error('cache read failed') + }) - await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) - await expect(command.run()).rejects.toThrow('cache read failed') - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + await expect(command.run()).rejects.toThrow('cache read failed') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) }) test('single environment provided but not found in TOML - throws AbortError', async () => { - // Given - vi.mocked(loadEnvironment).mockResolvedValue(undefined) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment).mockResolvedValue(undefined) - await CommandConfig.load() - const command = new TestThemeCommand(['--environment', 'notreal'], CommandConfig) + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'notreal'], CommandConfig) - // When/Then - await expect(command.run()).rejects.toThrow('Please provide a valid environment.') + // When/Then + await expect(command.run()).rejects.toThrow('Please provide a valid environment.') + }) }) test('single environment provided without store - does not throw when store is not required', async () => { - // Given - const environmentConfig = {path: '/some/path'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - - await CommandConfig.load() - const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) - - // When - await command.run() - - // Then - expect(command.commandCalls).toHaveLength(1) - expect(command.commandCalls[0]).toMatchObject({ - flags: { - environment: ['development'], - path: '/some/path', - }, - session: undefined, - multiEnvironment: false, + await withThemeEnvironment(async (tmpDir) => { + // Given + const environmentConfig = {path: joinPath(tmpDir, 'some/path')} + await mkdir(environmentConfig.path) + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: { + environment: ['development'], + path: environmentConfig.path, + }, + session: undefined, + multiEnvironment: false, + }) + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() }) - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() }) test('single environment provided with store - does not create session when command does not require auth', async () => { - // Given - const environmentConfig = {path: '/some/path', store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - - await CommandConfig.load() - const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) - - // When - await command.run() - - // Then - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(command.commandCalls).toHaveLength(1) - expect(command.commandCalls[0]?.session).toBeUndefined() + await withThemeEnvironment(async (tmpDir) => { + // Given + const environmentConfig = {path: joinPath(tmpDir, 'some/path'), store: 'store.myshopify.com'} + await mkdir(environmentConfig.path) + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]?.session).toBeUndefined() + }) }) test('multiple environments provided - uses renderConcurrent for parallel execution', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) - vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) - - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig) - - // When - await command.run() - - // Then - expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', { - from: 'current/working/directory', - silent: true, - }) - expect(loadEnvironment).toHaveBeenCalledWith('staging', 'shopify.theme.toml', { - from: 'current/working/directory', - silent: true, + await withThemeEnvironment(async (tmpDir) => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) + + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', { + from: tmpDir, + silent: true, + }) + expect(loadEnvironment).toHaveBeenCalledWith('staging', 'shopify.theme.toml', { + from: tmpDir, + silent: true, + }) + + expect(renderConcurrent).toHaveBeenCalledOnce() + expect(renderConcurrent).toHaveBeenCalledWith( + expect.objectContaining({ + processes: expect.arrayContaining([ + expect.objectContaining({prefix: 'development'}), + expect.objectContaining({prefix: 'staging'}), + ]), + showTimestamps: true, + }), + ) }) - - expect(renderConcurrent).toHaveBeenCalledOnce() - expect(renderConcurrent).toHaveBeenCalledWith( - expect.objectContaining({ - processes: expect.arrayContaining([ - expect.objectContaining({prefix: 'development'}), - expect.objectContaining({prefix: 'staging'}), - ]), - showTimestamps: true, - }), - ) }) test('multiple environments provided - logs metadata for each authenticated session', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({ - token: 'test-token', - storeFqdn: store, - })) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({ + token: 'test-token', + storeFqdn: store, + })) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(publicMetadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + store_fqdn_hash: hashString('store1.myshopify.com'), + store_domain: 'store1.myshopify.com', + }), + expect.objectContaining({ + store_fqdn_hash: hashString('store2.myshopify.com'), + store_domain: 'store2.myshopify.com', + }), + ]), + ) + const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(sensitiveMetadata).toEqual( + expect.arrayContaining([{store_fqdn: 'store1.myshopify.com'}, {store_fqdn: 'store2.myshopify.com'}]), + ) }) - - await CommandConfig.load() - const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig) - - // When - await command.run() - - // Then - const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) - expect(publicMetadata).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - store_fqdn_hash: hashString('store1.myshopify.com'), - store_domain: 'store1.myshopify.com', - }), - expect.objectContaining({ - store_fqdn_hash: hashString('store2.myshopify.com'), - store_domain: 'store2.myshopify.com', - }), - ]), - ) - const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) - expect(sensitiveMetadata).toEqual( - expect.arrayContaining([{store_fqdn: 'store1.myshopify.com'}, {store_fqdn: 'store2.myshopify.com'}]), - ) }) test("throws an AbortError if the path doesn't exist", async () => { - await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) + await withThemeEnvironment(async (tmpDir) => { + await CommandConfig.load() + const nonExistentPath = joinPath(tmpDir, 'non-existent') + vi.spyOn(pathUtils, 'cwd').mockReturnValue(nonExistentPath) + const command = new TestThemeCommand([], CommandConfig) - vi.mocked(fileExistsSync).mockReturnValue(false) - - await expect(command.run()).rejects.toThrow('Path does not exist: current/working/directory') - expect(fileExistsSync).toHaveBeenCalledWith('current/working/directory') + await expect(command.run()).rejects.toThrow(`Path does not exist: ${nonExistentPath}`) + }) }) test('multiple environments provided - displays warning if not allowed', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) - - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestNoMultiEnvThemeCommand( - ['--environment', 'development', '--environment', 'staging'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderWarning).toHaveBeenCalledWith( - expect.objectContaining({ - body: 'This command does not support multiple environments.', - }), - ) + await withThemeEnvironment(async () => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) + + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestNoMultiEnvThemeCommand( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderWarning).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'This command does not support multiple environments.', + }), + ) + }) }) }) describe('multi environment', () => { test('commands that act on the same store are run in groups to prevent conflicts', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'wow a theme'}) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'another theme'}) - - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithUnionFlags( - ['--environment', 'store1-theme', '--environment', 'store1-development', '--environment', 'store2-theme'], - CommandConfig, - ) - - // When - await command.run() - - // Then - const runGroupOneProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes - expect(runGroupOneProcesses).toHaveLength(2) - expect(runGroupOneProcesses?.map((process) => process.prefix)).toEqual(['store1-theme', 'store2-theme']) - - const runGroupTwoProcesses = vi.mocked(renderConcurrent).mock.calls[1]?.[0]?.processes - expect(runGroupTwoProcesses).toHaveLength(1) - expect(runGroupTwoProcesses?.map((process) => process.prefix)).toEqual(['store1-development']) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'wow a theme'}) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'another theme'}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithUnionFlags( + ['--environment', 'store1-theme', '--environment', 'store1-development', '--environment', 'store2-theme'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const runGroupOneProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(runGroupOneProcesses).toHaveLength(2) + expect(runGroupOneProcesses?.map((process) => process.prefix)).toEqual(['store1-theme', 'store2-theme']) + + const runGroupTwoProcesses = vi.mocked(renderConcurrent).mock.calls[1]?.[0]?.processes + expect(runGroupTwoProcesses).toHaveLength(1) + expect(runGroupTwoProcesses?.map((process) => process.prefix)).toEqual(['store1-development']) + }) }) test('commands with --force flag should not prompt for confirmation', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithForce( - ['--environment', 'development', '--environment', 'staging', '--force'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderConfirmationPrompt).not.toHaveBeenCalled() - expect(renderConcurrent).toHaveBeenCalledOnce() - expect(renderConcurrent).toHaveBeenCalledWith( - expect.objectContaining({ - processes: expect.arrayContaining([ - expect.objectContaining({prefix: 'development'}), - expect.objectContaining({prefix: 'staging'}), - ]), - showTimestamps: true, - }), - ) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithForce( + ['--environment', 'development', '--environment', 'staging', '--force'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(renderConcurrent).toHaveBeenCalledOnce() + expect(renderConcurrent).toHaveBeenCalledWith( + expect.objectContaining({ + processes: expect.arrayContaining([ + expect.objectContaining({prefix: 'development'}), + expect.objectContaining({prefix: 'staging'}), + ]), + showTimestamps: true, + }), + ) + }) }) test('commands that do not allow --force flag should not prompt for confirmation', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig) - - // When - await command.run() - - // Then - expect(renderConfirmationPrompt).not.toHaveBeenCalled() - expect(renderConcurrent).toHaveBeenCalledOnce() + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(renderConcurrent).toHaveBeenCalledOnce() + }) }) test('commands without --force flag that allow it should prompt for confirmation', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithForce( - ['--environment', 'development', '--environment', 'staging'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderConfirmationPrompt).toHaveBeenCalledOnce() - expect(renderConfirmationPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.any(Array), - confirmationMessage: 'Yes, proceed', - cancellationMessage: 'Cancel', - }), - ) - expect(renderConcurrent).toHaveBeenCalledOnce() + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithForce( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).toHaveBeenCalledOnce() + expect(renderConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.any(Array), + confirmationMessage: 'Yes, proceed', + cancellationMessage: 'Cancel', + }), + ) + expect(renderConcurrent).toHaveBeenCalledOnce() + }) }) test('confirmation prompts should display correctly formatted flag values', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', password: 'password1', path: '/home/path/to/theme1'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) - - await CommandConfig.load() - const command = new TestThemeCommandWithPathFlag( - ['--environment', 'development', '--environment', 'staging'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderConfirmationPrompt).toHaveBeenCalledOnce() - expect(renderConfirmationPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - message: ['Run testthemecommandwithpathflag in the following environments?'], - infoTable: { - Environment: [ - ['development', {subdued: 'store: store1.myshopify.com, password, path: /home/.../theme1'}], - ['staging', {subdued: 'store: store2.myshopify.com, password, path: /home/.../theme2'}], - ], - }, - confirmationMessage: 'Yes, proceed', - cancellationMessage: 'Cancel', - }), - ) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', password: 'password1', path: '/home/path/to/theme1'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) + + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).toHaveBeenCalledOnce() + expect(renderConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: ['Run testthemecommandwithpathflag in the following environments?'], + infoTable: { + Environment: [ + ['development', {subdued: 'store: store1.myshopify.com, password, path: /home/.../theme1'}], + ['staging', {subdued: 'store: store2.myshopify.com, password, path: /home/.../theme2'}], + ], + }, + confirmationMessage: 'Yes, proceed', + cancellationMessage: 'Cancel', + }), + ) + }) }) test('should not execute command if confirmation is cancelled', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithForce( - ['--environment', 'development', '--environment', 'staging'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderConfirmationPrompt).toHaveBeenCalledOnce() - expect(renderConcurrent).not.toHaveBeenCalled() + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithForce( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).toHaveBeenCalledOnce() + expect(renderConcurrent).not.toHaveBeenCalled() + }) }) test('should execute commands in environments with all required flags', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) - - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithUnionFlags( - ['--environment', 'theme', '--environment', 'development', '--environment', 'live'], - CommandConfig, - ) - - // When - await command.run() - - // Then - const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes - expect(renderConcurrentProcesses).toHaveLength(3) - expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['theme', 'development', 'live']) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithUnionFlags( + ['--environment', 'theme', '--environment', 'development', '--environment', 'live'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(3) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['theme', 'development', 'live']) + }) }) test('should not execute commands in environments that are missing required flags', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({store: 'store3.myshopify.com'}) - - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'development', '--environment', 'env-missing-store', '--environment', 'production'], - CommandConfig, - ) - - // When - await command.run() - - // Then - const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes - expect(renderConcurrentProcesses).toHaveLength(2) - expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['development', 'production']) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({store: 'store3.myshopify.com'}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'development', '--environment', 'env-missing-store', '--environment', 'production'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(2) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['development', 'production']) + }) }) test('should not execute commands in environments that are missing required flags even if they have a default value', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/a/path'}) - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({store: 'store3.myshopify.com'}) - - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithPath( - ['--environment', 'development', '--environment', 'env-missing-store', '--environment', 'path-defaults-to-cwd'], - CommandConfig, - ) - - // When - await command.run() - - // Then - const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes - expect(renderConcurrentProcesses).toHaveLength(1) - expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['development']) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/a/path'}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({store: 'store3.myshopify.com'}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithPath( + [ + '--environment', + 'development', + '--environment', + 'env-missing-store', + '--environment', + 'path-defaults-to-cwd', + ], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(1) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['development']) + }) }) test('should not execute commands in environments that are missing required "one of" flags', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) - - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithUnionFlags( - ['--environment', 'theme', '--environment', 'missing-theme-live-or-development', '--environment', 'live'], - CommandConfig, - ) - - // When - await command.run() - - // Then - const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes - expect(renderConcurrentProcesses).toHaveLength(2) - expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['theme', 'live']) + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithUnionFlags( + ['--environment', 'theme', '--environment', 'missing-theme-live-or-development', '--environment', 'live'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(2) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['theme', 'live']) + }) }) test('commands error gracefully and continue with other environments', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) - .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development', '--environment', 'production'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(3) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual([ + 'command-error', + 'development', + 'production', + ]) }) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development', '--environment', 'production'], - CommandConfig, - ) - - // When - await command.run() - - // Then - const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes - expect(renderConcurrentProcesses).toHaveLength(3) - expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual([ - 'command-error', - 'development', - 'production', - ]) }) test('error messages contain the environment name', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } + await withThemeEnvironment(async () => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + body: ['Environment command-error failed: \n\nMocking a command error'], + }), + ) }) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: ['Environment command-error failed: \n\nMocking a command error'], - }), - ) }) test('commands should display an error if the --path flag is used', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - "Can't use `--path` flag with multiple environments.", - "Configure each environment's theme path in your shopify.theme.toml file instead.", - ], - }), - ) + await withThemeEnvironment(async (tmpDir) => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + await writeFile(joinPath(tmpDir, 'shopify.theme.toml'), '') + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + body: [ + "Can't use `--path` flag with multiple environments.", + "Configure each environment's theme path in your shopify.theme.toml file instead.", + ], + }), + ) + }) }) test('commands should display an error if the --path flag is used and no shopify.theme.toml is found', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(fileExistsSync).mockReturnValue(false) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - "Can't use `--path` flag with multiple environments.", - 'Run this command from the directory containing shopify.theme.toml.', - 'No shopify.theme.toml found in current directory.', - ], - }), - ) + await withThemeEnvironment(async () => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + body: [ + "Can't use `--path` flag with multiple environments.", + 'Run this command from the directory containing shopify.theme.toml.', + 'No shopify.theme.toml found in current directory.', + ], + }), + ) + }) }) test('CLI and shopify.theme.toml flag values take precedence over defaults', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com', path: 'theme/path'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', development: true, path: 'development/path'}) - .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true, 'no-color': false}) - - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } + await withThemeEnvironment(async (tmpDir) => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({ + store: 'store1.myshopify.com', + theme: 'theme1.myshopify.com', + path: joinPath(tmpDir, 'theme/path'), + }) + .mockResolvedValueOnce({ + store: 'store2.myshopify.com', + development: true, + path: joinPath(tmpDir, 'development/path'), + }) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true, 'no-color': false}) + + await mkdir(joinPath(tmpDir, 'theme/path')) + await mkdir(joinPath(tmpDir, 'development/path')) + + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'theme', '--environment', 'development', '--environment', 'live', '--no-color'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const commandCalls = command.commandCalls + expect(commandCalls).toHaveLength(3) + + const themeEnvFlags = commandCalls[0]?.flags + expect(themeEnvFlags?.path).toEqual(resolvePath(joinPath(tmpDir, 'theme/path'))) + expect(themeEnvFlags?.store).toEqual('store1.myshopify.com') + expect(themeEnvFlags?.theme).toEqual('theme1.myshopify.com') + expect(themeEnvFlags?.['no-color']).toEqual(true) + + const developmentEnvFlags = commandCalls[1]?.flags + expect(developmentEnvFlags?.path).toEqual(resolvePath(joinPath(tmpDir, 'development/path'))) + expect(developmentEnvFlags?.store).toEqual('store2.myshopify.com') + expect(developmentEnvFlags?.development).toEqual(true) + expect(developmentEnvFlags?.['no-color']).toEqual(true) + + const liveEnvFlags = commandCalls[2]?.flags + expect(liveEnvFlags?.path).toEqual(tmpDir) + expect(liveEnvFlags?.store).toEqual('store3.myshopify.com') + expect(liveEnvFlags?.live).toEqual(true) + expect(liveEnvFlags?.['no-color']).toEqual(true) }) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'theme', '--environment', 'development', '--environment', 'live', '--no-color'], - CommandConfig, - ) - - // When - await command.run() - - // Then - const commandCalls = command.commandCalls - expect(commandCalls).toHaveLength(3) - - const themeEnvFlags = commandCalls[0]?.flags - expect(themeEnvFlags?.path).toEqual(resolvePath('theme/path')) - expect(themeEnvFlags?.store).toEqual('store1.myshopify.com') - expect(themeEnvFlags?.theme).toEqual('theme1.myshopify.com') - expect(themeEnvFlags?.['no-color']).toEqual(true) - - const developmentEnvFlags = commandCalls[1]?.flags - expect(developmentEnvFlags?.path).toEqual(resolvePath('development/path')) - expect(developmentEnvFlags?.store).toEqual('store2.myshopify.com') - expect(developmentEnvFlags?.development).toEqual(true) - expect(developmentEnvFlags?.['no-color']).toEqual(true) - - const liveEnvFlags = commandCalls[2]?.flags - expect(liveEnvFlags?.path).toEqual('current/working/directory') - expect(liveEnvFlags?.store).toEqual('store3.myshopify.com') - expect(liveEnvFlags?.live).toEqual(true) - expect(liveEnvFlags?.['no-color']).toEqual(true) }) test('multiple environment commands accept missing password when a store auth cache session exists', async () => { - const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'store1.myshopify.com'} - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) - vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([ - { - store: 'store1.myshopify.com', - clientId: 'store-auth-client-id', - userId: 'preview:123', - accessToken: 'shpat_preview_token', - scopes: [], - acquiredAt: '2026-06-08T11:00:00.000Z', - }, - ]) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } + await withThemeEnvironment(async (tmpDir) => { + const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'store1.myshopify.com'} + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', path: joinPath(tmpDir, 'home/path/to/theme1')}) + .mockResolvedValueOnce({ + store: 'store2.myshopify.com', + password: 'password2', + path: joinPath(tmpDir, 'home/path/to/theme2'), + }) + + await mkdir(joinPath(tmpDir, 'home/path/to/theme1')) + await mkdir(joinPath(tmpDir, 'home/path/to/theme2')) + + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([ + { + store: 'store1.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }, + ]) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--environment', 'preview', '--environment', 'another-preview'], + CommandConfig, + ) + + await command.run() + + expect(renderWarning).not.toHaveBeenCalled() + expect(listCurrentStoredStoreAppSessions).toHaveBeenCalledOnce() + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('store2.myshopify.com', 'password2') + expect(command.commandCalls).toEqual( + expect.arrayContaining([expect.objectContaining({session: storeAuthSession})]), + ) }) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - - await CommandConfig.load() - const command = new TestThemeCommandWithPathFlag( - ['--environment', 'preview', '--environment', 'another-preview'], - CommandConfig, - ) - - await command.run() - - expect(renderWarning).not.toHaveBeenCalled() - expect(listCurrentStoredStoreAppSessions).toHaveBeenCalledOnce() - expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() - expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('store2.myshopify.com', 'password2') - expect(command.commandCalls).toEqual( - expect.arrayContaining([expect.objectContaining({session: storeAuthSession})]), - ) }) test('multiple environment commands still require password when no store auth cache session exists', async () => { - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) - vi.mocked(renderConcurrent).mockResolvedValue(undefined) - - await CommandConfig.load() - const command = new TestThemeCommandWithPathFlag( - ['--environment', 'preview', '--environment', 'another-preview'], - CommandConfig, - ) - - await command.run() - - expect(renderWarning).toHaveBeenCalledWith( - expect.objectContaining({ - body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}], - }), - ) - expect(renderConcurrent).not.toHaveBeenCalled() - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - }) + await withThemeEnvironment(async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) - test('commands will only create a session object if the password flag is supported', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } - }) + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--environment', 'preview', '--environment', 'another-preview'], + CommandConfig, + ) - await CommandConfig.load() - const command = new TestUnauthenticatedThemeCommand( - ['--environment', 'store1', '--environment', 'store2'], - CommandConfig, - ) + await command.run() - // When - await command.run() + expect(renderWarning).toHaveBeenCalledWith( + expect.objectContaining({ + body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}], + }), + ) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + }) - // Then - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + test('commands will only create a session object if the password flag is supported', async () => { + await withThemeEnvironment(async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestUnauthenticatedThemeCommand( + ['--environment', 'store1', '--environment', 'store2'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) }) }) }) diff --git a/theme-command.test.ts.orig b/theme-command.test.ts.orig new file mode 100644 index 00000000000..616e8563d78 --- /dev/null +++ b/theme-command.test.ts.orig @@ -0,0 +1,1114 @@ +import ThemeCommand, {RequiredFlags} from './theme-command.js' +import {ensureThemeStore} from './theme-store.js' +import {describe, vi, expect, test, beforeEach} from 'vitest' +import {Config, Flags} from '@oclif/core' +import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import { + getCurrentStoredStoreAppSession, + listCurrentStoredStoreAppSessions, +} from '@shopify/cli-kit/node/store-auth-session' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {fileExistsSync} from '@shopify/cli-kit/node/fs' +import {resolvePath} from '@shopify/cli-kit/node/path' +import {renderConcurrent, renderConfirmationPrompt, renderError, renderWarning} from '@shopify/cli-kit/node/ui' +import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' +import {hashString} from '@shopify/cli-kit/node/crypto' + +import type {Writable} from 'stream' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/store-auth-session') +vi.mock('@shopify/cli-kit/node/environments') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/metadata', () => ({ + addPublicMetadata: vi.fn(), + addSensitiveMetadata: vi.fn(), +})) +vi.mock('./theme-store.js') +vi.mock('@shopify/cli-kit/node/fs') + +const CommandConfig = new Config({root: __dirname}) + +class TestThemeCommand extends ThemeCommand { + static flags = { + environment: Flags.string({ + multiple: true, + default: [], + env: 'SHOPIFY_FLAG_ENVIRONMENT', + }), + store: Flags.string({ + env: 'SHOPIFY_FLAG_STORE', + }), + password: Flags.string({ + env: 'SHOPIFY_FLAG_PASSWORD', + }), + path: Flags.string({ + env: 'SHOPIFY_FLAG_PATH', + default: 'current/working/directory', + }), + 'no-color': Flags.boolean({ + env: 'SHOPIFY_FLAG_NO_COLOR', + default: false, + }), + } + + static multiEnvironmentsFlags: RequiredFlags = ['store'] + + commandCalls: {flags: any; session: AdminSession; multiEnvironment: boolean; args: any; context?: any}[] = [] + + async command( + flags: any, + session: AdminSession, + multiEnvironment = false, + args?: any, + context?: {stdout?: Writable; stderr?: Writable}, + ): Promise { + this.commandCalls.push({flags, session, multiEnvironment, args, context}) + + if (flags.environment && flags.environment[0] === 'command-error') { + throw new Error('Mocking a command error') + } + } +} + +class TestScopedThemeCommand extends TestThemeCommand { + protected storeAuthScopes(): string[] { + return ['read_themes'] + } +} + +class TestThemeCommandWithForce extends TestThemeCommand { + static flags = { + ...TestThemeCommand.flags, + force: Flags.boolean({ + char: 'f', + description: 'Skip confirmation', + env: 'SHOPIFY_FLAG_FORCE', + }), + } +} + +class TestThemeCommandWithPathFlag extends TestThemeCommandWithForce { + static multiEnvironmentsFlags: RequiredFlags = ['store', 'password', 'path'] +} + +class TestThemeCommandWithUnionFlags extends TestThemeCommand { + static multiEnvironmentsFlags: RequiredFlags = ['store', ['live', 'development', 'theme']] + + static flags = { + ...TestThemeCommand.flags, + development: Flags.boolean({ + env: 'SHOPIFY_FLAG_DEVELOPMENT', + }), + theme: Flags.string({ + env: 'SHOPIFY_FLAG_THEME_ID', + }), + live: Flags.boolean({ + env: 'SHOPIFY_FLAG_LIVE', + }), + } +} +class TestThemeCommandWithPath extends TestThemeCommand { + static multiEnvironmentsFlags: RequiredFlags = ['store', 'path'] +} + +class TestUnauthenticatedThemeCommand extends ThemeCommand { + static flags = { + environment: Flags.string({ + multiple: true, + default: [], + env: 'SHOPIFY_FLAG_ENVIRONMENT', + }), + store: Flags.string({ + env: 'SHOPIFY_FLAG_STORE', + }), + } + + static multiEnvironmentsFlags: RequiredFlags = ['store'] + + commandCalls: {flags: any; session: AdminSession; multiEnvironment?: boolean; args?: any; context?: any}[] = [] + + async command( + flags: any, + session: AdminSession, + multiEnvironment?: boolean, + args?: any, + context?: {stdout?: Writable; stderr?: Writable}, + ): Promise { + this.commandCalls.push({flags, session, multiEnvironment, args, context}) + } +} + +class TestNoMultiEnvThemeCommand extends TestThemeCommand { + static multiEnvironmentsFlags: RequiredFlags = null +} + +class TestThemeCommandWithoutStoreRequired extends ThemeCommand { + static flags = { + environment: Flags.string({ + multiple: true, + default: [], + env: 'SHOPIFY_FLAG_ENVIRONMENT', + }), + path: Flags.string({ + env: 'SHOPIFY_FLAG_PATH', + default: 'current/working/directory', + }), + store: Flags.string({ + env: 'SHOPIFY_FLAG_STORE', + }), + } + + static multiEnvironmentsFlags: RequiredFlags = ['path'] + + commandCalls: { + flags: any + session: AdminSession | undefined + multiEnvironment?: boolean + args?: any + context?: any + }[] = [] + + async command( + flags: any, + session: AdminSession | undefined, + multiEnvironment?: boolean, + args?: any, + context?: {stdout?: Writable; stderr?: Writable}, + ): Promise { + this.commandCalls.push({flags, session, multiEnvironment, args, context}) + } +} + +describe('ThemeCommand', () => { + let mockSession: AdminSession + + beforeEach(() => { + mockSession = { + token: 'test-token', + storeFqdn: 'test-store.myshopify.com', + } + vi.mocked(ensureThemeStore).mockReturnValue('test-store.myshopify.com') + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([]) + vi.mocked(fileExistsSync).mockReturnValue(true) + }) + + describe('run', () => { + test('no environment provided', async () => { + // Given + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + // When + await command.run() + + // Then + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + expect(loadEnvironment).not.toHaveBeenCalled() + expect(renderConcurrent).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: {environment: []}, + session: mockSession, + multiEnvironment: false, + args: {}, + context: undefined, + }) + }) + + test('single environment provided', async () => { + // Given + const environmentConfig = {store: 'env-store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', { + from: 'current/working/directory', + }) + expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(1) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: { + environment: ['development'], + store: 'env-store.myshopify.com', + }, + session: mockSession, + multiEnvironment: false, + args: {}, + context: undefined, + }) + const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(publicMetadata).toContainEqual( + expect.objectContaining({ + store_fqdn_hash: hashString(mockSession.storeFqdn), + store_domain: mockSession.storeFqdn, + }), + ) + const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(sensitiveMetadata).toContainEqual({store_fqdn: mockSession.storeFqdn}) + }) + + test('uses a matching store auth cache session when no password is provided', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('uses the password flag instead of a matching store auth cache session', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', 'shptka_password') + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) + + test('falls back to theme authentication when no matching store auth cache session exists', async () => { + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) + + test('checks required scopes from the stored session before using a matching store auth cache session', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['read_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('treats a matching write scope in the stored session as satisfying a required read scope', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['write_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('uses a matching store auth cache session when stored scopes are empty', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('falls back to theme authentication when matching store auth session lacks required scopes', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['read_products'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) + + test('does not check stored store auth cache session expiry', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['read_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + expiresAt: '2026-06-08T11:30:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('rethrows unexpected store auth cache errors', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockImplementationOnce(() => { + throw new Error('cache read failed') + }) + + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + + await expect(command.run()).rejects.toThrow('cache read failed') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + + test('single environment provided but not found in TOML - throws AbortError', async () => { + // Given + vi.mocked(loadEnvironment).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'notreal'], CommandConfig) + + // When/Then + await expect(command.run()).rejects.toThrow('Please provide a valid environment.') + }) + + test('single environment provided without store - does not throw when store is not required', async () => { + // Given + const environmentConfig = {path: '/some/path'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: { + environment: ['development'], + path: '/some/path', + }, + session: undefined, + multiEnvironment: false, + }) + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + + test('single environment provided with store - does not create session when command does not require auth', async () => { + // Given + const environmentConfig = {path: '/some/path', store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]?.session).toBeUndefined() + }) + + test('multiple environments provided - uses renderConcurrent for parallel execution', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) + + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig) + + // When + await command.run() + + // Then + expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', { + from: 'current/working/directory', + silent: true, + }) + expect(loadEnvironment).toHaveBeenCalledWith('staging', 'shopify.theme.toml', { + from: 'current/working/directory', + silent: true, + }) + + expect(renderConcurrent).toHaveBeenCalledOnce() + expect(renderConcurrent).toHaveBeenCalledWith( + expect.objectContaining({ + processes: expect.arrayContaining([ + expect.objectContaining({prefix: 'development'}), + expect.objectContaining({prefix: 'staging'}), + ]), + showTimestamps: true, + }), + ) + }) + + test('multiple environments provided - logs metadata for each authenticated session', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({ + token: 'test-token', + storeFqdn: store, + })) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig) + + // When + await command.run() + + // Then + const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(publicMetadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + store_fqdn_hash: hashString('store1.myshopify.com'), + store_domain: 'store1.myshopify.com', + }), + expect.objectContaining({ + store_fqdn_hash: hashString('store2.myshopify.com'), + store_domain: 'store2.myshopify.com', + }), + ]), + ) + const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(sensitiveMetadata).toEqual( + expect.arrayContaining([{store_fqdn: 'store1.myshopify.com'}, {store_fqdn: 'store2.myshopify.com'}]), + ) + }) + + test("throws an AbortError if the path doesn't exist", async () => { + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + + vi.mocked(fileExistsSync).mockReturnValue(false) + + await expect(command.run()).rejects.toThrow('Path does not exist: current/working/directory') + expect(fileExistsSync).toHaveBeenCalledWith('current/working/directory') + }) + + test('multiple environments provided - displays warning if not allowed', async () => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) + + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestNoMultiEnvThemeCommand( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderWarning).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'This command does not support multiple environments.', + }), + ) + }) + }) + + describe('multi environment', () => { + test('commands that act on the same store are run in groups to prevent conflicts', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'wow a theme'}) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'another theme'}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithUnionFlags( + ['--environment', 'store1-theme', '--environment', 'store1-development', '--environment', 'store2-theme'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const runGroupOneProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(runGroupOneProcesses).toHaveLength(2) + expect(runGroupOneProcesses?.map((process) => process.prefix)).toEqual(['store1-theme', 'store2-theme']) + + const runGroupTwoProcesses = vi.mocked(renderConcurrent).mock.calls[1]?.[0]?.processes + expect(runGroupTwoProcesses).toHaveLength(1) + expect(runGroupTwoProcesses?.map((process) => process.prefix)).toEqual(['store1-development']) + }) + + test('commands with --force flag should not prompt for confirmation', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithForce( + ['--environment', 'development', '--environment', 'staging', '--force'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(renderConcurrent).toHaveBeenCalledOnce() + expect(renderConcurrent).toHaveBeenCalledWith( + expect.objectContaining({ + processes: expect.arrayContaining([ + expect.objectContaining({prefix: 'development'}), + expect.objectContaining({prefix: 'staging'}), + ]), + showTimestamps: true, + }), + ) + }) + + test('commands that do not allow --force flag should not prompt for confirmation', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(renderConcurrent).toHaveBeenCalledOnce() + }) + + test('commands without --force flag that allow it should prompt for confirmation', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithForce( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).toHaveBeenCalledOnce() + expect(renderConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.any(Array), + confirmationMessage: 'Yes, proceed', + cancellationMessage: 'Cancel', + }), + ) + expect(renderConcurrent).toHaveBeenCalledOnce() + }) + + test('confirmation prompts should display correctly formatted flag values', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', password: 'password1', path: '/home/path/to/theme1'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) + + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).toHaveBeenCalledOnce() + expect(renderConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: ['Run testthemecommandwithpathflag in the following environments?'], + infoTable: { + Environment: [ + ['development', {subdued: 'store: store1.myshopify.com, password, path: /home/.../theme1'}], + ['staging', {subdued: 'store: store2.myshopify.com, password, path: /home/.../theme2'}], + ], + }, + confirmationMessage: 'Yes, proceed', + cancellationMessage: 'Cancel', + }), + ) + }) + + test('should not execute command if confirmation is cancelled', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithForce( + ['--environment', 'development', '--environment', 'staging'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderConfirmationPrompt).toHaveBeenCalledOnce() + expect(renderConcurrent).not.toHaveBeenCalled() + }) + + test('should execute commands in environments with all required flags', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithUnionFlags( + ['--environment', 'theme', '--environment', 'development', '--environment', 'live'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(3) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['theme', 'development', 'live']) + }) + + test('should not execute commands in environments that are missing required flags', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({store: 'store3.myshopify.com'}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'development', '--environment', 'env-missing-store', '--environment', 'production'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(2) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['development', 'production']) + }) + + test('should not execute commands in environments that are missing required flags even if they have a default value', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/a/path'}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({store: 'store3.myshopify.com'}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithPath( + ['--environment', 'development', '--environment', 'env-missing-store', '--environment', 'path-defaults-to-cwd'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(1) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['development']) + }) + + test('should not execute commands in environments that are missing required "one of" flags', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) + + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithUnionFlags( + ['--environment', 'theme', '--environment', 'missing-theme-live-or-development', '--environment', 'live'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(2) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual(['theme', 'live']) + }) + + test('commands error gracefully and continue with other environments', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development', '--environment', 'production'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const renderConcurrentProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes + expect(renderConcurrentProcesses).toHaveLength(3) + expect(renderConcurrentProcesses?.map((process) => process.prefix)).toEqual([ + 'command-error', + 'development', + 'production', + ]) + }) + + test('error messages contain the environment name', async () => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + body: ['Environment command-error failed: \n\nMocking a command error'], + }), + ) + }) + + test('commands should display an error if the --path flag is used', async () => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + body: [ + "Can't use `--path` flag with multiple environments.", + "Configure each environment's theme path in your shopify.theme.toml file instead.", + ], + }), + ) + }) + + test('commands should display an error if the --path flag is used and no shopify.theme.toml is found', async () => { + // Given + const environmentConfig = {store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(fileExistsSync).mockReturnValue(false) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + body: [ + "Can't use `--path` flag with multiple environments.", + 'Run this command from the directory containing shopify.theme.toml.', + 'No shopify.theme.toml found in current directory.', + ], + }), + ) + }) + + test('CLI and shopify.theme.toml flag values take precedence over defaults', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'theme1.myshopify.com', path: 'theme/path'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', development: true, path: 'development/path'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', live: true, 'no-color': false}) + + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand( + ['--environment', 'theme', '--environment', 'development', '--environment', 'live', '--no-color'], + CommandConfig, + ) + + // When + await command.run() + + // Then + const commandCalls = command.commandCalls + expect(commandCalls).toHaveLength(3) + + const themeEnvFlags = commandCalls[0]?.flags + expect(themeEnvFlags?.path).toEqual(resolvePath('theme/path')) + expect(themeEnvFlags?.store).toEqual('store1.myshopify.com') + expect(themeEnvFlags?.theme).toEqual('theme1.myshopify.com') + expect(themeEnvFlags?.['no-color']).toEqual(true) + + const developmentEnvFlags = commandCalls[1]?.flags + expect(developmentEnvFlags?.path).toEqual(resolvePath('development/path')) + expect(developmentEnvFlags?.store).toEqual('store2.myshopify.com') + expect(developmentEnvFlags?.development).toEqual(true) + expect(developmentEnvFlags?.['no-color']).toEqual(true) + + const liveEnvFlags = commandCalls[2]?.flags + expect(liveEnvFlags?.path).toEqual('current/working/directory') + expect(liveEnvFlags?.store).toEqual('store3.myshopify.com') + expect(liveEnvFlags?.live).toEqual(true) + expect(liveEnvFlags?.['no-color']).toEqual(true) + }) + + test('multiple environment commands accept missing password when a store auth cache session exists', async () => { + const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'store1.myshopify.com'} + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([ + { + store: 'store1.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }, + ]) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--environment', 'preview', '--environment', 'another-preview'], + CommandConfig, + ) + + await command.run() + + expect(renderWarning).not.toHaveBeenCalled() + expect(listCurrentStoredStoreAppSessions).toHaveBeenCalledOnce() + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('store2.myshopify.com', 'password2') + expect(command.commandCalls).toEqual( + expect.arrayContaining([expect.objectContaining({session: storeAuthSession})]), + ) + }) + + test('multiple environment commands still require password when no store auth cache session exists', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--environment', 'preview', '--environment', 'another-preview'], + CommandConfig, + ) + + await command.run() + + expect(renderWarning).toHaveBeenCalledWith( + expect.objectContaining({ + body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}], + }), + ) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + + test('commands will only create a session object if the password flag is supported', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestUnauthenticatedThemeCommand( + ['--environment', 'store1', '--environment', 'store2'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + }) +})