diff --git a/.github/workflows/e2e-pr.yml b/.github/workflows/e2e-pr.yml index 8002980eb2..9401bd9292 100644 --- a/.github/workflows/e2e-pr.yml +++ b/.github/workflows/e2e-pr.yml @@ -106,6 +106,7 @@ jobs: - name: Create .env with environment variables to be set on the MRT target run: |- echo "PWA_KIT_SLAS_CLIENT_SECRET=${{ secrets.ZZRF_002_SLAS_PRIVATE_CLIENT_SECRET }}" > ${{ vars.MRT_ENV_VARS_E2E_BASE_FILENAME }} + echo "OTEL_TRACING_ENABLED=true" >> ${{ vars.MRT_ENV_VARS_E2E_BASE_FILENAME }} # Call the e2e/scripts/update-mrt-target.js script to update the MRT target config and environment variables. # This script is a Node.js script that makes a PATCH request to the MRT API to update the MRT target config and environment variables. diff --git a/e2e/scripts/validate-generated-project.js b/e2e/scripts/validate-generated-project.js index 8f39f8f739..07fc87b85d 100644 --- a/e2e/scripts/validate-generated-project.js +++ b/e2e/scripts/validate-generated-project.js @@ -45,9 +45,9 @@ const validateExtensibilityConfig = async (project, templateVersion) => { const pkg = require(pkgPath) return new Promise((resolve, reject) => { if ( - !pkg.hasOwn('ccExtensibility') || - !pkg['ccExtensibility'].hasOwn('extends') || - !pkg['ccExtensibility'].hasOwn('overridesDir') || + !Object.hasOwn(pkg, 'ccExtensibility') || + !Object.hasOwn(pkg['ccExtensibility'], 'extends') || + !Object.hasOwn(pkg['ccExtensibility'], 'overridesDir') || pkg['ccExtensibility'].extends !== '@salesforce/retail-react-app' || pkg['ccExtensibility'].overridesDir !== 'overrides' ) { @@ -94,6 +94,15 @@ program ) .option('--templateVersion ', 'Template version used to generate the project') -program.parse(process.argv) +// Export functions for testing +module.exports = { + validateGeneratedArtifacts, + validateExtensibilityConfig, + main +} -main(program) +// Only run CLI when file is executed directly +if (require.main === module) { + program.parse(process.argv) + main(program) +} diff --git a/e2e/scripts/validate-generated-project.test.js b/e2e/scripts/validate-generated-project.test.js new file mode 100644 index 0000000000..dd502aa9f1 --- /dev/null +++ b/e2e/scripts/validate-generated-project.test.js @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +const fs = require('fs') +const path = require('path') + +// Mock the dependencies +jest.mock('fs') + +// Mocking the config.js file to allow testing with smaller arrays of expected artifacts +jest.mock('../config.js', () => ({ + GENERATED_PROJECTS_DIR: '../generated-projects', + EXPECTED_GENERATED_ARTIFACTS: { + 'retail-app-demo': ['package.json', 'node_modules', 'config'], + 'retail-app-ext': ['package.json', 'node_modules', 'overrides'] + } +})) +jest.mock('./utils.js', () => ({ + diffArrays: jest.fn() +})) + +// Import the functions to test +const {diffArrays} = require('./utils.js') +const {validateGeneratedArtifacts} = require('./validate-generated-project.js') + +describe('validateGeneratedArtifacts', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('resolves when all expected artifacts are present', async () => { + const project = 'retail-app-demo' + const expectedArtifacts = ['package.json', 'node_modules', 'config'] + const actualArtifacts = ['package.json', 'node_modules', 'config', 'extra-file'] + + fs.readdirSync.mockReturnValue(actualArtifacts) + diffArrays.mockReturnValue([]) + + const result = await validateGeneratedArtifacts(project) + + expect(fs.readdirSync).toHaveBeenCalledWith( + // path.sep is used to handle the platform-specific path separator. (Windows uses \ and other platforms use /) + expect.stringContaining(`generated-projects${path.sep}${project}`) + ) + expect(diffArrays).toHaveBeenCalledWith(expectedArtifacts, actualArtifacts) + expect(result).toBe(`Successfully validated generated artifacts for: ${project} `) + }) + + test('rejects when artifacts are missing', async () => { + const project = 'retail-app-demo' + const actualArtifacts = ['package.json', 'node_modules'] + const missingArtifacts = ['config'] + + fs.readdirSync.mockReturnValue(actualArtifacts) + diffArrays.mockReturnValue(missingArtifacts) + + await expect(validateGeneratedArtifacts(project)).rejects.toBe( + `Generated project (${project}) is missing one or more artifacts: ${missingArtifacts}` + ) + }) + + test('rejects when project directory does not exist', async () => { + const project = 'non-existent-project' + const error = new Error('ENOENT: no such file or directory') + + fs.readdirSync.mockImplementation(() => { + throw error + }) + + await expect(validateGeneratedArtifacts(project)).rejects.toBe( + `Generated project (${project}) is missing one or more artifacts: ${error}` + ) + }) + + test('handles project with no expected artifacts', async () => { + const project = 'unknown-project' + const actualArtifacts = ['some-file'] + + fs.readdirSync.mockReturnValue(actualArtifacts) + diffArrays.mockReturnValue([]) + + const result = await validateGeneratedArtifacts(project) + + expect(diffArrays).toHaveBeenCalledWith([], actualArtifacts) + expect(result).toBe(`Successfully validated generated artifacts for: ${project} `) + }) +}) + +// Since it requires files at runtime, we'll test the key validation logic +describe('validateExtensibilityConfig validation logic', () => { + test('validates Object.hasOwn usage for extensibility config', () => { + // Test the core validation logic that was fixed + const validConfig = { + ccExtensibility: { + extends: '@salesforce/retail-react-app', + overridesDir: 'overrides' + } + } + + const invalidConfigMissingProperty = { + ccExtensibility: { + extends: '@salesforce/retail-react-app' + // missing overridesDir + } + } + + const invalidConfigWrongExtends = { + ccExtensibility: { + extends: '@wrong/package', + overridesDir: 'overrides' + } + } + + expect(Object.hasOwn(validConfig, 'ccExtensibility')).toBe(true) + expect(Object.hasOwn(validConfig.ccExtensibility, 'extends')).toBe(true) + expect(Object.hasOwn(validConfig.ccExtensibility, 'overridesDir')).toBe(true) + + expect(Object.hasOwn(invalidConfigMissingProperty.ccExtensibility, 'overridesDir')).toBe( + false + ) + + const isValidConfig = (pkg) => { + return ( + Object.hasOwn(pkg, 'ccExtensibility') && + Object.hasOwn(pkg.ccExtensibility, 'extends') && + Object.hasOwn(pkg.ccExtensibility, 'overridesDir') && + pkg.ccExtensibility.extends === '@salesforce/retail-react-app' && + pkg.ccExtensibility.overridesDir === 'overrides' + ) + } + + expect(isValidConfig(validConfig)).toBe(true) + expect(isValidConfig(invalidConfigMissingProperty)).toBe(false) + expect(isValidConfig(invalidConfigWrongExtends)).toBe(false) + }) + + test('validates template version matching logic', () => { + const pkg = {version: '1.0.0'} + + const validateVersion = (pkg, templateVersion) => { + return !templateVersion || pkg.version === templateVersion + } + + expect(validateVersion(pkg, undefined)).toBe(true) + expect(validateVersion(pkg, null)).toBe(true) + expect(validateVersion(pkg, '1.0.0')).toBe(true) + expect(validateVersion(pkg, '2.0.0')).toBe(false) + }) +}) diff --git a/e2e/tests/opentelemetry-b3-tracing.spec.js b/e2e/tests/opentelemetry-b3-tracing.spec.js index cdde3a389a..fbecf349e6 100644 --- a/e2e/tests/opentelemetry-b3-tracing.spec.js +++ b/e2e/tests/opentelemetry-b3-tracing.spec.js @@ -16,7 +16,7 @@ test.beforeEach(async ({page}) => { }) }) -test.skip('should inject B3 headers when __server_timing param is passed', async ({page}) => { +test('should inject B3 headers when __server_timing param is passed', async ({page}) => { const url = `${config.RETAIL_APP_HOME}?__server_timing=true` const responseHeaders = [] diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index dad3c60b94..582bb8217f 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1326,8 +1326,8 @@ class Auth { callback_uri: parameters.callback_uri, hint: parameters.hint || 'cross_device', locale: parameters.locale, - code_challenge: parameters.code_challenge, - idp_name: parameters.idp_name + idp_name: parameters.idp_name, + ...(parameters.code_challenge && {code_challenge: parameters.code_challenge}) } } @@ -1357,8 +1357,8 @@ class Auth { channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, new_password: parameters.new_password, - code_verifier: parameters.code_verifier, - hint: parameters.hint + hint: parameters.hint, + code_verifier: parameters.code_verifier } } diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index 87f169d946..d48a582d6b 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -6,7 +6,7 @@ */ /* eslint-disable @typescript-eslint/no-var-requires */ const sites = require('./sites.js') -const {parseCommerceAgentSettings} = require('./utils.js') +const {parseSettings} = require('./utils.js') module.exports = { app: { @@ -23,9 +23,17 @@ module.exports = { // Commerce shopping agent configuration for embedded messaging service // This enables an agentic shopping experience in the application // This property accepts either a JSON string or a plain JavaScript object. - // The value is set from the COMMERCE_AGENT_SETTINGS environment variable. - // If the COMMERCE_AGENT_SETTINGS environment variable is not set, the feature is disabled. - commerceAgent: parseCommerceAgentSettings(process.env.COMMERCE_AGENT_SETTINGS), + commerceAgent: parseSettings(process.env.COMMERCE_AGENT_SETTINGS) || { + enabled: 'false', + askAgentOnSearch: 'false', + embeddedServiceName: '', + embeddedServiceEndpoint: '', + scriptSourceUrl: '', + scrt2Url: '', + salesforceOrgId: '', + commerceOrgId: '', + siteId: '' + }, // Customize how your 'site' and 'locale' are displayed in the url. url: { {{#if answers.project.demo.enableDemoSettings}} diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js b/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js new file mode 100644 index 0000000000..915b0a78f1 --- /dev/null +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * Safely parses settings from either a JSON string or object + * @param {string|object} settings - The settings + * @returns {object} Parsed settings object + */ +function parseSettings(settings) { + // If settings is already an object, return it + if (typeof settings === 'object' && settings !== null) { + return settings + } + + // If settings is a string, try to parse it + if (typeof settings === 'string') { + try { + return JSON.parse(settings) + } catch (error) { + console.warn('Invalid json format:', error.message) + return + } + } + + console.warn('Cannot parse settings from:', settings) + return +} + +module.exports = { + parseSettings +} diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js.hbs deleted file mode 100644 index bfde4cd31d..0000000000 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js.hbs +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -/** - * Safely parses commerce agent settings from either a JSON string or object - * @param {string|object} settings - The commerce agent settings - * @returns {object} Parsed commerce agent settings object - */ -function parseCommerceAgentSettings(settings) { - // Default configuration when no settings are provided - const defaultConfig = { - enabled: 'false', - askAgentOnSearch: 'false', - embeddedServiceName: '', - embeddedServiceEndpoint: '', - scriptSourceUrl: '', - scrt2Url: '', - salesforceOrgId: '', - commerceOrgId: '', - siteId: '' - } - - // If settings is already an object, return it - if (typeof settings === 'object' && settings !== null) { - return settings - } - - // If settings is a string, try to parse it - if (typeof settings === 'string') { - try { - return JSON.parse(settings) - } catch (error) { - console.warn('Invalid COMMERCE_AGENT_SETTINGS format, using defaults:', error.message) - return defaultConfig - } - } - - // If settings is undefined/null, return defaults - return defaultConfig -} - -module.exports = { - parseCommerceAgentSettings -} diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 053ab06dfc..1c0b3b8921 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -6,7 +6,7 @@ */ /* eslint-disable @typescript-eslint/no-var-requires */ const sites = require('./sites.js') -const {parseCommerceAgentSettings} = require('./utils.js') +const {parseSettings} = require('./utils.js') module.exports = { app: { @@ -23,9 +23,17 @@ module.exports = { // Commerce shopping agent configuration for embedded messaging service // This enables an agentic shopping experience in the application // This property accepts either a JSON string or a plain JavaScript object. - // The value is set from the COMMERCE_AGENT_SETTINGS environment variable. - // If the COMMERCE_AGENT_SETTINGS environment variable is not set, the feature is disabled. - commerceAgent: parseCommerceAgentSettings(process.env.COMMERCE_AGENT_SETTINGS), + commerceAgent: parseSettings(process.env.COMMERCE_AGENT_SETTINGS) || { + enabled: 'false', + askAgentOnSearch: 'false', + embeddedServiceName: '', + embeddedServiceEndpoint: '', + scriptSourceUrl: '', + scrt2Url: '', + salesforceOrgId: '', + commerceOrgId: '', + siteId: '' + }, // Customize settings for your url url: { {{#if answers.project.demo.enableDemoSettings}} diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js new file mode 100644 index 0000000000..915b0a78f1 --- /dev/null +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * Safely parses settings from either a JSON string or object + * @param {string|object} settings - The settings + * @returns {object} Parsed settings object + */ +function parseSettings(settings) { + // If settings is already an object, return it + if (typeof settings === 'object' && settings !== null) { + return settings + } + + // If settings is a string, try to parse it + if (typeof settings === 'string') { + try { + return JSON.parse(settings) + } catch (error) { + console.warn('Invalid json format:', error.message) + return + } + } + + console.warn('Cannot parse settings from:', settings) + return +} + +module.exports = { + parseSettings +} diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js.hbs deleted file mode 100644 index bfde4cd31d..0000000000 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js.hbs +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -/** - * Safely parses commerce agent settings from either a JSON string or object - * @param {string|object} settings - The commerce agent settings - * @returns {object} Parsed commerce agent settings object - */ -function parseCommerceAgentSettings(settings) { - // Default configuration when no settings are provided - const defaultConfig = { - enabled: 'false', - askAgentOnSearch: 'false', - embeddedServiceName: '', - embeddedServiceEndpoint: '', - scriptSourceUrl: '', - scrt2Url: '', - salesforceOrgId: '', - commerceOrgId: '', - siteId: '' - } - - // If settings is already an object, return it - if (typeof settings === 'object' && settings !== null) { - return settings - } - - // If settings is a string, try to parse it - if (typeof settings === 'string') { - try { - return JSON.parse(settings) - } catch (error) { - console.warn('Invalid COMMERCE_AGENT_SETTINGS format, using defaults:', error.message) - return defaultConfig - } - } - - // If settings is undefined/null, return defaults - return defaultConfig -} - -module.exports = { - parseCommerceAgentSettings -} diff --git a/packages/pwa-kit-mcp/CHANGELOG.md b/packages/pwa-kit-mcp/CHANGELOG.md index a39fef448d..cab43f6ab8 100644 --- a/packages/pwa-kit-mcp/CHANGELOG.md +++ b/packages/pwa-kit-mcp/CHANGELOG.md @@ -1,4 +1,5 @@ ## v0.2.1-dev (Aug 11, 2025) +- Normalize tool names; Add introduction section for PWA Kit MCP and resize the images on README. [#3239](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3239) ## v0.1.1 (Aug 11, 2025) - Add missing `shelljs` dependency. [#3053](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3053) diff --git a/packages/pwa-kit-mcp/README.md b/packages/pwa-kit-mcp/README.md index 0902a7a1af..406b00a183 100644 --- a/packages/pwa-kit-mcp/README.md +++ b/packages/pwa-kit-mcp/README.md @@ -12,17 +12,22 @@ It allows AI agents to query context-aware services like this server to help dev 👉 **[Read more at modelcontextprotocol.io](https://modelcontextprotocol.io/)** +## What is PWA-Kit-MCP? + +PWA-Kit-MCP is a local STDIO MCP Server that communicates via STDIO and operates in conjunction with a running local process, making it a fully locally installed MCP server. It provides an initial suite of MCP tools intended to standardize and optimize the developer workflow for PWA Kit storefront development. These tools facilitate project creation, supply development guidelines, enable the generation of new components and pages, and support site validation through performance and accessibility testing. + + ## 🧰 Features The PWA Kit MCP Server offers the following intelligent tools tailored to Salesforce Commerce Cloud PWA development: -* **`create_app_guidelines`**: +* **`create_storefront_app`**: Guides agents and developers through creating a new PWA Kit project with `@salesforce/pwa-kit-create-app`. -* **`create_new_sample_component`**: +* **`create_sample_component`**: Walks developers through a brief Q\&A to scaffold a component using the commerce data model, layout, and structure. -* **`create_sample_storefront_page`**: +* **`create_sample_page`**: Interactive tool to generate a new PWA storefront page with custom routing and components. * **`development_guidelines`**: @@ -32,10 +37,6 @@ The PWA Kit MCP Server offers the following intelligent tools tailored to Salesf Runs performance and accessibility audits on a provided site URL. *Example: `https://pwa-kit.mobify-storefront.com`* -* **`git_version_control`**: - Manages the version control of your project using git. - If the project is not already a git repo, project files will be committed as a new local git repo together with a basic .gitignore. If the project is already a git repo, just commit the changes in the project. - ## ▶️ Running the MCP Server @@ -44,10 +45,10 @@ The PWA Kit MCP Server offers the following intelligent tools tailored to Salesf 1. Open **Cursor**. 2. Navigate to **Settings > Cursor Settings...** -![](https://raw.githubusercontent.com/SalesforceCommerceCloud/pwa-kit/refs/heads/develop/packages/pwa-kit-mcp/docs/images/cursor-settings.png) +Cursor Settings Screenshot 3. Go to **Tools & Integrations > MCP Tools > New MCP Server** -![](https://raw.githubusercontent.com/SalesforceCommerceCloud/pwa-kit/refs/heads/develop/packages/pwa-kit-mcp/docs/images/cursor-mcp-tools.png) +Cursor MCP Tools Screenshot 4. Update your `mcp.json` like this (edit the placeholders as needed): ```json @@ -86,7 +87,7 @@ Then send JSON-RPC requests like: ```json {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}} -{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "create_new_component", "arguments": {}}} +{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "create_sample_component", "arguments": {}}} ``` --- @@ -127,6 +128,5 @@ the output on "MCP Logs". | `package.json` | Node.js dependencies and project scripts | | `mcp.json` | MCP client configuration (used by Cursor or other IDEs) | | `src/server/` | Main server entry point (`server.js`) | -| `src/tools/` | Contains all MCP tools like `create-app-guideline`, `site-test`, etc. | -| `src/utils/` | Shared utility functions | -| ` \ No newline at end of file +| `src/tools/` | Contains all MCP tools like `create-storefront-app`, `site-test`, etc. | +| `src/utils/` | Shared utility functions | \ No newline at end of file diff --git a/packages/pwa-kit-mcp/src/server/server.js b/packages/pwa-kit-mcp/src/server/server.js index 3fc74d48c1..c2254bf3bb 100644 --- a/packages/pwa-kit-mcp/src/server/server.js +++ b/packages/pwa-kit-mcp/src/server/server.js @@ -14,8 +14,7 @@ import { CreateNewComponentTool, DeveloperGuidelinesTool, TestWithPlaywrightTool, - CreateNewPageTool, - VersionControlGitTool + CreateNewPageTool } from '../tools' // NOTE: This is a workaround to import JSON files as ES modules. @@ -39,7 +38,7 @@ class PwaStorefrontMCPServerHighLevel { } ) this.createNewComponentTool = new CreateNewComponentTool() - this.versionControlGitTool = new VersionControlGitTool() + this.createAppGuidelinesTool = new CreateAppGuidelinesTool() this.testWithPlaywrightTool = new TestWithPlaywrightTool() this.setupTools() } @@ -47,10 +46,10 @@ class PwaStorefrontMCPServerHighLevel { setupTools() { // Register CreateProjectTool this.server.tool( - CreateAppGuidelinesTool.name, - CreateAppGuidelinesTool.description, - CreateAppGuidelinesTool.inputSchema, - CreateAppGuidelinesTool.fn + this.createAppGuidelinesTool.name, + this.createAppGuidelinesTool.description, + this.createAppGuidelinesTool.inputSchema, + this.createAppGuidelinesTool.fn ) this.server.tool( DeveloperGuidelinesTool.name, @@ -79,12 +78,6 @@ class PwaStorefrontMCPServerHighLevel { CreateNewPageTool.inputSchema, CreateNewPageTool.handler ) - this.server.tool( - this.versionControlGitTool.name, - this.versionControlGitTool.description, - this.versionControlGitTool.inputSchema, - this.versionControlGitTool.handler - ) } async run() { diff --git a/packages/pwa-kit-mcp/src/tools/create-app-guideline.js b/packages/pwa-kit-mcp/src/tools/create-app-guideline.js index d605faaae5..9b0dbf8d03 100644 --- a/packages/pwa-kit-mcp/src/tools/create-app-guideline.js +++ b/packages/pwa-kit-mcp/src/tools/create-app-guideline.js @@ -7,6 +7,9 @@ // Project dependencies import {EmptyJsonSchema, getCreateAppCommand, isMonoRepo, runCommand} from '../utils/utils' +import shell from 'shelljs' +import fs from 'fs' +import path from 'path' const CREATE_APP_COMMAND = getCreateAppCommand() const DISPLAY_PROGRAM_FLAG = '--displayProgram' @@ -59,12 +62,15 @@ If the user requests a project using a **template**: - Presets and templates are mutually exclusive paths. Do not offer both options unless explicitly requested. - Do not pass any flags to the \`${CREATE_APP_COMMAND}\` CLI tool that are not listed in the program.json options". - Use the \`${COMMAND_RUNNER}\` command to run the \`${CREATE_APP_COMMAND}\` CLI tool when creating a new project. -- After project creation, prompt the user if **they want to do version control through git** using the **version_control_git** MCP tool. +- After project creation, **MANDATORY**: Always ask the user whether they want to do git version control and commit the files locally.** +- If the user replies "yes" or confirms they want version control: + - Use the integrated version control function and call the \`setupVersionControl\` function to handle git setup +- **IMPORTANT**: You cannot skip asking the user - this interaction is **mandatory** for every project creation. ` -export default { - name: 'create_app_guidelines', - description: ` +class CreateAppGuidelinesTool { + name = 'create_storefront_app' + description = ` This tool is used to provide the agent with the instructions on how to use the @salesforce/pwa-kit-create-app CLI tool to create a new PWA Kit projects. @@ -74,9 +80,102 @@ Example Triggers: - "Create a new PWA Kit app" - "Start a new storefront using a preset" - "What templates are available for PWA Kit?" -- "What presets are available for PWA Kit?"`, - inputSchema: EmptyJsonSchema, - fn: async () => { +- "What presets are available for PWA Kit?"` + inputSchema = EmptyJsonSchema + + /** + * Handles the version control of your project using git. + * If the directory is not a git repo, it creates a basic .gitignore, runs git init, adds all files, and makes an initial commit. + * If already a git repo, it skips initialization and .gitignore creation, and just adds and commits all files locally. + * @param {string} directory - The directory to initialize the git repository in. + */ + handleGitVersionControl(directory) { + if (!shell.which('git')) { + throw new Error( + 'git is not installed or not found in PATH. Please install git to initialize a repository.' + ) + } + const isGitRepo = fs.existsSync(path.join(directory, '.git')) + let result + if (isGitRepo) { + // Already a git repo: only add and commit + result = shell.exec('git add .', {cwd: directory, silent: true}) + if (result.code !== 0) { + throw new Error(`git add failed: ${result.stderr || result.stdout}`) + } + result = shell.exec('git commit -m "Initial commit"', {cwd: directory, silent: true}) + if (result.code !== 0) { + throw new Error(`git commit failed: ${result.stderr || result.stdout}`) + } + } else { + // Not a git repo: create .gitignore, init, add, commit + this.createBasicGitignore(directory) + result = shell.exec('git init', {cwd: directory, silent: true}) + if (result.code !== 0) { + throw new Error(`git init failed: ${result.stderr || result.stdout}`) + } + result = shell.exec('git add .', {cwd: directory, silent: true}) + if (result.code !== 0) { + throw new Error(`git add failed: ${result.stderr || result.stdout}`) + } + result = shell.exec('git commit -m "Initial commit"', {cwd: directory, silent: true}) + if (result.code !== 0) { + throw new Error(`git commit failed: ${result.stderr || result.stdout}`) + } + } + } + + /** + * Creates a basic .gitignore file in the given directory. + * @param {string} directory - The directory to create the .gitignore file in. + */ + createBasicGitignore(directory) { + const gitignorePath = path.join(directory, '.gitignore') + if (!fs.existsSync(gitignorePath)) { + fs.writeFileSync( + gitignorePath, + `# Node +node_modules/ +.env +.DS_Store +npm-debug.log +yarn-debug.log +yarn-error.log +coverage/ +dist/ +build/ +.next/ +out/ +logs/ +*.log +.idea/ +.vscode/ +` + ) + } + } + + /** + * Integrated version control function that can be called after project creation + * @param {string} projectDirectory - The directory where the project was created + * @returns {Object} Result object with success status and message + */ + async setupVersionControl(projectDirectory) { + try { + this.handleGitVersionControl(projectDirectory) + return { + success: true, + message: 'Git version control initialized and committed locally.' + } + } catch (error) { + return { + success: false, + message: `Error: ${error.message}` + } + } + } + + fn = async () => { // Run the display program and get the output. const programOutput = await runCommand(COMMAND_RUNNER, [ ...(COMMAND_RUNNER === 'npx' ? ['--yes'] : []), @@ -110,3 +209,5 @@ Example Triggers: } } } + +export default CreateAppGuidelinesTool diff --git a/packages/pwa-kit-mcp/src/tools/create-app-guideline.test.js b/packages/pwa-kit-mcp/src/tools/create-app-guideline.test.js index ba32aec698..ecb49d8376 100644 --- a/packages/pwa-kit-mcp/src/tools/create-app-guideline.test.js +++ b/packages/pwa-kit-mcp/src/tools/create-app-guideline.test.js @@ -4,47 +4,82 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import CreateAppGuidelineTool from './create-app-guideline' +import CreateAppGuidelinesTool from './create-app-guideline.js' import {EmptyJsonSchema} from '../utils/utils' +import shell from 'shelljs' +import fs from 'fs' +import path from 'path' +// Mock dependencies jest.mock('../utils/utils', () => { const originalModule = jest.requireActual('../utils/utils') - // eslint-disable-next-line @typescript-eslint/no-var-requires - const path = require('path') - const mockScriptPath = path.resolve('../pwa-kit-create-app/scripts/create-mobify-app.js') + const mockScriptPath = '../pwa-kit-create-app/scripts/create-mobify-app.js' return { ...originalModule, isMonoRepo: jest.fn(() => true), - getCreateAppCommand: jest.fn(() => mockScriptPath) + getCreateAppCommand: jest.fn(() => mockScriptPath), + runCommand: jest.fn().mockResolvedValue( + JSON.stringify({ + data: {}, + metadata: {description: 'CLI Description'}, + schemas: {} + }) + ) } }) -describe('PWA Create App Guidelines', () => { - describe('CreateAppGuidelineTool', () => { +jest.mock('shelljs', () => ({ + which: jest.fn(), + exec: jest.fn() +})) + +jest.mock('fs', () => ({ + existsSync: jest.fn(), + writeFileSync: jest.fn() +})) + +jest.mock('path', () => ({ + join: jest.fn() +})) + +describe('CreateAppGuidelinesTool', () => { + let tool + let mockUtils + + beforeEach(() => { + jest.clearAllMocks() + tool = new CreateAppGuidelinesTool() + mockUtils = require('../utils/utils') + + // Reset mocks + shell.which.mockReset() + shell.exec.mockReset() + fs.existsSync.mockReset() + fs.writeFileSync.mockReset() + path.join.mockReset() + }) + + describe('Tool Structure', () => { it('should have correct structure', () => { - expect(CreateAppGuidelineTool).toMatchObject({ - name: 'create_app_guidelines', - description: ` - -This tool is used to provide the agent with the instructions on how to use the @salesforce/pwa-kit-create-app CLI tool to create a new PWA Kit projects. - -Do not attempt to create a project without using this tool first. - -Example Triggers: -- "Create a new PWA Kit app" -- "Start a new storefront using a preset" -- "What templates are available for PWA Kit?" -- "What presets are available for PWA Kit?"`, + expect(tool).toMatchObject({ + name: 'create_storefront_app', + description: expect.stringContaining( + 'This tool is used to provide the agent with the instructions' + ), inputSchema: EmptyJsonSchema, fn: expect.any(Function) }) }) + it('should be instantiable', () => { + expect(tool).toBeInstanceOf(CreateAppGuidelinesTool) + }) + }) + + describe('Main Functionality', () => { it('should return guidelines content when executed', async () => { - // NOTE: THIS TEST IS SIMPLY A SANITY CHECK TO ENSURE THE TOOL IS WORKING. - // IT DOES NOT TEST THE CONTENT OF THE GUIDELINES IN ITS ENTIRETY. - const result = await CreateAppGuidelineTool.fn() + const result = await tool.fn() expect(result).toEqual({ content: [ @@ -57,7 +92,7 @@ Example Triggers: }) it('should include all major sections in the guidelines', async () => { - const result = await CreateAppGuidelineTool.fn() + const result = await tool.fn() const guidelineText = result.content[0].text const requiredSections = [ @@ -72,6 +107,199 @@ Example Triggers: expect(guidelineText).toContain(section) }) }) + + it('should call runCommand with correct parameters', async () => { + await tool.fn() + + expect(mockUtils.runCommand).toHaveBeenCalledWith('node', [ + '../pwa-kit-create-app/scripts/create-mobify-app.js', + '--displayProgram' + ]) + }) + }) + + describe('Git Version Control', () => { + const testDirectory = '/test/project/directory' + + beforeEach(() => { + path.join.mockImplementation((...args) => args.join('/')) + }) + + describe('handleGitVersionControl', () => { + it('should throw error if git is not installed', () => { + shell.which.mockReturnValue(false) + + expect(() => { + tool.handleGitVersionControl(testDirectory) + }).toThrow( + 'git is not installed or not found in PATH. Please install git to initialize a repository.' + ) + }) + + it('should handle existing git repository correctly', () => { + shell.which.mockReturnValue(true) + fs.existsSync.mockReturnValue(true) // .git exists + shell.exec.mockReturnValue({code: 0, stdout: '', stderr: ''}) + + expect(() => { + tool.handleGitVersionControl(testDirectory) + }).not.toThrow() + + expect(shell.exec).toHaveBeenCalledWith('git add .', { + cwd: testDirectory, + silent: true + }) + expect(shell.exec).toHaveBeenCalledWith('git commit -m "Initial commit"', { + cwd: testDirectory, + silent: true + }) + expect(shell.exec).not.toHaveBeenCalledWith('git init', expect.any(Object)) + }) + + it('should handle new git repository correctly', () => { + shell.which.mockReturnValue(true) + fs.existsSync.mockReturnValue(false) // .git doesn't exist + shell.exec.mockReturnValue({code: 0, stdout: '', stderr: ''}) + + expect(() => { + tool.handleGitVersionControl(testDirectory) + }).not.toThrow() + + expect(shell.exec).toHaveBeenCalledWith('git init', { + cwd: testDirectory, + silent: true + }) + expect(shell.exec).toHaveBeenCalledWith('git add .', { + cwd: testDirectory, + silent: true + }) + expect(shell.exec).toHaveBeenCalledWith('git commit -m "Initial commit"', { + cwd: testDirectory, + silent: true + }) + }) + + it('should throw error if git add fails', () => { + shell.which.mockReturnValue(true) + fs.existsSync.mockReturnValue(false) + shell.exec + .mockReturnValueOnce({code: 0, stdout: '', stderr: ''}) // git init success + .mockReturnValueOnce({code: 1, stdout: '', stderr: 'git add failed'}) // git add fails + + expect(() => { + tool.handleGitVersionControl(testDirectory) + }).toThrow('git add failed: git add failed') + }) + + it('should throw error if git commit fails', () => { + shell.which.mockReturnValue(true) + fs.existsSync.mockReturnValue(false) + shell.exec + .mockReturnValueOnce({code: 0, stdout: '', stderr: ''}) // git init success + .mockReturnValueOnce({code: 0, stdout: '', stderr: ''}) // git add success + .mockReturnValueOnce({code: 1, stdout: '', stderr: 'git commit failed'}) // git commit fails + + expect(() => { + tool.handleGitVersionControl(testDirectory) + }).toThrow('git commit failed: git commit failed') + }) + + it('should throw error if git init fails', () => { + shell.which.mockReturnValue(true) + fs.existsSync.mockReturnValue(false) + shell.exec.mockReturnValue({code: 1, stdout: '', stderr: 'git init failed'}) + + expect(() => { + tool.handleGitVersionControl(testDirectory) + }).toThrow('git init failed: git init failed') + }) + }) + + describe('createBasicGitignore', () => { + it('should create .gitignore file if it does not exist', () => { + const gitignorePath = `${testDirectory}/.gitignore` + fs.existsSync.mockReturnValue(false) + + tool.createBasicGitignore(testDirectory) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + gitignorePath, + expect.stringContaining('# Node\nnode_modules/') + ) + }) + + it('should not create .gitignore file if it already exists', () => { + const gitignorePath = `${testDirectory}/.gitignore` + fs.existsSync.mockReturnValue(true) + + tool.createBasicGitignore(testDirectory) + + expect(fs.writeFileSync).not.toHaveBeenCalled() + }) + + it('should create .gitignore with correct content', () => { + fs.existsSync.mockReturnValue(false) + + tool.createBasicGitignore(testDirectory) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('# Node\nnode_modules/\n.env\n.DS_Store') + ) + }) + }) + + describe('setupVersionControl', () => { + it('should return success when git operations complete successfully', async () => { + shell.which.mockReturnValue(true) + fs.existsSync.mockReturnValue(false) + shell.exec.mockReturnValue({code: 0, stdout: '', stderr: ''}) + + const result = await tool.setupVersionControl(testDirectory) + + expect(result).toEqual({ + success: true, + message: 'Git version control initialized and committed locally.' + }) + }) + + it('should return error when git operations fail', async () => { + shell.which.mockReturnValue(false) + + const result = await tool.setupVersionControl(testDirectory) + + expect(result).toEqual({ + success: false, + message: + 'Error: git is not installed or not found in PATH. Please install git to initialize a repository.' + }) + }) + + it('should handle errors gracefully and return error message', async () => { + const errorMessage = 'Test error message' + shell.which.mockReturnValue(true) + fs.existsSync.mockReturnValue(false) + shell.exec.mockImplementation(() => { + throw new Error(errorMessage) + }) + + const result = await tool.setupVersionControl(testDirectory) + + expect(result).toEqual({ + success: false, + message: `Error: ${errorMessage}` + }) + }) + }) + }) + + describe('Error Handling', () => { + it('should handle runCommand errors gracefully', async () => { + const errorMessage = 'Command execution failed' + mockUtils.runCommand.mockRejectedValue(new Error(errorMessage)) + + await expect(tool.fn()).rejects.toThrow(errorMessage) + }) }) }) diff --git a/packages/pwa-kit-mcp/src/tools/create-new-component.js b/packages/pwa-kit-mcp/src/tools/create-new-component.js index 8c05723ce8..a41e659186 100644 --- a/packages/pwa-kit-mcp/src/tools/create-new-component.js +++ b/packages/pwa-kit-mcp/src/tools/create-new-component.js @@ -43,7 +43,7 @@ What is the main purpose of this component? Reply with exactly one of the follow class CreateNewComponentTool { constructor() { - this.name = 'create_new_component' + this.name = 'create_sample_component' this.description = 'Create a sample React component. Gather information from user for the MCP tool parameters **one at a time**, in a natural and conversational way. Do **not** ask all the questions at once.' this.inputSchema = { diff --git a/packages/pwa-kit-mcp/src/tools/create-new-page-tool.js b/packages/pwa-kit-mcp/src/tools/create-new-page-tool.js index 2dee80bed2..c3a6c563f2 100644 --- a/packages/pwa-kit-mcp/src/tools/create-new-page-tool.js +++ b/packages/pwa-kit-mcp/src/tools/create-new-page-tool.js @@ -71,7 +71,7 @@ const systemPromptForUnfoundComponents = (unfoundComponents) => class CreateNewPageTool { constructor() { - this.name = 'create_sample_storefront_page' + this.name = 'create_sample_page' this.description = 'Create a sample PWA storefront page. Gather information from user for the MCP tool parameters **one at a time**, in a natural and conversational way. Do **not** ask all the questions at once.' this.inputSchema = { diff --git a/packages/pwa-kit-mcp/src/tools/index.js b/packages/pwa-kit-mcp/src/tools/index.js index d9ff2537e6..989401a129 100644 --- a/packages/pwa-kit-mcp/src/tools/index.js +++ b/packages/pwa-kit-mcp/src/tools/index.js @@ -11,7 +11,6 @@ export {default as CreateNewComponentTool} from './create-new-component.js' export {default as DeveloperGuidelinesTool} from './developer-guideline.js' export {TestWithPlaywrightTool} from './site-test.js' export {default as CreateNewPageTool} from './create-new-page-tool.js' -export {default as VersionControlGitTool} from './version-control-git.js' // Re-export individual test functions export {runAccessibilityTest} from './site-test-accessibility.js' diff --git a/packages/pwa-kit-mcp/src/tools/version-control-git.js b/packages/pwa-kit-mcp/src/tools/version-control-git.js deleted file mode 100644 index 246835b0c8..0000000000 --- a/packages/pwa-kit-mcp/src/tools/version-control-git.js +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import {z} from 'zod' -import shell from 'shelljs' -import fs from 'fs' -import path from 'path' - -class VersionControlGitTool { - name = 'version_control_git' - description = 'Manages the version control using git' - inputSchema = { - initGit: z - .boolean() - .describe('Do you want to commit your files locally through git? (yes/no)'), - current_project_directory: z - .string() - .describe( - 'The absolute path to the current working directory where git actions will be performed.' - ) - } - - handler = async (args) => { - try { - if (!args || !args.initGit || !args.current_project_directory) { - return { - role: 'system', - content: [] - } - } - const {current_project_directory} = args - this.handleGitVersionControl(current_project_directory) - return { - role: 'system', - content: [ - { - type: 'text', - text: 'Git version control initialized and committed locally.' - } - ] - } - } catch (error) { - return { - role: 'system', - content: [{type: 'text', text: `Error: ${error.message}`}] - } - } - } - - /** - * Handles the version control of your project using git. - * If the directory is not a git repo, it creates a basic .gitignore, runs git init, adds all files, and makes an initial commit. - * If already a git repo, it skips initialization and .gitignore creation, and just adds and commits all files locally. - * @param {string} directory - The directory to initialize the git repository in. - */ - handleGitVersionControl(directory) { - if (!shell.which('git')) { - throw new Error( - 'git is not installed or not found in PATH. Please install git to initialize a repository.' - ) - } - const isGitRepo = fs.existsSync(path.join(directory, '.git')) - let result - if (isGitRepo) { - // Already a git repo: only add and commit - result = shell.exec('git add .', {cwd: directory, silent: true}) - if (result.code !== 0) - throw new Error(`git add failed: ${result.stderr || result.stdout}`) - result = shell.exec('git commit -m "Initial commit"', {cwd: directory, silent: true}) - if (result.code !== 0) - throw new Error(`git commit failed: ${result.stderr || result.stdout}`) - } else { - // Not a git repo: create .gitignore, init, add, commit - this.createBasicGitignore(directory) - result = shell.exec('git init', {cwd: directory, silent: true}) - if (result.code !== 0) - throw new Error(`git init failed: ${result.stderr || result.stdout}`) - result = shell.exec('git add .', {cwd: directory, silent: true}) - if (result.code !== 0) - throw new Error(`git add failed: ${result.stderr || result.stdout}`) - result = shell.exec('git commit -m "Initial commit"', {cwd: directory, silent: true}) - if (result.code !== 0) - throw new Error(`git commit failed: ${result.stderr || result.stdout}`) - } - } - - /** - * Creates a basic .gitignore file in the given directory. - * @param {string} directory - The directory to create the .gitignore file in. - */ - createBasicGitignore(directory) { - const gitignorePath = path.join(directory, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync( - gitignorePath, - `# Node -node_modules/ -.env -.DS_Store -npm-debug.log -yarn-debug.log -yarn-error.log -coverage/ -dist/ -build/ -.next/ -out/ -logs/ -*.log -.idea/ -.vscode/ -` - ) - } - } -} - -export default VersionControlGitTool diff --git a/packages/pwa-kit-mcp/src/tools/version-control-git.test.js b/packages/pwa-kit-mcp/src/tools/version-control-git.test.js deleted file mode 100644 index 2ff27aa898..0000000000 --- a/packages/pwa-kit-mcp/src/tools/version-control-git.test.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import VersionControlGitTool from './version-control-git.js' -import shell from 'shelljs' -import fs from 'fs' -import path from 'path' - -describe('VersionControlGitTool', () => { - let tool - const tempDir = path.join(__dirname, '__test_tmp__') - beforeAll(() => { - if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir) - }) - afterAll(() => { - if (fs.existsSync(tempDir)) fs.rmSync(tempDir, {recursive: true, force: true}) - }) - beforeEach(() => { - tool = new VersionControlGitTool() - }) - - it('returns empty content if args are missing', async () => { - const result = await tool.handler() - expect(result.content).toEqual([]) - }) - - it('returns empty content if current_project_directory is missing', async () => { - const result = await tool.handler({initGit: true}) - expect(result.content).toEqual([]) - }) - - it('returns error if git is not installed', async () => { - jest.spyOn(shell, 'which').mockReturnValueOnce(false) - const result = await tool.handler({initGit: true, current_project_directory: tempDir}) - expect(result.content[0].text).toMatch(/git is not installed/) - shell.which.mockRestore() - }) - - it('runs add/commit if already a git repo', async () => { - jest.spyOn(shell, 'which').mockReturnValue(true) - jest.spyOn(fs, 'existsSync').mockImplementation((p) => p.endsWith('.git')) - jest.spyOn(shell, 'exec').mockImplementation(() => ({code: 0, stdout: '', stderr: ''})) - const result = await tool.handler({initGit: true, current_project_directory: tempDir}) - expect(result.content[0].text).toMatch( - /Git version control initialized and committed locally\./ - ) - shell.exec.mockRestore() - shell.which.mockRestore() - fs.existsSync.mockRestore() - }) - - it('runs full flow if not a git repo', async () => { - jest.spyOn(shell, 'which').mockReturnValue(true) - jest.spyOn(fs, 'existsSync').mockReturnValue(false) - jest.spyOn(shell, 'exec').mockImplementation(() => ({code: 0, stdout: '', stderr: ''})) - jest.spyOn(tool, 'createBasicGitignore').mockImplementation(() => {}) - const result = await tool.handler({initGit: true, current_project_directory: tempDir}) - expect(result.content[0].text).toMatch( - /Git version control initialized and committed locally\./ - ) - shell.exec.mockRestore() - shell.which.mockRestore() - fs.existsSync.mockRestore() - tool.createBasicGitignore.mockRestore() - }) - - it('returns error if git command fails', async () => { - jest.spyOn(shell, 'which').mockReturnValue(true) - jest.spyOn(fs, 'existsSync').mockReturnValue(false) - jest.spyOn(shell, 'exec').mockImplementation(() => ({code: 1, stdout: '', stderr: 'fail'})) - jest.spyOn(tool, 'createBasicGitignore').mockImplementation(() => {}) - const result = await tool.handler({initGit: true, current_project_directory: tempDir}) - expect(result.content[0].text).toMatch(/git init failed|git add failed|git commit failed/) - shell.exec.mockRestore() - shell.which.mockRestore() - fs.existsSync.mockRestore() - tool.createBasicGitignore.mockRestore() - }) -}) diff --git a/packages/template-mrt-reference-app/app/isolation-actions.js b/packages/template-mrt-reference-app/app/isolation-actions.js index ae12af8731..e3fa56042d 100644 --- a/packages/template-mrt-reference-app/app/isolation-actions.js +++ b/packages/template-mrt-reference-app/app/isolation-actions.js @@ -8,7 +8,7 @@ const {LambdaClient, InvokeCommand} = require('@aws-sdk/client-lambda') const {S3Client, GetObjectCommand} = require('@aws-sdk/client-s3') -const {CloudWatchLogsClient, PutLogEventsCommand} = require('@aws-sdk/client-cloudwatch-logs') +const {CloudWatchLogsClient, CreateLogStreamCommand} = require('@aws-sdk/client-cloudwatch-logs') export const isolationOriginLambdaTest = async (input) => { const client = new LambdaClient() @@ -25,7 +25,8 @@ export const isolationOriginLambdaTest = async (input) => { } export const isolationS3Test = async (input) => { - const client = new S3Client({region: 'us-east-1'}) + const region = process.env.AWS_REGION || 'us-east-1' + const client = new S3Client({region: region}) try { await client.send(new GetObjectCommand(input)) } catch (e) { @@ -41,16 +42,12 @@ export const isolationS3Test = async (input) => { export const isolationLogsTest = async (input) => { const client = new CloudWatchLogsClient() try { + const randomString = Math.random().toString(36).slice(2, 7) const inputValues = { ...input, - logEvents: [ - { - timestamp: Date.now(), - message: 'This is plastic' - } - ] + logStreamName: `new_log_stream_${randomString}` } - await client.send(new PutLogEventsCommand(inputValues)) + await client.send(new CreateLogStreamCommand(inputValues)) } catch (e) { if (e.name === 'AccessDeniedException') { return true @@ -65,7 +62,7 @@ export const executeIsolationTests = async (params) => { const tests = [ {name: 'origin', keys: ['FunctionName'], fn: isolationOriginLambdaTest}, {name: 'storage', keys: ['Bucket', 'Key'], fn: isolationS3Test}, - {name: 'logs', keys: ['logGroupName', 'logStreamName'], fn: isolationLogsTest} + {name: 'logs', keys: ['logGroupName'], fn: isolationLogsTest} ] let results = {} for (const test of tests) { diff --git a/packages/template-mrt-reference-app/app/ssr.test.js b/packages/template-mrt-reference-app/app/ssr.test.js index 0eb727af13..cccbaf47ba 100644 --- a/packages/template-mrt-reference-app/app/ssr.test.js +++ b/packages/template-mrt-reference-app/app/ssr.test.js @@ -11,7 +11,7 @@ const {LambdaClient, InvokeCommand} = require('@aws-sdk/client-lambda') const {S3Client, GetObjectCommand} = require('@aws-sdk/client-s3') const { CloudWatchLogsClient, - PutLogEventsCommand, + CreateLogStreamCommand, AccessDeniedException } = require('@aws-sdk/client-cloudwatch-logs') const {mockClient} = require('aws-sdk-client-mock') @@ -37,7 +37,8 @@ describe('server', () => { DEPLOY_TARGET: 'test', EXTERNAL_DOMAIN_NAME: 'test.com', MOBIFY_PROPERTY_ID: 'test', - AWS_LAMBDA_FUNCTION_NAME: 'pretend-to-be-remote' + AWS_LAMBDA_FUNCTION_NAME: 'pretend-to-be-remote', + AWS_REGION: 'us-east-2' }) const ssr = require('./ssr') @@ -99,8 +100,8 @@ describe('server', () => { jest.spyOn(console, 'error') lambdaMock.on(InvokeCommand).rejects(new AccessDeniedException()) s3Mock.on(GetObjectCommand).rejects(new AccessDenied()) - logsMock.on(PutLogEventsCommand).rejects(new AccessDeniedException()) - const params = `FunctionName=name&Bucket=bucket&Key=key&logGroupName=lgName&logStreamName=lsName` + logsMock.on(CreateLogStreamCommand).rejects(new AccessDeniedException()) + const params = `FunctionName=name&Bucket=bucket&Key=key&logGroupName=lgName` const response = await request(app).get(`/isolation?${params}`) expect(response.body.origin).toBe(true) expect(response.body.storage).toBe(true) @@ -111,8 +112,8 @@ describe('server', () => { jest.spyOn(console, 'error') lambdaMock.on(InvokeCommand).resolves() s3Mock.on(GetObjectCommand).resolves() - logsMock.on(PutLogEventsCommand).resolves() - const params = `FunctionName=name&Bucket=bucket&Key=key&logGroupName=lgName&logStreamName=lsName` + logsMock.on(CreateLogStreamCommand).resolves() + const params = `FunctionName=name&Bucket=bucket&Key=key&logGroupName=lgName` const response = await request(app).get(`/isolation?${params}`) expect(response.body.origin).toBe(false) expect(response.body.storage).toBe(false) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 1f4757aef7..09ee2a1182 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -2,12 +2,13 @@ - Add support for environment level base paths on /mobify routes [#2892](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2892) - Fix private client endpoint prop name [#3177](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3177) - This feature introduces an AI-powered shopping assistant that integrates Salesforce Embedded Messaging Service with PWA Kit applications. The shopper agent provides real-time chat support, search assistance, and personalized shopping guidance directly within the e-commerce experience. [#2658](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2658) -- Added support for Multi-Ship [#3056](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3056) [#3199](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3199) [#3203](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3203) [#3211] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3211) [#3217](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3217) [#3216] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3216) +- Added support for Multi-Ship [#3056](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3056) [#3199](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3199) [#3203](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3203) [#3211] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3211) [#3217](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3217) [#3216] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3216) [#3231] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3231) [#3240] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3240) - The feature toggle for partial hydration is now found in the config file (`config.app.partialHydrationEnabled`) [#3058](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3058) - Mask user not found messages to prevent user enumeration from passwordless login [#3113](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3113) - [Bugfix] Pin `@chakra-ui/react` version to 2.7.0 to avoid breaking changes from 2.10.9 [#2658](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2658) - Introduce optional prop `hybridAuthEnabled` to control Hybrid Auth specific behaviors in commerce-sdk-react [#3151](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3151) - Inject sfdc_user_agent request header into all SCAPI requests for debugging and metrics prupose [#3183](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3183) +- Fix config parsing to gracefully handle missing properties [#3230](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3230) - [Bugfix] Fix unit test failures in generated projects [3204](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3204) ## v7.0.0 (July 22, 2025) diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index a377d3d0df..89cc73f253 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -85,6 +85,7 @@ import { import Seo from '@salesforce/retail-react-app/app/components/seo' import ShopperAgent from '@salesforce/retail-react-app/app/components/shopper-agent' import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url' +import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils' const PlaceholderComponent = () => (
@@ -217,8 +218,8 @@ const App = (props) => { }, [basket?.currency]) const commerceAgentConfiguration = useMemo(() => { - return config.app.commerceAgent - }, [config?.app]) + return getCommerceAgentConfig() + }, [config.app.commerceAgent]) useEffect(() => { // update the basket customer email diff --git a/packages/template-retail-react-app/app/components/search/index.jsx b/packages/template-retail-react-app/app/components/search/index.jsx index 477eaaf244..bdd310ddfb 100644 --- a/packages/template-retail-react-app/app/components/search/index.jsx +++ b/packages/template-retail-react-app/app/components/search/index.jsx @@ -42,6 +42,7 @@ import { categoryUrlBuilder } from '@salesforce/retail-react-app/app/utils/url' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {getCommerceAgentConfig} from '@salesforce/retail-react-app/app/utils/config-utils' const onClient = typeof window !== 'undefined' @@ -93,8 +94,11 @@ const formatSuggestions = (searchSuggestions, input) => { */ const Search = (props) => { const config = getConfig() - const {enabled, askAgentOnSearch} = config.app.commerceAgent - const askAgentOnSearchEnabled = isAskAgentOnSearchEnabled(enabled, askAgentOnSearch) + const askAgentOnSearchEnabled = useMemo(() => { + const {enabled, askAgentOnSearch} = getCommerceAgentConfig() + return isAskAgentOnSearchEnabled(enabled, askAgentOnSearch) + }, [config.app.commerceAgent]) + const [isOpen, setIsOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const navigate = useNavigation() diff --git a/packages/template-retail-react-app/app/hooks/use-current-basket.js b/packages/template-retail-react-app/app/hooks/use-current-basket.js index 10698c8fc6..fc90fbe9aa 100644 --- a/packages/template-retail-react-app/app/hooks/use-current-basket.js +++ b/packages/template-retail-react-app/app/hooks/use-current-basket.js @@ -68,14 +68,8 @@ export const useCurrentBasket = ({id = ''} = {}) => { pickupStoreIds.sort() // Calculate total shipping cost - const totalShippingCost = currentBasket?.shippingItems?.reduce((total, item) => { - return ( - total + - (item.priceAfterItemDiscount !== undefined - ? item.priceAfterItemDiscount - : item.price || 0) - ) - }, 0) + // Use currentBasket.shippingTotal to include all costs (base _ promotions + surcharges + other fees) + const totalShippingCost = currentBasket?.shippingTotal || 0 return { totalItems, diff --git a/packages/template-retail-react-app/app/hooks/use-einstein.js b/packages/template-retail-react-app/app/hooks/use-einstein.js index f24cadd2c9..89700fb903 100644 --- a/packages/template-retail-react-app/app/hooks/use-einstein.js +++ b/packages/template-retail-react-app/app/hooks/use-einstein.js @@ -437,7 +437,7 @@ const useEinstein = () => { const {effectiveDnt} = useDNT() const {getTokenWhenReady} = useAccessToken() const { - app: {einsteinAPI: config} + app: {einsteinAPI: config = {}} } = getConfig() const {host, einsteinId, siteId, isProduction} = config diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx index 500852333b..75df2c3ed7 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx @@ -169,6 +169,7 @@ const ShippingAddressSelection = ({ const address = customer.addresses.find((addr) => addr.preferred === true) if (address) { form.reset({...address}) + setSelectedAddressId(address.addressId) } } }, []) @@ -176,7 +177,7 @@ const ShippingAddressSelection = ({ useEffect(() => { // If the customer deletes all their saved addresses during checkout, // we need to make sure to display the address form. - if (!isLoading && !customer?.addresses && !isEditingAddress) { + if (!isLoading && !customer?.addresses?.length && !isEditingAddress) { setIsEditingAddress(true) } }, [customer]) @@ -187,10 +188,7 @@ const ShippingAddressSelection = ({ addressId: matchedAddress.addressId, ...matchedAddress }) - } - - if (!matchedAddress && selectedAddressId) { - setIsEditingAddress(true) + setSelectedAddressId(matchedAddress.addressId) } }, [matchedAddress]) @@ -213,7 +211,7 @@ const ShippingAddressSelection = ({ if (addressId && isEditingAddress) { setIsEditingAddress(false) } - + setSelectedAddressId(addressId) const address = customer.addresses.find((addr) => addr.addressId === addressId) form.reset({...address}) @@ -422,7 +420,11 @@ const ShippingAddressSelection = ({ diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx index 3c49d447ac..06e80bbb5e 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx @@ -349,13 +349,8 @@ export default function ShippingMethods() { // Multiple shipments summary {deliveryShipments.map((shipment) => { - const shippingItem = basket?.shippingItems?.find( - (item) => item.shipmentId === shipment.shipmentId - ) - const itemCost = - shippingItem?.priceAfterItemDiscount !== undefined - ? shippingItem.priceAfterItemDiscount - : shippingItem?.price || 0 + // Use shipment.shippingTotal instead of looping on shippingItems to include all costs (base _ promotions + surcharges + other fees) + const itemCost = shipment.shippingTotal || 0 return ( diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js index 087eae6af2..21a342870a 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js @@ -349,6 +349,75 @@ describe('ShippingMethods', () => { expect(screen.getAllByText('Standard Shipping').length).toBeGreaterThan(0) expect(screen.getAllByText('Express Shipping').length).toBeGreaterThan(0) }) + + test('should display correct individual shipping costs in summary mode with all relevant shipping fees - surcharge', () => { + const multiShipmentBasketWithSurcharges = { + ...mockBasket, + shipments: [ + { + shipmentId: 'shipment-1', + shippingTotal: 15.99, // Base 5.99 + surcharge 10.00 + shippingAddress: { + firstName: 'John', + lastName: 'Doe', + address1: '123 Main St', + city: 'Anytown', + stateCode: 'CA', + postalCode: '12345' + }, + shippingMethod: { + id: 'shipping-method-1', + name: 'Ground', + description: 'Order received within 7-10 business days' + } + }, + { + shipmentId: 'shipment-2', + shippingTotal: 5.99, // Base only + shippingAddress: { + firstName: 'Jane', + lastName: 'Smith', + address1: '456 Oak Ave', + city: 'Somewhere', + stateCode: 'NY', + postalCode: '67890' + }, + shippingMethod: { + id: 'shipping-method-2', + name: 'Ground', + description: 'Order received within 7-10 business days' + } + } + ], + shippingItems: [ + {shipmentId: 'shipment-1', price: 5.99}, // Base + {shipmentId: 'shipment-1', price: 10.0}, // Surcharge + {shipmentId: 'shipment-2', price: 5.99} // Base + ] + } + + mockUseCurrentBasket.mockReturnValue({ + data: multiShipmentBasketWithSurcharges, + derivedData: { + totalShippingCost: 21.98 // 15.99 + 5.99 + }, + isLoading: false + }) + + // show summary mode + mockUseCheckout.mockReturnValue({ + step: 3, + STEPS: {SHIPPING_OPTIONS: 2}, + goToStep: jest.fn(), + goToNextStep: jest.fn() + }) + + renderWithIntl() + + expect(screen.getByText('$15.99')).toBeInTheDocument() // First shipment + expect(screen.getByText('$5.99')).toBeInTheDocument() // Second shipment + expect(screen.getByText('$21.98')).toBeInTheDocument() // Total + }) }) describe('Error Handling', () => { diff --git a/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx index 75605f6698..cda5935451 100644 --- a/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx +++ b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx @@ -39,7 +39,7 @@ const SocialLoginRedirect = () => { const {data: customer} = useCurrentCustomer() // Build redirectURI from config values const appOrigin = useAppOrigin() - const redirectPath = getConfig().app.login.social?.redirectURI || '' + const redirectPath = getConfig().app.login?.social?.redirectURI || '' const redirectURI = buildRedirectURI(appOrigin, redirectPath) const locatedFrom = getSessionJSONItem('returnToPage') diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 344cb41256..c612aadd1b 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -228,8 +228,16 @@ const throwSlasTokenValidationError = (message, code) => { export const createRemoteJWKSet = (tenantId) => { const appOrigin = getAppOrigin() const {app: appConfig} = getConfig() - const shortCode = appConfig.commerceAPI.parameters.shortCode - const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '') + const shortCode = appConfig.commerceAPI?.parameters?.shortCode + const configTenantId = appConfig.commerceAPI?.parameters?.organizationId?.replace( + /^f_ecom_/, + '' + ) + if (!shortCode || !configTenantId) { + throw new Error( + 'Cannot find `commerceAPI.parameters.(shortCode|organizationId)` in your config file. Please check the config file.' + ) + } if (tenantId !== configTenantId) { throw new Error( `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").` diff --git a/packages/template-retail-react-app/app/utils/config-utils.js b/packages/template-retail-react-app/app/utils/config-utils.js new file mode 100644 index 0000000000..5597b9149e --- /dev/null +++ b/packages/template-retail-react-app/app/utils/config-utils.js @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' + +export const getCommerceAgentConfig = () => { + const defaults = { + enabled: 'false', + askAgentOnSearch: 'false', + embeddedServiceName: '', + embeddedServiceEndpoint: '', + scriptSourceUrl: '', + scrt2Url: '', + salesforceOrgId: '', + commerceOrgId: '', + siteId: '' + } + return getConfig().app.commerceAgent ?? defaults +} diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 618088db3b..5d5008cc5d 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -4,14 +4,23 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -// eslint-disable-next-line @typescript-eslint/no-var-requires +/* eslint-disable @typescript-eslint/no-var-requires */ const sites = require('./sites.js') -// eslint-disable-next-line @typescript-eslint/no-var-requires -const {parseCommerceAgentSettings} = require('./utils.js') +const {parseSettings} = require('./utils.js') module.exports = { app: { - commerceAgent: parseCommerceAgentSettings(process.env.COMMERCE_AGENT_SETTINGS), + commerceAgent: parseSettings(process.env.COMMERCE_AGENT_SETTINGS) || { + enabled: 'false', + askAgentOnSearch: 'false', + embeddedServiceName: '', + embeddedServiceEndpoint: '', + scriptSourceUrl: '', + scrt2Url: '', + salesforceOrgId: '', + commerceOrgId: '', + siteId: '' + }, url: { site: 'path', locale: 'path', diff --git a/packages/template-retail-react-app/config/utils.js b/packages/template-retail-react-app/config/utils.js index bfde4cd31d..915b0a78f1 100644 --- a/packages/template-retail-react-app/config/utils.js +++ b/packages/template-retail-react-app/config/utils.js @@ -6,24 +6,11 @@ */ /** - * Safely parses commerce agent settings from either a JSON string or object - * @param {string|object} settings - The commerce agent settings - * @returns {object} Parsed commerce agent settings object + * Safely parses settings from either a JSON string or object + * @param {string|object} settings - The settings + * @returns {object} Parsed settings object */ -function parseCommerceAgentSettings(settings) { - // Default configuration when no settings are provided - const defaultConfig = { - enabled: 'false', - askAgentOnSearch: 'false', - embeddedServiceName: '', - embeddedServiceEndpoint: '', - scriptSourceUrl: '', - scrt2Url: '', - salesforceOrgId: '', - commerceOrgId: '', - siteId: '' - } - +function parseSettings(settings) { // If settings is already an object, return it if (typeof settings === 'object' && settings !== null) { return settings @@ -34,15 +21,15 @@ function parseCommerceAgentSettings(settings) { try { return JSON.parse(settings) } catch (error) { - console.warn('Invalid COMMERCE_AGENT_SETTINGS format, using defaults:', error.message) - return defaultConfig + console.warn('Invalid json format:', error.message) + return } } - // If settings is undefined/null, return defaults - return defaultConfig + console.warn('Cannot parse settings from:', settings) + return } module.exports = { - parseCommerceAgentSettings + parseSettings }