diff --git a/.changeset/cuddly-yaks-laugh.md b/.changeset/cuddly-yaks-laugh.md new file mode 100644 index 0000000000..e2df0d2810 --- /dev/null +++ b/.changeset/cuddly-yaks-laugh.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': minor +--- + +Release the developer preview for the Theme Console command diff --git a/docs-shopify.dev/commands/interfaces/theme-console.interface.ts b/docs-shopify.dev/commands/interfaces/theme-console.interface.ts index 0fe1a9cd03..ccab48c225 100644 --- a/docs-shopify.dev/commands/interfaces/theme-console.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-console.interface.ts @@ -30,6 +30,12 @@ export interface themeconsole { */ '-s, --store '?: string + /** + * The password for storefronts with password protection. + * @environment SHOPIFY_FLAG_STORE_PASSWORD + */ + '--store-password '?: string + /** * The url to be used as context * @environment SHOPIFY_FLAG_URL diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index c5edf9758d..6c8aaa5747 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -4255,6 +4255,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_PORT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--store-password ", + "value": "string", + "description": "The password for storefronts with password protection.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_PASSWORD" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", "syntaxKind": "PropertySignature", @@ -4292,7 +4301,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface themeconsole {\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * Local port to serve authentication service.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themeconsole {\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * Local port to serve authentication service.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } diff --git a/packages/cli-kit/src/public/node/http.ts b/packages/cli-kit/src/public/node/http.ts index b1a157da0c..f9f2eadb6a 100644 --- a/packages/cli-kit/src/public/node/http.ts +++ b/packages/cli-kit/src/public/node/http.ts @@ -19,7 +19,7 @@ export function formData(): FormData { return new FormData() } -export type Response = ReturnType +export type Response = Awaited> /** * An interface that abstracts way node-fetch. When Node has built-in @@ -33,7 +33,7 @@ export type Response = ReturnType * @param init - An object containing any custom settings that you want to apply to the request. * @returns A promise that resolves with the response. */ -export async function fetch(url: RequestInfo, init?: RequestInit): Response { +export async function fetch(url: RequestInfo, init?: RequestInit): Promise { return runWithTimer('cmd_all_timing_network_ms')(() => debugLogResponseInfo({url: url.toString(), request: nodeFetch(url, init)}), ) @@ -48,7 +48,7 @@ export async function fetch(url: RequestInfo, init?: RequestInit): Response { * @param init - An object containing any custom settings that you want to apply to the request. * @returns A promise that resolves with the response. */ -export async function shopifyFetch(url: RequestInfo, init?: RequestInit): Response { +export async function shopifyFetch(url: RequestInfo, init?: RequestInit): Promise { const sanitizedUrl = sanitizeURL(url.toString()) const options: RequestInit = { ...(init ?? {}), diff --git a/packages/cli/README.md b/packages/cli/README.md index c9f258a3fe..4be67baefa 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1611,14 +1611,15 @@ USAGE $ shopify theme console --url /products/classic-leather-jacket FLAGS - -e, --environment= The environment to apply to the current command. - -s, --store= Store URL. It can be the store prefix (example) or the full myshopify.com URL - (example.myshopify.com, https://example.myshopify.com). - --no-color Disable color output. - --password= Password generated from the Theme Access app. - --port= [default: 9293] Local port to serve authentication service. - --url= [default: /] The url to be used as context - --verbose Increase the verbosity of the output. + -e, --environment= The environment to apply to the current command. + -s, --store= Store URL. It can be the store prefix (example) or the full myshopify.com URL + (example.myshopify.com, https://example.myshopify.com). + --no-color Disable color output. + --password= Password generated from the Theme Access app. + --port= Local port to serve authentication service. + --store-password= The password for storefronts with password protection. + --url= [default: /] The url to be used as context + --verbose Increase the verbosity of the output. DESCRIPTION Shopify Liquid REPL (read-eval-print loop) tool diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d6a62112e7..33d20138e9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4759,6 +4759,14 @@ "description": "Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data.\n\n You can also provide context to the console using a URL, as some Liquid objects are context-specific", "descriptionWithMarkdown": "Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data.\n\n You can also provide context to the console using a URL, as some Liquid objects are context-specific", "flags": { + "dev-preview": { + "allowNo": false, + "description": "Enables the developer preview for the upcoming `theme console` implementation.", + "env": "SHOPIFY_FLAG_BETA", + "hidden": true, + "name": "dev-preview", + "type": "boolean" + }, "environment": { "char": "e", "description": "The environment to apply to the current command.", @@ -4785,7 +4793,6 @@ "type": "option" }, "port": { - "default": "9293", "description": "Local port to serve authentication service.", "env": "SHOPIFY_FLAG_PORT", "hasDynamicHelp": false, @@ -4802,6 +4809,14 @@ "name": "store", "type": "option" }, + "store-password": { + "description": "The password for storefronts with password protection.", + "env": "SHOPIFY_FLAG_STORE_PASSWORD", + "hasDynamicHelp": false, + "multiple": false, + "name": "store-password", + "type": "option" + }, "url": { "default": "/", "description": "The url to be used as context", diff --git a/packages/theme/src/cli/commands/theme/console.ts b/packages/theme/src/cli/commands/theme/console.ts index 622b441970..a21a727f48 100644 --- a/packages/theme/src/cli/commands/theme/console.ts +++ b/packages/theme/src/cli/commands/theme/console.ts @@ -1,12 +1,14 @@ import {themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' import {ensureThemeStore} from '../../utilities/theme-store.js' +import {ensureReplEnv, initializeRepl} from '../../services/console.js' import {globalFlags} from '@shopify/cli-kit/node/cli' import {ensureAuthenticatedStorefront, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {execCLI2} from '@shopify/cli-kit/node/ruby' -import {renderInfo} from '@shopify/cli-kit/node/ui' +import {renderInfo, renderWarning} from '@shopify/cli-kit/node/ui' import {Flags} from '@oclif/core' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {outputInfo} from '@shopify/cli-kit/node/output' export default class Console extends ThemeCommand { static summary = 'Shopify Liquid REPL (read-eval-print loop) tool' @@ -32,21 +34,40 @@ export default class Console extends ThemeCommand { port: Flags.string({ description: 'Local port to serve authentication service.', env: 'SHOPIFY_FLAG_PORT', - default: '9293', + }), + 'store-password': Flags.string({ + description: 'The password for storefronts with password protection.', + env: 'SHOPIFY_FLAG_STORE_PASSWORD', + }), + 'dev-preview': Flags.boolean({ + hidden: true, + description: 'Enables the developer preview for the upcoming `theme console` implementation.', + env: 'SHOPIFY_FLAG_BETA', }), } async run() { const {flags} = await this.parse(Console) const store = ensureThemeStore(flags) - const {password, url, port} = flags + const {url, port, password: themeAccessPassword} = flags const cliVersion = CLI_KIT_VERSION const theme = `liquid-console-repl-${cliVersion}` - const adminSession = await ensureAuthenticatedThemes(store, password, [], true) - const storefrontToken = await ensureAuthenticatedStorefront([], password) + const adminSession = await ensureAuthenticatedThemes(store, themeAccessPassword, [], true) + const storefrontToken = await ensureAuthenticatedStorefront([], themeAccessPassword) const authUrl = `http://localhost:${port}/password` + if (flags['dev-preview']) { + outputInfo('This feature is currently in development and is not ready for use or testing yet.') + + if (flags.port) { + renderPortDeprecationWarning() + } + const {themeId, storePassword} = await ensureReplEnv(adminSession, flags['store-password']) + await initializeRepl(adminSession, storefrontToken, themeId, url, storePassword) + return + } + renderInfo({ body: [ 'Activate the Shopify Liquid console in', @@ -55,10 +76,21 @@ export default class Console extends ThemeCommand { ], }) - return execCLI2(['theme', 'console', '--url', url, '--port', port, '--theme', theme], { + return execCLI2(['theme', 'console', '--url', url, '--port', port ?? '9293', '--theme', theme], { store, adminToken: adminSession.token, storefrontToken, }) } } +function renderPortDeprecationWarning() { + renderWarning({ + headline: ['The', {command: '--port'}, 'flag has been deprecated.'], + body: [ + {command: 'shopify theme console'}, + 'no longer requires a port to run. The', + {command: '--port'}, + 'flag is in the process of being deprecated and will be removed soon.', + ], + }) +} diff --git a/packages/theme/src/cli/services/console.test.ts b/packages/theme/src/cli/services/console.test.ts new file mode 100644 index 0000000000..5a5fd8aad9 --- /dev/null +++ b/packages/theme/src/cli/services/console.test.ts @@ -0,0 +1,69 @@ +import {ensureReplEnv} from './console.js' +import { + isStorefrontPasswordCorrect, + isStorefrontPasswordProtected, +} from '../utilities/theme-environment/storefront-session.js' +import {ensureValidPassword} from '../utilities/repl/storefront-password-prompt.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {AdminSession} from '@shopify/cli-kit/node/session' + +vi.mock('../utilities/theme-environment/storefront-session.js') +vi.mock('../utilities/repl/storefront-password-prompt.js') +vi.mock('../utilities/repl/repl-theme-manager.js', () => { + const REPLThemeManager = vi.fn() + REPLThemeManager.prototype.findOrCreate = () => ({ + id: 1, + name: 'theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + return {REPLThemeManager} +}) + +describe('ensureReplEnv', () => { + beforeEach(() => { + vi.mocked(ensureValidPassword).mockResolvedValue('testPassword') + }) + + const adminSession: AdminSession = {storeFqdn: 'test-store.myshopify.com', token: 'token'} + + test('should prompt for password when storefront is password protected', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + + // When + const {storePassword} = await ensureReplEnv(adminSession) + + // Then + expect(ensureValidPassword).toHaveBeenCalled() + expect(storePassword).toBe('testPassword') + }) + + test('should skip prompt and return undefined for password when storefront is not password protected', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + + // When + const {storePassword} = await ensureReplEnv(adminSession) + + // Then + expect(ensureValidPassword).not.toHaveBeenCalled() + expect(storePassword).toBeUndefined() + }) + + test('should return undefined for storePassword when password is provided but storefront is not password protected', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + + // When + const {storePassword} = await ensureReplEnv(adminSession, 'testPassword') + + // Then + expect(ensureValidPassword).not.toHaveBeenCalled() + expect(storePassword).toBeUndefined() + }) +}) diff --git a/packages/theme/src/cli/services/console.ts b/packages/theme/src/cli/services/console.ts new file mode 100644 index 0000000000..52c9aba8e8 --- /dev/null +++ b/packages/theme/src/cli/services/console.ts @@ -0,0 +1,44 @@ +import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' +import {REPLThemeManager} from '../utilities/repl/repl-theme-manager.js' +import {DevServerSession} from '../utilities/theme-environment/types.js' +import {ensureValidPassword} from '../utilities/repl/storefront-password-prompt.js' +import {replLoop} from '../utilities/repl/repl.js' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {consoleLog} from '@shopify/cli-kit/node/output' + +export async function ensureReplEnv(adminSession: AdminSession, storePasswordFlag?: string) { + const themeId = await findOrCreateReplTheme(adminSession) + + const storePassword = (await isStorefrontPasswordProtected(adminSession.storeFqdn)) + ? await ensureValidPassword(storePasswordFlag, adminSession.storeFqdn) + : undefined + + return { + themeId, + storePassword, + } +} + +async function findOrCreateReplTheme(adminSession: AdminSession): Promise { + const themeManager = new REPLThemeManager(adminSession) + const replTheme = await themeManager.findOrCreate() + + return replTheme.id.toString() +} + +export async function initializeRepl( + adminSession: AdminSession, + storefrontToken: string, + themeId: string, + url: string, + password: string | undefined, +) { + consoleLog('Welcome to Shopify Liquid console\n(press Ctrl + C to exit)') + const themeSession: DevServerSession = { + ...adminSession, + storefrontToken, + storefrontPassword: password, + expiresAt: new Date(), + } + return replLoop(themeSession, themeId, url) +} diff --git a/packages/theme/src/cli/services/local-storage.ts b/packages/theme/src/cli/services/local-storage.ts index 93220a0f72..90d8e3425e 100644 --- a/packages/theme/src/cli/services/local-storage.ts +++ b/packages/theme/src/cli/services/local-storage.ts @@ -13,6 +13,7 @@ interface DevelopmentThemeLocalStorageSchema { let _themeLocalStorageInstance: LocalStorage | undefined let _developmentThemeLocalStorageInstance: LocalStorage | undefined +let _replThemeLocalStorageInstance: LocalStorage | undefined function themeLocalStorage() { if (!_themeLocalStorageInstance) { @@ -30,6 +31,15 @@ function developmentThemeLocalStorage() { return _developmentThemeLocalStorageInstance } +function replThemeLocalStorage() { + if (!_replThemeLocalStorageInstance) { + _replThemeLocalStorageInstance = new LocalStorage({ + projectName: 'shopify-cli-repl-theme-config', + }) + } + return _replThemeLocalStorageInstance +} + export function getThemeStore() { return themeLocalStorage().get('themeStore') } @@ -52,3 +62,18 @@ export function removeDevelopmentTheme(): void { outputDebug(outputContent`Removing development theme...`) developmentThemeLocalStorage().delete(getThemeStore()) } + +export function getREPLTheme(): string | undefined { + outputDebug(outputContent`Getting REPL theme...`) + return replThemeLocalStorage().get(getThemeStore()) +} + +export function setREPLTheme(theme: string): void { + outputDebug(outputContent`Setting REPL theme to ${theme}...`) + replThemeLocalStorage().set(getThemeStore(), theme) +} + +export function removeREPLTheme(): void { + outputDebug(outputContent`Removing REPL theme...`) + replThemeLocalStorage().delete(getThemeStore()) +} diff --git a/packages/theme/src/cli/utilities/repl/evaluator.test.ts b/packages/theme/src/cli/utilities/repl/evaluator.test.ts new file mode 100644 index 0000000000..e407b07935 --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/evaluator.test.ts @@ -0,0 +1,220 @@ +import {evaluate, EvaluationConfig} from './evaluator.js' +import {DevServerSession} from '../theme-environment/types.js' +import {render} from '../theme-environment/storefront-renderer.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {AbortSilentError} from '@shopify/cli-kit/node/error' + +vi.mock('../theme-environment/storefront-renderer') +vi.mock('@shopify/cli-kit/node/output') + +describe('evaluate', () => { + let mockConfig: EvaluationConfig + + beforeEach(() => { + mockConfig = { + themeSession: {} as DevServerSession, + themeId: 'test-theme-id', + url: 'https://test-shop.myshopify.com', + replSession: [], + snippet: '', + } + }) + + test('should evaluate a result successfully', async () => { + const mockResponse = createMockResponse({ + status: 200, + text: '
\n[{ "type": "display", "value": 123123 }]\n
', + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + const result = await evaluate({...mockConfig, snippet: 'shop.id'}) + + expect(result).toBe(123123) + }) + + test('should add succesful assignments to the session', async () => { + const mockResponse = createMockResponse({ + status: 200, + text: '
\n[{ "type": "context", "value": "assign x = 1" }]
', + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + const result = await evaluate({...mockConfig, snippet: 'assign x = 1'}) + + expect(mockConfig.replSession).toEqual([{type: 'context', value: '{% assign x = 1 %}'}]) + expect(result).toBeUndefined() + }) + + test('should not add unsuccessful assignments to the session', async () => { + const mockResponse = createMockResponse({ + status: 200, + text: '
\nLiquid syntax error (snippets/eval line 1): Unexpected character = in "{{ x = 1 | json }}"\n
', + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + const result = await evaluate({...mockConfig, snippet: 'assign x = ;'}) + + expect(mockConfig.replSession).toEqual([]) + expect(result).toBeUndefined() + }) + + test('should translate equals-sign assignments into variable tag assignments and add them to the session', async () => { + const mockResponseOne = createMockResponse({ + status: 200, + text: '
\nLiquid syntax error (snippets/eval line 1): Unexpected character = in "{{ x = 1 | json }}"
', + }) + const mockResponseTwo = createMockResponse({ + status: 200, + text: '
\nLiquid syntax error (snippets/eval line 1): Unknown tag \'x\'
', + }) + const mockResponseThree = createMockResponse({ + status: 200, + text: '
\n[{ "type": "context", "value": "" }]
', + }) + vi.mocked(render) + .mockResolvedValueOnce(mockResponseOne as any) + .mockResolvedValueOnce(mockResponseTwo as any) + .mockResolvedValueOnce(mockResponseThree as any) + .mockResolvedValue(mockResponseTwo as any) + + const result = await evaluate({...mockConfig, snippet: 'x = 1'}) + + expect(mockConfig.replSession).toEqual([{type: 'context', value: '{% assign x = 1 %}'}]) + expect(result).toBeUndefined() + }) + + test('should handle `unknown tag` syntax errors and return undefined', async () => { + const mockResponseOne = createMockResponse({ + status: 200, + text: `
+Liquid syntax error (snippets/eval line 1): Unknown tag 'invalid_tag'
`, + }) + + vi.mocked(render).mockResolvedValue(mockResponseOne as any) + + const result = await evaluate({...mockConfig, snippet: 'invalid_tag'}) + + expect(result).toBeUndefined() + expect(outputInfo).toHaveBeenCalledOnce() + expect(outputInfo).toHaveBeenCalledWith( + outputContent`${outputToken.errorText("Unknown object, property, tag, or filter: 'invalid_tag'")}`, + ) + }) + + test('should handle general liquid syntax errors for unknown objects and return undefined', async () => { + const mockResponseOne = createMockResponse({ + status: 200, + text: `
+Liquid syntax error (snippets/eval line 1): Liquid error: undefined method 'unknown_object' for nil:NilClass +
`, + }) + vi.mocked(render).mockResolvedValue(mockResponseOne as any) + + const result = await evaluate({...mockConfig, snippet: 'unknown_object'}) + + expect(result).toBeUndefined() + expect(outputInfo).toHaveBeenCalledOnce() + expect(outputInfo).toHaveBeenCalledWith( + outputContent`${outputToken.errorText("Liquid error: undefined method 'unknown_object' for nil:NilClass")}`, + ) + }) + + test('should return undefined if the server responds with a liquid syntax error', async () => { + const mockResponse = createMockResponse({ + status: 200, + text: `
+ [{ "type": "display", "value": "Liquid syntax error: Unknown variable 'shop' in ..."}] +
`, + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + const result = await evaluate({...mockConfig, snippet: 'asdf'}) + + expect(result).toBe(undefined) + }) + + test('should return undefined and abort if the server responds with a non-200 status code', async () => { + const mockResponse = createMockResponse({ + status: 500, + text: 'Internal Server Error', + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + const result = await evaluate({...mockConfig, snippet: 'asdf'}) + + expect(result).toBe(undefined) + }) + + test('should return undefined if an error occurs during JSON parsing', async () => { + const mockResponse = createMockResponse({ + status: 200, + text: 'text', + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + const jsonParseSpy = vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { + throw new Error('JSON parsing error') + }) + + await expect(evaluate({...mockConfig, snippet: 'asdf'})).rejects.toThrow('JSON parsing error') + jsonParseSpy.mockRestore() + }) + + test('should handle expired session and throw AbortSilentError', async () => { + const mockResponse = createMockResponse({ + status: 401, + text: 'Unauthorized', + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + await expect(evaluate({...mockConfig, snippet: 'asdf'})).rejects.toThrow(AbortSilentError) + expect(outputInfo).toHaveBeenCalledWith( + outputContent`${outputToken.errorText('Session expired. Please initiate a new one.')}`, + ) + }) + + test('should handle too many requests and throw AbortSilentError', async () => { + const mockResponse = createMockResponse({ + status: 429, + text: 'Too Many Requests', + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + await expect(evaluate({...mockConfig, snippet: 'asdf'})).rejects.toThrow(AbortSilentError) + expect(outputInfo).toHaveBeenCalledWith( + outputContent`${outputToken.errorText('Evaluations limit reached. Try again later.')}`, + ) + }) + + test('should handle resource not found and throw AbortSilentError', async () => { + const mockResponse = createMockResponse({ + status: 200, + text: 'Not Found', + headers: {'server-timing': 'pageType;desc="404"'}, + }) + vi.mocked(render).mockResolvedValue(mockResponse as any) + + await expect(evaluate({...mockConfig, snippet: 'asdf'})).rejects.toThrow(AbortSilentError) + expect(outputInfo).toHaveBeenCalledWith( + outputContent`${outputToken.errorText('Page not found. Please provide a valid --url value.')}`, + ) + }) +}) + +function createMockResponse({ + status, + text, + headers = {}, +}: { + status: number + text: string + headers?: {[key: string]: string} +}) { + return { + status, + text: vi.fn().mockResolvedValue(text), + headers: { + get: vi.fn((header: string) => headers[header] || null), + }, + } +} diff --git a/packages/theme/src/cli/utilities/repl/evaluator.ts b/packages/theme/src/cli/utilities/repl/evaluator.ts new file mode 100644 index 0000000000..40fa0af8b6 --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/evaluator.ts @@ -0,0 +1,199 @@ +import {render} from '../theme-environment/storefront-renderer.js' +import {DevServerSession} from '../theme-environment/types.js' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {Response} from '@shopify/cli-kit/node/http' + +export interface SessionItem { + type: string + value: string +} + +export interface EvaluationConfig { + themeSession: DevServerSession + themeId: string + url: string + replSession: SessionItem[] + snippet: string +} + +export async function evaluate(config: EvaluationConfig): Promise { + try { + return evaluateSnippet(config) + + // eslint-disable-next-line no-catch-all/no-catch-all, @typescript-eslint/no-explicit-any + } catch (error: any) { + outputInfo(outputContent`${outputToken.errorText(error.message)}`) + outputDebug(error.stack || 'Error backtrace not found') + } +} + +async function evaluateSnippet(config: EvaluationConfig): Promise { + return ( + (await evalResult(config)) || + (await evalContext(config)) || + (await evalAssignmentContext(config)) || + (await evalSyntaxError(config)) || + undefined + ) +} + +async function evalResult(config: EvaluationConfig) { + outputDebug(`Evaluating snippet - ${config.snippet}`) + + const input = `{ "type": "display", "value": {{ ${config.snippet} | json }} }` + const request = await makeRequest({...config, snippet: input}) + const text = await request.text() + + return successfulRequest(request.status, text) ? parseDisplayResult(text) : undefined +} + +async function evalContext(config: EvaluationConfig) { + outputDebug(`Evaluating context - ${config.snippet}`) + + const json = `{ "type": "context", "value": "{% ${config.snippet.replace(/"/g, '\\"')} %}" }` + const request = await makeRequest({...config, snippet: json}) + const text = await request.text() + + if (successfulRequest(request.status, text)) { + config.replSession.push(JSON.parse(json)) + } +} + +async function evalAssignmentContext(config: EvaluationConfig) { + outputDebug(`Evaluating assignment context - ${config.snippet}`) + + if (isSmartAssignment(config.snippet)) { + config.snippet = `assign ${config.snippet}` + outputInfo(outputContent`${outputToken.gray(`> ${config.snippet}`)}`) + return evalContext(config) + } +} + +async function evalSyntaxError(config: EvaluationConfig) { + outputDebug(`Evaluating syntax error - ${config.snippet}`) + + let body = '' + if (!isStandardAssignment(config.snippet)) { + const response = await makeRequest({...config, snippet: `{{ ${config.snippet} }}`}) + body = await response.text() + } + + if (!hasLiquidError(body)) { + const response = await makeRequest({...config, snippet: `{% ${config.snippet} %}`}) + body = await response.text() + } + + if (hasLiquidError(body)) { + const error = body.replace(/ \(snippets\/eval line \d+\)/, '') + printSyntaxError(config.snippet, error) + } +} + +function printSyntaxError(snippet: string, error: string) { + if (error.includes('Unknown tag')) { + outputInfo(outputContent`${outputToken.errorText(`Unknown object, property, tag, or filter: '${snippet}'`)}`) + return + } + + const resultContent = stripHTMLContent(error) + if (resultContent) { + outputInfo(outputContent`${outputToken.errorText(resultContent)}`) + } +} + +async function makeRequest(config: EvaluationConfig): Promise { + const requestBody = buildRequestBody(config) + const response = await render(config.themeSession, { + path: config.url, + query: [], + themeId: config.themeId, + cookies: '', + sectionId: 'announcement-bar', + headers: {}, + replaceTemplates: { + 'sections/announcement-bar.liquid': `{% render 'eval' %}`, + 'snippets/eval.liquid': `\n${requestBody}\n`, + }, + }) + + if (isExpiredSession(response)) { + expiredSessionError() + } + + if (isTooManyRequests(response)) { + tooManyRequestsError() + } + + if (isResourceNotFound(response)) { + notFoundError() + } + + return response +} + +function buildRequestBody(config: EvaluationConfig): string { + const items = [...config.replSession.map((item) => JSON.stringify(item)), config.snippet] + return `[${items.join(',').replace(/\\"/g, '"')}]` +} + +function parseDisplayResult(result: string): string | number | undefined { + const resultContent = stripHTMLContent(result) + if (resultContent) { + const displayObject = JSON.parse(resultContent)?.find((item: SessionItem) => item.type === 'display') + return displayObject?.value + } +} + +function stripHTMLContent(result: string): undefined | string { + const splitResult = result.split('\n').slice(1, -1) + if (splitResult.length === 0) return + + return splitResult.join('') +} + +function hasLiquidError(body: string): boolean { + return /Liquid syntax error/.test(body) +} + +function isStandardAssignment(input: string): boolean { + const regex = /^\s*assign\s*((?:\(?[\w\-.[\]]\)?)+)\s*=\s*(.*)\s*/m + return regex.test(input) +} + +function isExpiredSession(response: Response): boolean { + return response.status === 401 || response.status === 403 +} + +function isTooManyRequests(response: Response): boolean { + return response.status === 430 || response.status === 429 +} + +function isResourceNotFound(response: Response): boolean { + // We don't look for the status code here because the Section Rendering API returns 200 even on unknown paths. + return response.headers.get('server-timing')?.includes('pageType;desc="404"') || false +} + +function expiredSessionError(): never { + outputInfo(outputContent`${outputToken.errorText('Session expired. Please initiate a new one.')}`) + throw new AbortSilentError() +} + +function tooManyRequestsError(): never { + outputInfo(outputContent`${outputToken.errorText('Evaluations limit reached. Try again later.')}`) + throw new AbortSilentError() +} + +function notFoundError(): never { + outputInfo(outputContent`${outputToken.errorText('Page not found. Please provide a valid --url value.')}`) + throw new AbortSilentError() +} + +function isSmartAssignment(input: string): boolean { + const regex = /^\s*((?:\(?[\w\-.[\]]\)?)+)\s*=\s*(.*)\s*/m + return regex.test(input) +} + +function successfulRequest(status: number, text: string) { + return status === 200 && !hasLiquidError(text) +} diff --git a/packages/theme/src/cli/utilities/repl/presenter.test.ts b/packages/theme/src/cli/utilities/repl/presenter.test.ts new file mode 100644 index 0000000000..0855125a5c --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/presenter.test.ts @@ -0,0 +1,58 @@ +import {presentValue} from './presenter.js' +import {consoleWarn, outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/output') + +describe('presentValue', () => { + test('should print a warning message if value has a JSON error', () => { + // Given + const value = {error: 'json not allowed for this object'} + + // When + presentValue(value) + + // Then + expect(consoleWarn).toHaveBeenCalledWith( + "Object can't be printed, but you can access its fields. Read more at https://shopify.dev/docs/api/liquid.", + ) + expect(outputInfo).not.toHaveBeenCalled() + }) + + test('should print "null" if value is undefined', () => { + // Given + const value = undefined + + // When + presentValue(value) + + // Then + expect(outputInfo).toHaveBeenCalledWith(outputContent`${outputToken.cyan('null')}`) + expect(consoleWarn).not.toHaveBeenCalled() + }) + + test('should print "null" if value is null', () => { + // Given + const value = null + + // When + presentValue(value) + + // Then + expect(outputInfo).toHaveBeenCalledWith(outputContent`${outputToken.cyan('null')}`) + expect(consoleWarn).not.toHaveBeenCalled() + }) + + test('should print the formatted output if value is not undefined, null, or has a JSON error', () => { + // Given + const value = {foo: 'bar'} + const formattedOutput = JSON.stringify(value, null, 2) + + // When + presentValue(value) + + // Then + expect(outputInfo).toHaveBeenCalledWith(outputContent`${outputToken.cyan(formattedOutput)}`) + expect(consoleWarn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/theme/src/cli/utilities/repl/presenter.ts b/packages/theme/src/cli/utilities/repl/presenter.ts new file mode 100644 index 0000000000..2172ad7e33 --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/presenter.ts @@ -0,0 +1,37 @@ +import {consoleWarn, outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' + +export function presentValue(value?: unknown) { + if (hasJsonError(value)) { + consoleWarn( + "Object can't be printed, but you can access its fields. Read more at https://shopify.dev/docs/api/liquid.", + ) + return + } + + if (value === undefined || value === null) { + renderValue('null') + return + } + + const formattedOutput = JSON.stringify(value, null, 2) + renderValue(formattedOutput) +} + +function hasJsonError(output: unknown): boolean { + if (Array.isArray(output)) { + return output.length > 0 ? hasJsonError(output[0]) : false + } + + if (output && typeof output === 'object') { + const errorOutput = output as {error?: unknown} + const errorMessage = errorOutput.error + + return typeof errorMessage === 'string' && errorMessage.includes('json not allowed for this object') + } + + return false +} + +function renderValue(value: string) { + return outputInfo(outputContent`${outputToken.cyan(value)}`) +} diff --git a/packages/theme/src/cli/utilities/repl/repl-theme-manager.test.ts b/packages/theme/src/cli/utilities/repl/repl-theme-manager.test.ts new file mode 100644 index 0000000000..a3e74cf0ee --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/repl-theme-manager.test.ts @@ -0,0 +1,83 @@ +import {REPLThemeManager} from './repl-theme-manager.js' +import {setREPLTheme, removeREPLTheme, getREPLTheme, getDevelopmentTheme} from '../../services/local-storage.js' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' +import {bulkUploadThemeAssets, createTheme, fetchTheme} from '@shopify/cli-kit/node/themes/api' + +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('../../services/local-storage') + +describe('REPLThemeManager', () => { + let adminSession: AdminSession + let themeManager: REPLThemeManager + + beforeEach(() => { + adminSession = {storeFqdn: 'mystore.myshopify.com', token: 'token'} + themeManager = new REPLThemeManager(adminSession) + }) + + describe('create', () => { + test('should upload theme assets', async () => { + // Given + const theme = { + id: 123, + name: 'Liquid Console (3.60)', + role: DEVELOPMENT_THEME_ROLE, + processing: true, + createdAtRuntime: true, + } + vi.mocked(createTheme).mockResolvedValue(theme) + + // When + await themeManager.create(DEVELOPMENT_THEME_ROLE, 'Liquid Console (3.60)') + + // Then + expect(bulkUploadThemeAssets).toHaveBeenCalledWith(123, expect.any(Array), adminSession) + }) + }) + + test('should set the REPL theme in local storage', async () => { + // Given + const themeName = 'Liquid Console (3.60)' + vi.mocked(createTheme).mockResolvedValue({ + id: 123, + name: themeName, + role: DEVELOPMENT_THEME_ROLE, + processing: true, + createdAtRuntime: true, + }) + + // When + await themeManager.create(DEVELOPMENT_THEME_ROLE, themeName) + + // Then + expect(setREPLTheme).toHaveBeenCalledWith('123') + }) + + test('should remove the REPL theme from local storage if nothing is found', async () => { + // Given + vi.mocked(fetchTheme).mockResolvedValue(undefined) + vi.mocked(getREPLTheme).mockReturnValue('123') + themeManager = new REPLThemeManager(adminSession) + + // When + await themeManager.fetch() + + // Then + expect(removeREPLTheme).toHaveBeenCalled() + }) + + test('should not conflict with development local storage', async () => { + // Given + vi.mocked(getDevelopmentTheme).mockReturnValue('123') + vi.mocked(getREPLTheme).mockReturnValue('234') + themeManager = new REPLThemeManager(adminSession) + + // When + await themeManager.fetch() + + // Then + expect(fetchTheme).toHaveBeenCalledWith(234, adminSession) + }) +}) diff --git a/packages/theme/src/cli/utilities/repl/repl-theme-manager.ts b/packages/theme/src/cli/utilities/repl/repl-theme-manager.ts new file mode 100644 index 0000000000..555e7eaeb3 --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/repl-theme-manager.ts @@ -0,0 +1,61 @@ +import {getREPLTheme, setREPLTheme, removeREPLTheme} from '../../services/local-storage.js' +import {ThemeManager} from '@shopify/cli-kit/node/themes/theme-manager' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {DEVELOPMENT_THEME_ROLE, Role} from '@shopify/cli-kit/node/themes/utils' +import {bulkUploadThemeAssets} from '@shopify/cli-kit/node/themes/api' +import {Theme} from '@shopify/cli-kit/node/themes/types' +import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' + +export class REPLThemeManager extends ThemeManager { + protected context = 'REPL' + + constructor(adminSession: AdminSession) { + super(adminSession) + this.themeId = getREPLTheme() + } + + async create(themeRole: Role, themeName: string) { + const theme = await super.create(themeRole, themeName) + await this.uploadThemeAssets(theme) + + return theme + } + + async findOrCreate(): Promise { + let theme = await this.fetch() + if (!theme) { + const themeName = `Liquid Console (${CLI_KIT_VERSION})` + theme = await this.create(DEVELOPMENT_THEME_ROLE, themeName) + } + return theme + } + + protected setTheme(themeName: string): void { + setREPLTheme(themeName) + } + + protected removeTheme(): void { + removeREPLTheme() + } + + private async uploadThemeAssets(theme: Theme) { + const assets = [ + {key: 'config/settings_data.json', value: '{}'}, + {key: 'config/settings_schema.json', value: '[]'}, + {key: 'snippets/eval.liquid', value: ''}, + {key: 'layout/password.liquid', value: '{{ content_for_header }}{{ content_for_layout }}'}, + {key: 'layout/theme.liquid', value: '{{ content_for_header }}{{ content_for_layout }}'}, + {key: 'sections/announcement-bar.liquid', value: ''}, + { + key: 'templates/index.json', + value: JSON.stringify({ + sections: { + announcement: {type: 'announcement-bar', settings: {}}, + }, + order: ['announcement'], + }), + }, + ] + await bulkUploadThemeAssets(theme.id, assets, this.adminSession) + } +} diff --git a/packages/theme/src/cli/utilities/repl/repl.test.ts b/packages/theme/src/cli/utilities/repl/repl.test.ts new file mode 100644 index 0000000000..286659b14b --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/repl.test.ts @@ -0,0 +1,73 @@ +import {DELIMITER_WARNING as DELIMITER_WARNING_MESSAGE, handleInput} from './repl.js' +import {evaluate} from './evaluator.js' +import {presentValue} from './presenter.js' +import {DevServerSession} from '../theme-environment/types.js' +import {describe, expect, test, vi} from 'vitest' +import {consoleWarn} from '@shopify/cli-kit/node/output' +import {createInterface} from 'readline' + +vi.mock('@shopify/cli-kit/node/output') +vi.mock('./evaluator.js') +vi.mock('./presenter.js') + +describe('handleInput', () => { + const themeSesssion: DevServerSession = { + storefrontPassword: 'password', + token: 'token', + expiresAt: new Date(), + storeFqdn: 'store.myshopify.com', + storefrontToken: 'storefrontToken', + } + const themeId = '123' + const url = '/' + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + test('should call consoleWarn if input has {{ delimiter', async () => { + // Given + const inputValue = '{{ collections.first }}' + + // When + await handleInput(inputValue, themeSesssion, themeId, url, rl, []) + + // Then + expect(consoleWarn).toHaveBeenCalledWith(DELIMITER_WARNING_MESSAGE) + }) + + test('should call consoleWarn if input has {% delimiter', async () => { + // Given + const inputValue = '{%' + + // When + await handleInput(inputValue, themeSesssion, themeId, url, rl, []) + + // Then + expect(consoleWarn).toHaveBeenCalledWith(DELIMITER_WARNING_MESSAGE) + }) + + test('should not call consoleWarn if {{ delimiter is wrapped in quotes', async () => { + // Given + const inputValue = '"{{ collections.first }}"' + + // When + await handleInput(inputValue, themeSesssion, themeId, url, rl, []) + + // Then + expect(consoleWarn).not.toHaveBeenCalled() + }) + + test('should call evaluate, presentValue, and prompt readline if input is valid', async () => { + // Given + const inputValue = '"test"' + + // When + await handleInput(inputValue, themeSesssion, themeId, url, rl, []) + + // Then + expect(consoleWarn).not.toHaveBeenCalled() + expect(evaluate).toHaveBeenCalled() + expect(presentValue).toHaveBeenCalled() + }) +}) diff --git a/packages/theme/src/cli/utilities/repl/repl.ts b/packages/theme/src/cli/utilities/repl/repl.ts new file mode 100644 index 0000000000..e07f160422 --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/repl.ts @@ -0,0 +1,63 @@ +import {evaluate, SessionItem} from './evaluator.js' +import {presentValue} from './presenter.js' +import {DevServerSession} from '../theme-environment/types.js' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {consoleWarn, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {createInterface, Interface} from 'readline' + +export const DELIMITER_WARNING = + "Liquid Console doesn't support Liquid delimiters such as '{{ ... }}' or '{% ... %}'.\nPlease use 'collections.first' instead of '{{ collections.first }}'." + +export async function replLoop(themeSession: DevServerSession, themeId: string, url: string) { + if (process.stdin.isTTY) { + // We want to indicate that we're still using stdin, so that the process + // doesn't exit early. + process.stdin.ref() + } + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + const replSession: SessionItem[] = [] + + rl.on('line', (input) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + handleInput(input, themeSession, themeId, url, rl, replSession) + }) + rl.prompt() +} + +export async function handleInput( + inputValue: string, + themeSession: DevServerSession, + themeId: string, + url: string, + rl: Interface, + replSession: SessionItem[], +) { + try { + if (hasDelimiter(inputValue)) { + consoleWarn(DELIMITER_WARNING) + return rl.prompt() + } + const evaluatedValue = await evaluate({snippet: inputValue, themeSession, themeId, url, replSession}) + presentValue(evaluatedValue) + rl.prompt() + } catch (error) { + shutdownReplSession(error) + rl.close() + throw new AbortSilentError() + } +} + +function shutdownReplSession(error: unknown) { + if (error instanceof Error) { + outputInfo(outputContent`${outputToken.errorText(`Shopify Liquid console error: ${error.message}`)}`) + outputDebug(error.stack || 'Error backtrace not found') + } +} + +function hasDelimiter(input: string): boolean { + return /^\s*(\{\{|{%)/.test(input) +} diff --git a/packages/theme/src/cli/utilities/repl/storefront-password-prompt.test.ts b/packages/theme/src/cli/utilities/repl/storefront-password-prompt.test.ts new file mode 100644 index 0000000000..651bbf3cf6 --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/storefront-password-prompt.test.ts @@ -0,0 +1,51 @@ +import {ensureValidPassword} from './storefront-password-prompt.js' +import {isStorefrontPasswordProtected, isStorefrontPasswordCorrect} from '../theme-environment/storefront-session.js' +import {renderTextPrompt} from '@shopify/cli-kit/node/ui' +import {describe, beforeEach, vi, test, expect} from 'vitest' + +vi.mock('../theme-environment/storefront-session.js') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('../utilities/repl-theme-manager.js', () => { + const REPLThemeManager = vi.fn() + REPLThemeManager.prototype.findOrCreate = () => ({ + id: 1, + name: 'theme', + role: 'development', + createdAtRuntime: true, + processing: true, + }) + return {REPLThemeManager} +}) + +describe('ensureValidPassword', () => { + beforeEach(() => { + vi.mocked(renderTextPrompt).mockResolvedValue('testPassword') + }) + + test('should skip prompt for password when correct storefront password is provided', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + + // When + await ensureValidPassword('correctPassword', 'test-store') + + // Then + expect(renderTextPrompt).not.toHaveBeenCalled() + }) + + test('should prompt for correct password when incorrect password is provided', async () => { + // Given + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(isStorefrontPasswordCorrect) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + + // When + await ensureValidPassword('incorrectPassword', 'test-store') + + // Then + expect(renderTextPrompt).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/theme/src/cli/utilities/repl/storefront-password-prompt.ts b/packages/theme/src/cli/utilities/repl/storefront-password-prompt.ts new file mode 100644 index 0000000000..026caaf88a --- /dev/null +++ b/packages/theme/src/cli/utilities/repl/storefront-password-prompt.ts @@ -0,0 +1,20 @@ +import {isStorefrontPasswordCorrect} from '../theme-environment/storefront-session.js' +import {renderTextPrompt} from '@shopify/cli-kit/node/ui' + +export async function ensureValidPassword(password: string | undefined, store: string) { + let finalPassword = password || (await promptPassword('Enter your theme password')) + + // eslint-disable-next-line no-await-in-loop + while (!(await isStorefrontPasswordCorrect(finalPassword, store))) { + // eslint-disable-next-line no-await-in-loop + finalPassword = await promptPassword('Incorrect password provided. Please try again') + } + return finalPassword +} + +async function promptPassword(prompt: string): Promise { + return renderTextPrompt({ + message: prompt, + password: true, + }) +} diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts index 2579eddd33..5d7cb78c0e 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-session.test.ts @@ -1,4 +1,8 @@ -import {getStorefrontSessionCookies, isStorefrontPasswordProtected} from './storefront-session.js' +import { + getStorefrontSessionCookies, + isStorefrontPasswordCorrect, + isStorefrontPasswordProtected, +} from './storefront-session.js' import {describe, expect, test, vi} from 'vitest' import {fetch} from '@shopify/cli-kit/node/http' @@ -126,7 +130,79 @@ describe('Storefront API', () => { headers: { ...mock.headers, raw: vi.fn().mockReturnValue({'set-cookie': setCookieArray}), + get: vi.fn().mockImplementation((key) => mock.headers?.[key]), }, } as any } + + describe('isStorefrontPasswordCorrect', () => { + test('returns true when the password is correct', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 302, + headers: { + location: 'https://store.myshopify.com/', + }, + }), + ) + + // When + const result = await isStorefrontPasswordCorrect('correct-password', 'store.myshopify.com') + + // Then + expect(result).toBe(true) + }) + + test('returns false when the password is incorrect', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 401, + }), + ) + + // When + const result = await isStorefrontPasswordCorrect('wrong-password', 'store.myshopify.com') + + // Then + expect(result).toBe(false) + }) + + test('returns false when the redirect location is incorrect', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 302, + headers: { + location: 'https://random-location.com/', + }, + }), + ) + + // When + const result = await isStorefrontPasswordCorrect('correct-password', 'store.myshopify.com') + + // Then + expect(result).toBe(false) + }) + + test('throws an error when the server responds with "Too Many Requests"', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce( + response({ + status: 429, + headers: { + 'retry-after': '60', + }, + }), + ) + + // When + const result = isStorefrontPasswordCorrect('wrong-password', 'store.myshopify.com') + + // Then + await expect(result).rejects.toThrow('Too many incorrect password attempts. Please try again after 60 seconds.') + }) + }) }) diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts index 19bf55ff3a..d9bd475618 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts @@ -4,7 +4,7 @@ import {fetch} from '@shopify/cli-kit/node/http' import {AbortError} from '@shopify/cli-kit/node/error' export async function isStorefrontPasswordProtected(storeURL: string): Promise { - const response = await fetch(storeURL, { + const response = await fetch(prependHttps(storeURL), { method: 'GET', redirect: 'manual', }) @@ -12,6 +12,29 @@ export async function isStorefrontPasswordProtected(storeURL: string): Promise