From a79da41066295d3d539b938ef7df535c51172ca8 Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 08:14:01 -0500 Subject: [PATCH 01/12] PDX-0: feat(ci): add Quality Orchestrator action to CI workflow RCA: No automated PR risk analysis or test coverage mapping existed in the CI pipeline Fix: Add parallel quality-analysis job using mrdailey99/QualityOrchestrator@v1 to score PR risk and post coverage comments Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/CI_Execution.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/CI_Execution.yml b/.github/workflows/CI_Execution.yml index 3d1558ad..3790fd4e 100644 --- a/.github/workflows/CI_Execution.yml +++ b/.github/workflows/CI_Execution.yml @@ -19,6 +19,22 @@ on: # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: + quality-analysis: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: mrdailey99/QualityOrchestrator@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + test-dir: 'test' + framework: 'auto' + generate-stubs: 'true' + fail-on-high: 'false' + provardx-ci-execution: strategy: matrix: From df7c748cf260beddbb424a421ce3d8c712aa564e Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 08:20:08 -0500 Subject: [PATCH 02/12] PDX-0: fix(ci): address adversarial review findings for quality-analysis job RCA: @v1 tag does not exist on the action repo; mutable tag and unvalidated stub paths posed supply chain and path-traversal risk Fix: pin to @v1.0.0, disable stub generation, add persist-credentials: false to checkout step Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/CI_Execution.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI_Execution.yml b/.github/workflows/CI_Execution.yml index 3790fd4e..66861f1e 100644 --- a/.github/workflows/CI_Execution.yml +++ b/.github/workflows/CI_Execution.yml @@ -27,12 +27,14 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v4 - - uses: mrdailey99/QualityOrchestrator@v1 + with: + persist-credentials: false + - uses: mrdailey99/QualityOrchestrator@v1.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} test-dir: 'test' framework: 'auto' - generate-stubs: 'true' + generate-stubs: 'false' fail-on-high: 'false' provardx-ci-execution: From 8a6e8d5ecec58e25239a4fbb1dc65e63e223bc2e Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 08:47:59 -0500 Subject: [PATCH 03/12] PDX-0: chore(ci): upgrade all GitHub Actions to Node.js 24-compatible versions RCA: Actions running on Node.js 20 are deprecated and will be forced to Node.js 24 by default on June 2nd 2026; Node.js 20 runner support ends September 16th 2026 Fix: Bump all actions across CI_Execution, CIRelease_Tagging, DeployManual, and UnpublishManual workflows to their latest Node.js 24-compatible major versions Upgrades applied: - actions/checkout: v4 -> v6 - actions/setup-node: v4 -> v6 - actions/cache: v4 -> v5 - actions/github-script: v6 -> v9 - actions/upload-artifact: v4 -> v7 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/CIRelease_Tagging.yml | 4 ++-- .github/workflows/CI_Execution.yml | 16 ++++++++-------- .github/workflows/DeployManual.yml | 4 ++-- .github/workflows/UnpublishManual.yml | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/CIRelease_Tagging.yml b/.github/workflows/CIRelease_Tagging.yml index 4693b3e1..ec24487a 100644 --- a/.github/workflows/CIRelease_Tagging.yml +++ b/.github/workflows/CIRelease_Tagging.yml @@ -12,8 +12,8 @@ jobs: runs-on: ubuntu-latest if: github.event.pull_request.merged == true steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 20 - name: Install Dependencies diff --git a/.github/workflows/CI_Execution.yml b/.github/workflows/CI_Execution.yml index 66861f1e..1e146a01 100644 --- a/.github/workflows/CI_Execution.yml +++ b/.github/workflows/CI_Execution.yml @@ -26,7 +26,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false - uses: mrdailey99/QualityOrchestrator@v1.0.0 @@ -44,12 +44,12 @@ jobs: nodeversion: [20] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.nodeversion }} - name: 'Cache node_modules' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ matrix.os == 'windows-latest' && 'C:\\Users\\runneradmin\\AppData\\Roaming\\npm-cache' || '~/.npm' }} key: ${{ runner.os }}-node-v${{ matrix.nodeversion }}-${{ hashFiles('**/package.json') }} @@ -80,7 +80,7 @@ jobs: - name: Check for target branch in Utils repo id: check_branch - uses: actions/github-script@v6 + uses: actions/github-script@v9 with: script: | const branch = process.env.BRANCH_NAME; @@ -98,7 +98,7 @@ jobs: return branchExists; - name: Check out Utils repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: ProvarTesting/provardx-plugins-utils path: utils @@ -125,7 +125,7 @@ jobs: PROVAR_DEV_WHITELIST_KEYS: ${{ secrets.PROVAR_DEV_WHITELIST_KEYS }} run: node scripts/mcp-smoke.cjs - name: Check out Regression repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: ProvarTesting/provar-manager-regression path: ProvarRegression @@ -145,7 +145,7 @@ jobs: yarn run test:nuts - name: Archive NUTS results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nuts-report-${{ matrix.os }} path: mochawesome-report diff --git a/.github/workflows/DeployManual.yml b/.github/workflows/DeployManual.yml index 5612e182..1d83ec7c 100644 --- a/.github/workflows/DeployManual.yml +++ b/.github/workflows/DeployManual.yml @@ -14,11 +14,11 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/UnpublishManual.yml b/.github/workflows/UnpublishManual.yml index e8a52bd7..1f212c1b 100644 --- a/.github/workflows/UnpublishManual.yml +++ b/.github/workflows/UnpublishManual.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 registry-url: 'https://registry.npmjs.org' From 464745041833aee149eda0e711314971919dc3bb Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:08:38 -0500 Subject: [PATCH 04/12] PDX-0: feat(mcp): add provar-nitrox-component-catalog resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bundled static MCP resource at provar://nitrox/component-catalog that catalogs all 9 shipped NitroX (Hybrid Model) base component packages. Also documents MCP Inspector setup in docs/development.md. RCA: The NitroX generate tool had no reference material to guide component generation — agents had to guess naming conventions, type strings, tagNames, and interaction patterns. Additionally no guidance existed for interactive MCP testing via the MCP Inspector. Fix: Bundle a static catalog of all shipped NitroX packages (generated from local installation, committed to docs/) and register it as a readable MCP resource, mirroring the provar-step-reference pattern. Add MCP Inspector setup guide including port-in-use resolution steps to docs/development.md. Changes: - docs/NITROX_COMPONENT_CATALOG.md: committed static catalog (~2000 lines) across common, html5, salesforce-lwc, screenflow, experienceCloud, omnistudio, runtimeOmnistudio, msdynamics, and vlocityIns packages - package.json: compile step copies catalog to lib/mcp/docs/; added to wireit files inputs - src/mcp/server.ts: registers new resource via readFileSync — no runtime dependency on local Provar installation - src/mcp/tools/nitroXTools.ts: provar_nitrox_generate description references the catalog - scripts/generate-nitrox-catalog.cjs: dev utility to regenerate when packages update - docs/development.md: MCP Inspector setup guide with port-cleanup commands Co-Authored-By: Claude Sonnet 4.6 --- docs/NITROX_COMPONENT_CATALOG.md | 2001 +++++++++++++++++++++++++++ docs/development.md | 86 +- package.json | 3 +- scripts/generate-nitrox-catalog.cjs | 129 ++ src/mcp/server.ts | 29 + src/mcp/tools/nitroXTools.ts | 2 + 6 files changed, 2230 insertions(+), 20 deletions(-) create mode 100644 docs/NITROX_COMPONENT_CATALOG.md create mode 100644 scripts/generate-nitrox-catalog.cjs diff --git a/docs/NITROX_COMPONENT_CATALOG.md b/docs/NITROX_COMPONENT_CATALOG.md new file mode 100644 index 00000000..46a5d554 --- /dev/null +++ b/docs/NITROX_COMPONENT_CATALOG.md @@ -0,0 +1,2001 @@ +# NitroX Component Package Catalog + +Shipped base NitroX (Hybrid Model) component packages. +Use as a reference when generating new NitroX components — match naming conventions, +type strings, tagNames, interaction titles, and attribute names from these shipped packages. + +--- + +## common (v1.8.21) + +Package includes definitions for Generic Fact Component +**Requires Provar:** >=2.10.2 + +### Components + +#### Generic Component + +- **name:** `json::/com/provar/common/GenericComponent` +- **type:** `container/genericComponent` +- **tagName:** `*` +- **interactions:** `Clear`, `Set`, `Check`, `Uncheck`, `Click` +- **attributes:** `Class`, `Visible`, `Disabled`, `Name`, `Label`, `Type`, `Checked`, `Required`, `Read only`, `Max length`, `Min length`, `Href`, `Value` + +--- + +## experienceCloud (v1.0.7) + +Package includes definitions for Experience Cloud FACT elements +**Requires Provar:** >=2.10.2 + +### Components + +#### Community Leaderboard Item + +- **name:** `json::/com/provar/experienceCloud/CommunityLeaderboardItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Click` +- **attributes:** `Class` +- **child elements:** 2 + +#### CheckBox + +- **name:** `json::/com/provar/experienceCloud/ExpCloudCheckBox` +- **type:** `Checkbox` +- **tagName:** `div` +- **interactions:** `Check`, `Uncheck` +- **attributes:** `Class`, `Value` + +#### Lookup List + +- **name:** `json::/com/provar/experienceCloud/ExpCloudLookupList` +- **type:** `container` +- **tagName:** `a` +- **interactions:** `Click` +- **attributes:** `Name`, `Type`, `Text`, `Class`, `Href` + +#### PickList + +- **name:** `json::/com/provar/experienceCloud/ExpCloudPickList` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set`, `Set By Index` +- **attributes:** `Class`, `Value` + +#### Rich Text Area + +- **name:** `json::/com/provar/experienceCloud/ExpCloudRichTextArea` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Clear`, `Set` +- **attributes:** `Class`, `Value` + +#### Profile Menu + +- **name:** `json::/com/provar/experienceCloud/UserProfileMenu` +- **type:** `container` +- **tagName:** `community_user-user-profile-menu` +- **interactions:** `Set`, `Set By Index`, `Click` +- **attributes:** `Class`, `Username`, `Is guest user`, `Login button text`, `Menu Items` + +--- + +## html5 (v1.8.11) + +Package includes definitions for HTML5 FACT elements +**Requires Provar:** >=2.10.2 + +### Components + +#### HTML5 Anchor Object + +- **name:** `json::/com/provar/html5/AnchorObject` +- **type:** `container` +- **tagName:** `a` +- **interactions:** `Click` +- **attributes:** `Name`, `Visible`, `Type`, `Text`, `Class`, `Href` + +#### HTML5 Button + +- **name:** `json::/com/provar/html5/Button` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Click` +- **attributes:** `Disabled`, `Visible`, `Text Content`, `Inner Text`, `Label`, `Name`, `Type`, `Title`, `Aria Label` + +#### HTML5 Button Input + +- **name:** `json::/com/provar/html5/ButtonInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Click` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Name`, `Type` + +#### HTML5 Checkbox Input + +- **name:** `json::/com/provar/html5/CheckboxInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Check`, `Uncheck` +- **attributes:** `Disabled`, `Name`, `Visible`, `Type`, `Checked`, `Required`, `Read only` + +#### HTML5 Color Input + +- **name:** `json::/com/provar/html5/ColorInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Visible`, `Name`, `Type`, `Value` + +#### HTML5 Date Input + +- **name:** `json::/com/provar/html5/DateInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Set`, `Set Date` +- **attributes:** `Disabled`, `Visible`, `Name`, `Pattern`, `Placeholder`, `Read only`, `Required`, `Type`, `Value`, `Min`, `Max`, `Validation Message`, `Class` + +#### HTML5 Datetime-local Input + +- **name:** `json::/com/provar/html5/DatetimeLocalInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Set`, `Set Date Time` +- **attributes:** `Disabled`, `Visible`, `Name`, `Pattern`, `Class`, `Placeholder`, `Read only`, `Required`, `Type`, `Validation Message`, `Value`, `Max`, `Min` + +#### HTML5 Div + +- **name:** `json::/com/provar/html5/Division` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Click` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Email Input + +- **name:** `json::/com/provar/html5/EmailInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Clear`, `Append`, `Set` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Max length`, `Name`, `Pattern`, `Placeholder`, `Read only`, `Required`, `Type`, `Value`, `Class` + +#### HTML5 File Input + +- **name:** `json::/com/provar/html5/FileInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Upload Files` +- **attributes:** `Disabled`, `Visible`, `Name`, `Type`, `Value` + +#### HTML5 Footer + +- **name:** `json::/com/provar/html5/Footer` +- **type:** `container` +- **tagName:** `footer` +- **attributes:** `Class`, `Value` + +#### HTML5 Form + +- **name:** `json::/com/provar/html5/Form` +- **type:** `container` +- **tagName:** `form` +- **attributes:** `Class`, `Name`, `Id`, `Value` + +#### HTML5 Header + +- **name:** `json::/com/provar/html5/Header` +- **type:** `container` +- **tagName:** `header` +- **attributes:** `Class`, `Value` + +#### HTML5 Heading1 + +- **name:** `json::/com/provar/html5/Header1` +- **type:** `container` +- **tagName:** `h1` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Heading2 + +- **name:** `json::/com/provar/html5/Header2` +- **type:** `container` +- **tagName:** `h2` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Heading3 + +- **name:** `json::/com/provar/html5/Header3` +- **type:** `container` +- **tagName:** `h3` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Heading4 + +- **name:** `json::/com/provar/html5/Header4` +- **type:** `container` +- **tagName:** `h4` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Heading5 + +- **name:** `json::/com/provar/html5/Header5` +- **type:** `container` +- **tagName:** `h5` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Heading6 + +- **name:** `json::/com/provar/html5/Header6` +- **type:** `container` +- **tagName:** `h6` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Iframe + +- **name:** `json::/com/provar/html5/IFrame` +- **type:** `iframe` +- **tagName:** `iframe` +- **attributes:** `Id`, `Name` + +#### HTML5 Image + +- **name:** `json::/com/provar/html5/Image` +- **type:** `container` +- **tagName:** `img` +- **attributes:** `Name`, `Visible`, `Source`, `Width`, `Alternate`, `Class` + +#### HTML5 Image Input + +- **name:** `json::/com/provar/html5/ImageInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Click` +- **attributes:** `Disabled`, `Visible`, `Name`, `Type`, `Value`, `Source`, `Width` + +#### HTML5 Label + +- **name:** `json::/com/provar/html5/Label` +- **type:** `container` +- **tagName:** `label` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Legend + +- **name:** `json::/com/provar/html5/Legend` +- **type:** `container` +- **tagName:** `legend` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 List Item + +- **name:** `json::/com/provar/html5/ListItem` +- **type:** `container` +- **tagName:** `li` +- **attributes:** `Visible`, `Class` + +#### HTML5 Main + +- **name:** `json::/com/provar/html5/Main` +- **type:** `container` +- **tagName:** `main` +- **attributes:** `Class`, `Value` + +#### HTML5 Number Input + +- **name:** `json::/com/provar/html5/NumberInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Clear`, `Append`, `Set` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Min`, `Max`, `Name`, `Placeholder`, `Read only`, `Required`, `Type`, `Value`, `Class` + +#### HTML5 P + +- **name:** `json::/com/provar/html5/Paragraph` +- **type:** `container` +- **tagName:** `p` +- **attributes:** `Class`, `Visible`, `Text content`, `Value` + +#### HTML5 Password Input + +- **name:** `json::/com/provar/html5/PasswordInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Clear`, `Append`, `Set` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Max length`, `Name`, `Pattern`, `Placeholder`, `Read only`, `Required`, `Type`, `Value`, `Class` + +#### HTML5 Radio + +- **name:** `json::/com/provar/html5/RadioInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Check` +- **attributes:** `Disabled`, `Visible`, `Name`, `Type`, `Checked`, `Required` + +#### HTML5 Range Input + +- **name:** `json::/com/provar/html5/RangeInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Set`, `Set Range Value` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Name`, `Type`, `Value`, `Max`, `Min`, `Step` + +#### HTML5 Reset + +- **name:** `json::/com/provar/html5/ResetInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Click` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Name`, `Type`, `Class`, `Value` + +#### HTML5 Search Input + +- **name:** `json::/com/provar/html5/SearchInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Max length`, `Name`, `Pattern`, `Placeholder`, `Read only`, `Required`, `Type`, `Value`, `Class` + +#### HTML5 Section + +- **name:** `json::/com/provar/html5/Section` +- **type:** `container` +- **tagName:** `section` +- **attributes:** `Class`, `Value` + +#### HTML5 Select + +- **name:** `json::/com/provar/html5/Select` +- **type:** `container` +- **tagName:** `select` +- **interactions:** `Set`, `Set By Index` +- **attributes:** `Class`, `Visible`, `Value`, `Selected Index`, `Disabled`, `Name`, `Required` + +#### HTML5 Span + +- **name:** `json::/com/provar/html5/Span` +- **type:** `container` +- **tagName:** `span` +- **interactions:** `Click` +- **attributes:** `Class`, `Visible`, `Value` + +#### HTML5 Submit + +- **name:** `json::/com/provar/html5/SubmitInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Click` +- **attributes:** `Disabled`, `Visible`, `Name`, `Validation Message`, `Type`, `Class`, `Value` + +#### HTML5 Telephone Input + +- **name:** `json::/com/provar/html5/TelephoneInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Clear`, `Append`, `Set` +- **attributes:** `Disabled`, `Visible`, `Min length`, `Max length`, `Name`, `Pattern`, `Placeholder`, `Read only`, `Required`, `Validation Message`, `Type`, `Value`, `Class` + +#### HTML5 Textarea + +- **name:** `json::/com/provar/html5/Textarea` +- **type:** `container` +- **tagName:** `textarea` +- **interactions:** `Clear`, `Append`, `Set` +- **attributes:** `Disabled`, `Visible`, `Text Content`, `innerText`, `Name`, `Type`, `Min length`, `Max length`, `Rows`, `Columns`, `Placeholder`, `Read only`, `Required`, `Value`, `Class` + +#### HTML5 Text Input + +- **name:** `json::/com/provar/html5/TextInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Clear`, `Append`, `Set` +- **attributes:** `Disabled`, `Visible`, `Validation Message`, `Min length`, `Max length`, `Name`, `Pattern`, `Placeholder`, `Read only`, `Required`, `Type`, `Value`, `Class` + +#### HTML5 Time Input + +- **name:** `json::/com/provar/html5/TimeInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Visible`, `Name`, `Validation Message`, `Read only`, `Required`, `Type`, `Value`, `Max`, `Min`, `Class` + +#### HTML5 Unordered List + +- **name:** `json::/com/provar/html5/UnorderedList` +- **type:** `container` +- **tagName:** `ul` +- **attributes:** `Visible`, `Class` + +#### HTML5 Url Input + +- **name:** `json::/com/provar/html5/UrlInput` +- **type:** `container` +- **tagName:** `input` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Visible`, `Max length`, `Name`, `Pattern`, `Placeholder`, `Read only`, `Required`, `Type`, `Value`, `Class` + +--- + +## msdynamics (v1.0.3) + +Package includes definitions for Microsoft Dynamics +**Requires Provar:** >=2.10.2 + +### Components + +#### MS Dynamics Abstract Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/AbstractFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Click` +- **attributes:** `Field Name`, `Label`, `Value`, `Required`, `Read Only`, `Message` + +#### MS Dynamics Attach File Button + +- **name:** `json::/nitroXPackages/ms-dynamics/AttachFileButton` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Activate`, `Attach File` +- **attributes:** `Label` + +#### MS Dynamics Currency Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/CurrencyFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` + +#### MS Dynamics Date/Time Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/DateTimeFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set`, `Clear` + +#### MS Dynamics Decimal Number Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/DecimalNumberFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` + +#### MS Dynamics Dialog Button + +- **name:** `json::/nitroXPackages/ms-dynamics/DialogButton` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Click` +- **attributes:** `The buttons's label` + +#### MS Dynamics Email Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/EmailFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` + +#### MS Dynamics Flip Switch Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/FlipSwitchFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Toggle`, `Check`, `Unheck` +- **attributes:** `Switch Value`, `Switch Label` + +#### MS Dynamics Flyout Menu item + +- **name:** `json::/nitroXPackages/ms-dynamics/FlyoutMenuItem` +- **type:** `container` +- **tagName:** `button,li` +- **interactions:** `Click`, `Locate` +- **attributes:** `Label` + +#### MS Dynamics Form Command + +- **name:** `json::/nitroXPackages/ms-dynamics/FormCommand` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Click`, `Locate` +- **attributes:** `Label` + +#### MS Dynamics Grid Command + +- **name:** `json::/nitroXPackages/ms-dynamics/GridCommand` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Click`, `Locate` +- **attributes:** `The command's label.` + +#### MS Dynamics Grid Filter + +- **name:** `json::/nitroXPackages/ms-dynamics/GridFilter` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` +- **attributes:** `Filter Type`, `Value` + +#### MS Dynamics Header Fields Flyout + +- **name:** `json::/nitroXPackages/ms-dynamics/HeaderFieldsFlyout` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Activate` + +#### MS Dynamics Qualify Lead Dialog + +- **name:** `json::/nitroXPackages/ms-dynamics/LeadQualifyDialog` +- **type:** `container` +- **tagName:** `div` + +#### MS Grid Control + +- **name:** `json::/nitroXPackages/ms-dynamics/LegacyGrid` +- **type:** `table` +- **tagName:** `div` +- **attributes:** `Entity Display Name`, `Entity Type`, `Columns`, `Column Labels`, `ColumnsFields`, `Row Values` + +#### Grid Column + +- **name:** `json::/nitroXPackages/ms-dynamics/LegacyGridColumn` +- **type:** `abstract` +- **tagName:** `div` +- **interactions:** `Click` +- **attributes:** `Column Name`, `Label`, `Value`, `Column Type`, `Cell Type` + +#### Text Column + +- **name:** `json::/nitroXPackages/ms-dynamics/LegacyGridTextColumn` +- **type:** `column` + +#### MS Dynamics Navigation Bar + +- **name:** `json::/nitroXPackages/ms-dynamics/NavigationBar` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Locate` + +#### MS Dynamics Option Set Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/OptionSetFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` + +#### MS Dynamics Page Section + +- **name:** `json::/nitroXPackages/ms-dynamics/PageSection` +- **type:** `container` +- **tagName:** `section` +- **attributes:** `Title` + +#### MS Dynamics Phone Number Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/PhoneNumberFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` + +#### MS Dynamics Confirm Dialog + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsConfirmDialog` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Confirm`, `Cancel` +- **attributes:** `Title`, `Subtitle`, `Message text`, `Confirm button label`, `Cancel button label` + +#### MS Power Apps Grid Control + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGrid` +- **type:** `table` +- **tagName:** `div` +- **attributes:** `Entity Display Name`, `Entity Type`, `Columns`, `Column Labels`, `ColumnsFields`, `ROW Number`, `CheckBox Column`, `Row Values` + +#### Checkbox Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridCheckboxColumn` +- **type:** `column` +- **tagName:** `div` +- **interactions:** `Check`, `Uncheck`, `Select All Rows`, `Un-Select All Rows` + +#### MS Power Apps Grid Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridColumn` +- **type:** `abstract` +- **tagName:** `div,span` +- **interactions:** `Click` +- **attributes:** `Column Name`, `Data Type`, `Data Format`, `Label`, `Value`, `Column Type`, `Cell Type` + +#### Currency Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridCurrencyColumn` +- **type:** `column` + +#### Date Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridDateColumn` +- **type:** `column` + +#### DateTime Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridDateTimeColumn` +- **type:** `column` + +#### Decimal Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridDecimalColumn` +- **type:** `column` + +#### Email Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridEmailColumn` +- **type:** `column` + +#### Integer Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridIntegerColumn` +- **type:** `column` + +#### Lookup Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridLookupColumn` +- **type:** `column` + +#### OptionSet Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridOptionSetColumn` +- **type:** `column` + +#### Phone Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridPhoneColumn` +- **type:** `column` + +#### Text Column + +- **name:** `json::/nitroXPackages/ms-dynamics/PowerAppsGridTextColumn` +- **type:** `column` + +#### MS Dynamics Process Bread Crumb Stage + +- **name:** `json::/nitroXPackages/ms-dynamics/ProcessBreadCrumbStage` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Activate` +- **attributes:** `Stage Title`, `Stage's GUID'` + +#### MS Dynamics Quick Create Button + +- **name:** `json::/nitroXPackages/ms-dynamics/QuickCreateButton` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Click`, `Click and Confirm` +- **attributes:** `Label` + +#### MS Dynamics Quick Create Menu Item + +- **name:** `json::/nitroXPackages/ms-dynamics/QuickCreateMenuItem` +- **type:** `container` +- **tagName:** `button` +- **interactions:** `Click`, `Locate` +- **attributes:** `Label` + +#### MS Dynamics Related Entity Menu Item + +- **name:** `json::/nitroXPackages/ms-dynamics/RelatedEntityMenuItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Click`, `Locate` +- **attributes:** `The item's label` + +#### MS Dynamics Related Entity Tab + +- **name:** `json::/nitroXPackages/ms-dynamics/RelatedEntityTabPanel` +- **type:** `container` +- **tagName:** `li,div` +- **interactions:** `Activate`, `Locate` +- **attributes:** `Title`, `Selected`, `Tab Name` + +#### MS Dynamics Rich Text Editor + +- **name:** `json::/nitroXPackages/ms-dynamics/RichTextEditor` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set Text`, `Clear`, `Insert Text` +- **attributes:** `Label`, `Value` + +#### MS Dynamics Selection Tree Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/SelectionTreeFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set`, `Clear` + +#### MS Dynamics Simple Lookup Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/SimpleLookupFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set`, `Clear`, `New` + +#### MS Dynamics Site Map Group Area + +- **name:** `json::/nitroXPackages/ms-dynamics/SiteMapAreaGroup` +- **type:** `container` +- **tagName:** `lix` +- **attributes:** `Label` + +#### MS Dynamics Site Map Entity + +- **name:** `json::/nitroXPackages/ms-dynamics/SiteMapEntity` +- **type:** `container` +- **tagName:** `li` +- **interactions:** `Click` +- **attributes:** `Label` + +#### MS Dynamics Site Map Group Area + +- **name:** `json::/nitroXPackages/ms-dynamics/SiteMapEntityAreaGroup` +- **type:** `container` +- **tagName:** `li` +- **interactions:** `Click` +- **attributes:** `Label` + +#### MS Dynamics Site Pinned Group + +- **name:** `json::/nitroXPackages/ms-dynamics/SiteMapEntityPinnedGroup` +- **type:** `container` +- **tagName:** `li,div` +- **interactions:** `Activate` +- **attributes:** `Label` + +#### MS Dynamics Site Recent Group + +- **name:** `json::/nitroXPackages/ms-dynamics/SiteMapEntityRecentGroup` +- **type:** `container` +- **tagName:** `li,div` +- **interactions:** `Activate` +- **attributes:** `Label` + +#### MS Dynamics Tab + +- **name:** `json::/nitroXPackages/ms-dynamics/TabPanel` +- **type:** `container` +- **tagName:** `li,div` +- **interactions:** `Activate`, `Locate` +- **attributes:** `Title`, `Accessibility Label`, `Selected`, `Tab Name` + +#### MS Dynamics Text Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/TextFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` + +#### MS Dynamics Whole Number Field Section Item + +- **name:** `json::/nitroXPackages/ms-dynamics/WholeNumberFieldSectionItem` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Set` + +#### Ms Dynamics Column + +- **name:** `json::/nitroXPackages/ms-dynamics/WorkListColumn` +- **type:** `Column` +- **tagName:** `xxx` +- **interactions:** `Click` +- **attributes:** `Column Name`, `Label`, `Value`, `Column Type`, `Cell Type` + +#### MS Work List + +- **name:** `json::/nitroXPackages/ms-dynamics/WorkListTable` +- **type:** `table` +- **tagName:** `div` +- **attributes:** `Columns`, `Column Key`, `ColumnsData`, `Row Values`, `Row Count`, `CheckBox Column` + +#### Text Column + +- **name:** `json::/nitroXPackages/ms-dynamics/WorkListTextColumn` +- **type:** `column` +- **tagName:** `div` + +--- + +## omnistudio (v1.0.0) + +Package includes definitions for Omnistudio FACT elements +**Requires Provar:** >=2.10.2 + +### Components + +#### Omnistudio Select + +- **name:** `json::/com/provar/omnistudio/OmnistudioSelect` +- **type:** `container` +- **tagName:** `omnistudio-omniscript-select` +- **interactions:** `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Options`, `Label`, `Message`, `Name`, `Placeholder`, `Value Label`, `Read only`, `Required`, `Options`, `Options List`, `Value` +- **child elements:** 2 + +--- + +## runtimeOmnistudio (v1.0.0) + +Package includes definitions for Runtime Omnistudio FACT elements +**Requires Provar:** >=2.10.2 + +### Components + +#### Runtime Omnistudio Radio + +- **name:** `json::/com/provar/runtimeOmnistudio/RuntimeOmnistudioRadio` +- **type:** `container` +- **tagName:** `runtime_omnistudio_omniscript-omniscript-radio` +- **interactions:** `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Required`, `Value`, `Type` + +--- + +## salesforce-lwc (v1.9.23) + +Package includes definitions for Salesforce Lightning Web Components FACT elements +**Requires Provar:** >=2.10.2 + +### Components + +#### CheckBox Column + +- **name:** `json::/com/provar/salesforce/lwc/CheckBoxColumn` +- **type:** `container` +- **tagName:** `*` +- **interactions:** `Check`, `Uncheck`, `Select All Rows`, `Un-Select All Rows` + +#### Currency Column + +- **name:** `json::/com/provar/salesforce/lwc/CurrencyColumn` +- **type:** `container` +- **tagName:** `*` +- **attributes:** `Format style` + +#### Date Column + +- **name:** `json::/com/provar/salesforce/lwc/DateColumn` +- **type:** `container` +- **tagName:** `*` +- **interactions:** ` Inline Edit setDate` +- **attributes:** `Day`, `Era`, `Hour`, `Hour12`, `Minute`, `Month`, `Second`, `Weekday`, `Year` + +#### Lightning Accordion + +- **name:** `json::/com/provar/salesforce/lwc/LightningAccordion` +- **type:** `container` +- **tagName:** `lightning-accordion` + +#### Lightning Accordion Section + +- **name:** `json::/com/provar/salesforce/lwc/LightningAccordionSection` +- **type:** `container` +- **tagName:** `lightning-accordion-section` +- **interactions:** `Activate`, `Expand Section`, `Collapse Section` +- **attributes:** `Label`, `Name`, `Visible` +- **child elements:** 1 + +#### Lightning Address Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningAddressInput` +- **type:** `container` +- **tagName:** `lightning-input-address` +- **interactions:** `Set`, `Set Street`, `Set City`, `Set Province`, `Set Country`, `Set Postal Code` +- **attributes:** `Address label`, `Address lookup placeholder`, `City`, `City label`, `City placeholder`, `Country`, `Country disabled`, `Country label`, `Country options`, `Country placeholder`, `Disabled`, `Field level help`, `Postal code`, `Postal code label`, `Postal code placeholder`, `Province`, `Province label`, `Province options`, `Province placeholder`, `Read only`, `Required`, `Show address lookup`, `Street`, `Street label`, `Street placeholder`, `Visible` +- **child elements:** 14 + +#### Lightning Button + +- **name:** `json::/com/provar/salesforce/lwc/LightningButton` +- **type:** `container` +- **tagName:** `lightning-button` +- **interactions:** `Click` +- **attributes:** `Icon name`, `Label`, `Name`, `Title`, `Class`, `Variant`, `Disabled`, `Visible` + +#### Lightning Button Group + +- **name:** `json::/com/provar/salesforce/lwc/LightningButtonGroup` +- **type:** `container` +- **tagName:** `lightning-button-group` + +#### Lightning Button Icon + +- **name:** `json::/com/provar/salesforce/lwc/LightningButtonIcon` +- **type:** `container` +- **tagName:** `lightning-button-icon` +- **interactions:** `Click` +- **attributes:** `Icon name`, `Alternative Text`, `Tooltip`, `Name`, `Title`, `Class`, `Disabled`, `Variant`, `Size`, `Visible` + +#### Lightning Button Icon Stateful + +- **name:** `json::/com/provar/salesforce/lwc/LightningButtonIconStateful` +- **type:** `container` +- **tagName:** `lightning-button-icon-stateful` +- **interactions:** `Toggle On`, `Toggle Off`, `Toggle` +- **attributes:** `Name`, `Title`, `Icon name`, `Alternative Text`, `Size`, `Disabled`, `Class`, `Selected`, `Variant`, `Visible` + +#### Lightning Button Menu + +- **name:** `json::/com/provar/salesforce/lwc/LightningButtonMenu` +- **type:** `container` +- **tagName:** `lightning-button-menu` +- **interactions:** `Click`, `Activate` +- **attributes:** `Value`, `Label`, `Tooltip`, `Access key`, `Title`, `Icon name`, `Icon Size`, `Visible` + +#### Lightning Button Stateful + +- **name:** `json::/com/provar/salesforce/lwc/LightningButtonStateful` +- **type:** `container` +- **tagName:** `lightning-button-stateful` +- **interactions:** `Toggle On`, `Toggle Off`, `Toggle` +- **attributes:** `Icon name when hover`, `Icon name when off`, `Icon name when on`, `Label when hover`, `Label when off`, `Label when on`, `Disabled`, `Class`, `Selected`, `Variant`, `Visible` + +#### Lightning Card + +- **name:** `json::/com/provar/salesforce/lwc/LightningCard` +- **type:** `container` +- **tagName:** `lightning-card` +- **attributes:** `Title`, `Icon name`, `Class`, `Visible` +- **child elements:** 1 + +#### Lightning Checkbox Button Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningCheckboxButtonInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Check`, `Uncheck`, `Toggle` +- **attributes:** `Name`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Checked`, `Type`, `Message`, `Visible` +- **child elements:** 2 + +#### Lightning Checkbox Group + +- **name:** `json::/com/provar/salesforce/lwc/LightningCheckboxGroup` +- **type:** `container` +- **tagName:** `lightning-checkbox-group` +- **interactions:** `Check`, `Uncheck` +- **attributes:** `Name`, `Label`, `Disabled`, `Class`, `Required`, `Message when value missing`, `Visible` +- **child elements:** 2 + +#### Lightning Checkbox Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningCheckboxInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Check`, `Uncheck`, `Toggle`, `Set` +- **attributes:** `Name`, `Label`, `Disabled`, `Class`, `Required`, `Checked`, `Type`, `Visible` +- **child elements:** 2 + +#### Lightning Column + +- **name:** `json::/com/provar/salesforce/lwc/LightningColumn` +- **type:** `Column` +- **tagName:** `*` +- **interactions:** `Sort Column`, `Wrap Text`, ` Inline Edit set`, `Append Inline Edit`, `Clear Inline Edit`, `Clip Text` +- **attributes:** `Column Key`, `Label`, `Value`, `Column Type`, `Cell Type` + +#### Lightning Combobox + +- **name:** `json::/com/provar/salesforce/lwc/LightningCombobox` +- **type:** `container` +- **tagName:** `lightning-combobox` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Field level help`, `Label`, `Options`, `Options List`, `Message`, `Name`, `Placeholder`, `Read only`, `Required`, `Value Label`, `Value`, `Visible` +- **child elements:** 2 + +#### Lightning Datatable + +- **name:** `json::/com/provar/Table/LightningDatatable` +- **type:** `table` +- **tagName:** `lightning-datatable` +- **attributes:** `Columns`, `ColumnsFields`, `ROW Number`, `CheckBox Column` + +#### Lightning Date Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningDateInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set`, `Set Date`, `Set Today` +- **attributes:** `Name`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Min`, `Max`, `Value`, `Formatted Value`, `Placeholder`, `Type`, `Message`, `Visible` +- **child elements:** 2 + +#### Lightning Date Time Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningDateTimeInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set`, `Set Now`, `Set Date and Time`, `Set Date`, `Set Time` +- **attributes:** `Name`, `Label`, `Disabled`, `Visible`, `Read only`, `Class`, `Required`, `Min`, `Max`, `Value`, `Type`, `Timezone`, `Message`, `Formatted Time Value`, `Formatted Date Value` +- **child elements:** 6 + +#### Lightning Dual Listbox + +- **name:** `json::/com/provar/salesforce/lwc/LightningDualListbox` +- **type:** `container` +- **tagName:** `lightning-dual-listbox` +- **interactions:** `Set` +- **attributes:** `Add button label`, `Visible`, `Disable reordering`, `Disabled`, `Down button label`, `Field level help`, `Label`, `Max`, `Message when range overflow`, `Message when range underflow`, `Message when value missing`, `Min`, `Name`, `Remove button label`, `Required`, `Selected label`, `Source label`, `Up button label`, `Value`, `Required options` +- **child elements:** 6 + +#### Lightning Email Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningEmailInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Multiple`, `Value`, `Placeholder`, `Type`, `Message` +- **child elements:** 2 + +#### Lightning File Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningFileInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Upload Files` +- **attributes:** `Accepted File Types`, `Visible`, `Disabled`, `Label`, `Multiple Files Allowed`, `Name`, `Message` + +#### Lightning File Upload + +- **name:** `json::/com/provar/salesforce/lwc/LightningFileUpload` +- **type:** `container` +- **tagName:** `lightning-file-upload` +- **interactions:** `Upload Files` +- **attributes:** `Accepted File Types`, `Visible`, `Disabled`, `Label`, `Multiple Files Allowed`, `Name`, `Message` + +#### Lightning Formatted Address + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedAddress` +- **type:** `container` +- **tagName:** `lightning-formatted-address` +- **interactions:** `Click` +- **attributes:** `City`, `Visible`, `Country`, `Disabled`, `Latitude`, `Longitude`, `Postal Code`, `Province`, `Show static map`, `Street` + +#### Lightning Formatted Date Time + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedDateTime` +- **type:** `container` +- **tagName:** `lightning-formatted-date-time` +- **attributes:** `Day`, `Visible`, `Era`, `Hour`, `Hour12`, `Minute`, `Month`, `Second`, `Value`, `Weekday`, `Year` + +#### Lightning Formatted Email + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedEmail` +- **type:** `container` +- **tagName:** `lightning-formatted-email` +- **interactions:** `Click` +- **attributes:** `Value`, `Visible`, `Label`, `Hide-icon` + +#### Lightning Formatted Lookup + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedLookup` +- **type:** `container` +- **tagName:** `lightning-formatted-lookup` +- **interactions:** `Click` +- **attributes:** `Visible`, `Display value` + +#### Lightning Formatted Name + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedName` +- **type:** `container` +- **tagName:** `lightning-formatted-name` +- **attributes:** `Salutation`, `Visible`, `First Name`, `Middle Name`, `Last Name`, `Suffix`, `Informal Name`, `Format`, `Inner Text` + +#### Lightning Formatted Number + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedNumber` +- **type:** `container` +- **tagName:** `lightning-formatted-number` +- **attributes:** `Value`, `Visible`, `Inner text`, `Format style`, `Currency code`, `Currency display as`, `Maximum fraction digits`, `Maximum significant digits`, `Minimum fraction digits`, `Minimum integer digits`, `Minimum significant digits` + +#### Lightning Formatted Phone + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedPhone` +- **type:** `container` +- **tagName:** `lightning-formatted-phone` +- **attributes:** `Value`, `Visible`, `Disabled` + +#### Lightning Formatted Rich Text + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedRichText` +- **type:** `container` +- **tagName:** `lightning-formatted-rich-text` +- **attributes:** `Value`, `Visible`, `Text Content`, `innerText` + +#### Lightning Formatted Text + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedText` +- **type:** `container` +- **tagName:** `lightning-formatted-text` +- **interactions:** `Click` +- **attributes:** `Value`, `Visible`, `Linkify` + +#### Lightning Formatted Url + +- **name:** `json::/com/provar/salesforce/lwc/LightningFormattedUrl` +- **type:** `container` +- **tagName:** `lightning-formatted-url` +- **interactions:** `Click` +- **attributes:** `Value`, `Visible`, `Label`, `Tooltip` + +#### Lightning Grouped Combobox + +- **name:** `json::/com/provar/salesforce/lwc/LightningGroupedCombobox` +- **type:** `container` +- **tagName:** `lightning-grouped-combobox` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Visible`, `Field level help`, `Label`, `Message`, `Name`, `Placeholder`, `Read only`, `Required`, `Value Label`, `Value` +- **child elements:** 2 + +#### Lightning Helptext + +- **name:** `json::/com/provar/salesforce/lwc/LightningHelptext` +- **type:** `container` +- **tagName:** `lightning-helptext` +- **interactions:** `Hover` +- **attributes:** `Content`, `Visible`, `Icon name` + +#### Lightning Icon + +- **name:** `json::/com/provar/salesforce/lwc/LightningIcon` +- **type:** `container` +- **tagName:** `lightning-icon` +- **interactions:** `Click` +- **attributes:** `Icon name`, `Visible`, `Title`, `Class`, `Size` + +#### Lightning Input Field + +- **name:** `json::/com/provar/salesforce/lwc/LightningInputField` +- **type:** `container` +- **tagName:** `lightning-input-field` +- **attributes:** `Field name` + +#### Lightning Layout + +- **name:** `json::/com/provar/salesforce/lwc/LightningLayout` +- **type:** `container` +- **tagName:** `lightning-layout` + +#### Lightning Layout Item + +- **name:** `json::/com/provar/salesforce/lwc/LightningLayoutItem` +- **type:** `container` +- **tagName:** `lightning-layout-item` + +#### Lightning Location Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningLocationInput` +- **type:** `container` +- **tagName:** `lightning-input-location` +- **interactions:** `Set`, `Set Latitude`, `Set Longitude` +- **attributes:** `Label`, `Visible`, `Disabled`, `Read only`, `Class`, `Required`, `Field level help`, `Latitude`, `Longitude` +- **child elements:** 5 + +#### Lightning Lookup + +- **name:** `json::/com/provar/salesforce/lwc/LightningLookup` +- **type:** `container` +- **tagName:** `lightning-lookup` +- **interactions:** `Set`, `Show All Results` +- **attributes:** `Field name`, `Class`, `Visible`, `Disabled`, `Label`, `Required`, `Value`, `Text Value` + +#### Lightning Lookup Address + +- **name:** `json::/com/provar/salesforce/lwc/LightningLookupAddress` +- **type:** `container` +- **tagName:** `lightning-lookup-address` +- **interactions:** `Enter and Select` +- **attributes:** `Disabled`, `Visible`, `Placeholder` + +#### Lightning Menu Item + +- **name:** `json::/com/provar/salesforce/lwc/LightningMenuItem` +- **type:** `container` +- **tagName:** `lightning-menu-item` +- **interactions:** `Click`, `Deactivate` +- **attributes:** `Checked`, `Visible`, `Disabled`, `Download`, `Href`, `Icon name`, `Label`, `Value` + +#### Lightning Number Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningNumberInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Value`, `Formatted Value`, `Placeholder`, `Type`, `Message` +- **child elements:** 2 + +#### Lightning Output Field + +- **name:** `json::/com/provar/salesforce/lwc/LightningOutputField` +- **type:** `container` +- **tagName:** `lightning-output-field` +- **attributes:** `Field class`, `Field name` +- **child elements:** 4 + +#### Lightning Password Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningPasswordInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Value`, `Placeholder`, `Type`, `Pattern`, `Message` +- **child elements:** 2 + +#### Lightning PickList + +- **name:** `json::/com/provar/salesforce/lwc/LightningPicklist` +- **type:** `container` +- **tagName:** `lightning-picklist` +- **interactions:** `Set`, `Set By Index` +- **attributes:** `Disabled`, `Visible`, `Label`, `Name`, `Read only`, `Required`, `Value`, `Options List`, `Options`, `Message` +- **child elements:** 2 + +#### Lightning Pill + +- **name:** `json::/com/provar/salesforce/lwc/LightningPill` +- **type:** `container` +- **tagName:** `lightning-pill` +- **interactions:** `Click`, `Remove` +- **attributes:** `Name`, `Visible`, `Label`, `Href`, `Has error`, `Role` + +#### Lightning Progress Indicator + +- **name:** `json::/com/provar/salesforce/lwc/LightningProgressIndicator` +- **type:** `container` +- **tagName:** `lightning-progress-indicator` +- **attributes:** `Has error`, `Current step`, `Variant`, `Type` + +#### Lightning Progress Step + +- **name:** `json::/com/provar/salesforce/lwc/LightningProgressStep` +- **type:** `container` +- **tagName:** `lightning-progress-step` +- **interactions:** `Select Step` +- **attributes:** `Label`, `Visible`, `Value` + +#### Lightning Radio Group + +- **name:** `json::/com/provar/salesforce/lwc/LightningRadioGroup` +- **type:** `container` +- **tagName:** `lightning-radio-group` +- **interactions:** `Check` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Class`, `Required`, `Message when value missing` +- **child elements:** 2 + +#### Lightning Record Edit Form + +- **name:** `json::/com/provar/salesforce/lwc/LightningRecordEditForm` +- **type:** `container` +- **tagName:** `lightning-record-edit-form` +- **attributes:** `Object API name`, `Record id`, `Record type id` + +#### Lightning Record Form + +- **name:** `json::/com/provar/salesforce/lwc/LightningRecordForm` +- **type:** `container` +- **tagName:** `lightning-record-form` + +#### Lightning Record Picker + +- **name:** `json::/com/provar/salesforce/lwc/LightningRecordPicker` +- **type:** `container` +- **tagName:** `lightning-record-picker` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Visible`, `Label`, `Name`, `Read only`, `Required`, `Value`, `Message` +- **child elements:** 2 + +#### Lightning Record View Form + +- **name:** `json::/com/provar/salesforce/lwc/LightningRecordViewForm` +- **type:** `container` +- **tagName:** `lightning-record-view-form` + +#### Lightning Rich Text Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningRichTextInput` +- **type:** `container` +- **tagName:** `lightning-input-rich-text` +- **interactions:** `Clear`, `Set` +- **attributes:** `Label`, `Visible`, `Disabled`, `Class`, `Required`, `Value`, `Placeholder` +- **child elements:** 2 + +#### Lightning Search Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningSearchInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Value`, `Placeholder`, `Type` +- **child elements:** 2 + +#### Lightning Select + +- **name:** `json::/com/provar/salesforce/lwc/LightningSelect` +- **type:** `container` +- **tagName:** `lightning-select` +- **interactions:** `Set` +- **attributes:** `Disabled`, `Visible`, `Label`, `Name`, `Read only`, `Required`, `Value`, `Message` +- **child elements:** 2 + +#### Lightning Slider + +- **name:** `json::/com/provar/salesforce/lwc/LightningSlider` +- **type:** `container` +- **tagName:** `lightning-slider` +- **interactions:** `Set Slider Value` +- **attributes:** `Label`, `Visible`, `Disabled`, `Min`, `Max`, `Step`, `Value` +- **child elements:** 3 + +#### Lightning Tab + +- **name:** `json::/com/provar/salesforce/lwc/LightningTab` +- **type:** `container` +- **tagName:** `lightning-tab` +- **interactions:** `Activate`, `Select Tab` +- **attributes:** `End icon name`, `Visible`, `Icon name`, `Title`, `Label`, `Show error indicator` + +#### Lightning Tabset + +- **name:** `json::/com/provar/salesforce/lwc/LightningTabset` +- **type:** `container` +- **tagName:** `lightning-tabset` +- **interactions:** `Set` +- **attributes:** `Active tab value`, `Visible`, `Title` + +#### Lightning Telephone Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningTelephoneInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Value`, `Placeholder`, `Type`, `Message` +- **child elements:** 2 + +#### Lightning Text Area + +- **name:** `json::/com/provar/salesforce/lwc/LightningTextArea` +- **type:** `container` +- **tagName:** `lightning-textarea` +- **interactions:** `Clear`, `Set`, `Set Text` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Value`, `Placeholder`, `Message`, `Max length` +- **child elements:** 2 + +#### Lightning Text Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningTextInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Append`, `Set` +- **attributes:** `Name`, `Data-id`, `Message when value missing`, `Message when pattern mismatch`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Message`, `Value`, `Placeholder`, `Type`, `Visible` +- **child elements:** 2 + +#### Lightning Tile + +- **name:** `json::/com/provar/salesforce/lwc/LightningTile` +- **type:** `container` +- **tagName:** `lightning-tile` +- **interactions:** `Click` +- **attributes:** `Label`, `Visible`, `Actions`, `Link` + +#### Lightning Time Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningTimeInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Placeholder`, `Value`, `Formatted Value`, `Min`, `Max`, `Type`, `Visible`, `Message` +- **child elements:** 2 + +#### Lightning Toggle Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningToggleInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Check`, `Uncheck`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Checked`, `Type`, `Message` +- **child elements:** 2 + +#### Lightning Url Input + +- **name:** `json::/com/provar/salesforce/lwc/LightningUrlInput` +- **type:** `container` +- **tagName:** `lightning-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Value`, `Placeholder`, `Type`, `Message` +- **child elements:** 2 + +#### Lightning Vertical Navigation + +- **name:** `json::/com/provar/salesforce/lwc/LightningVerticalNavigation` +- **type:** `container` +- **tagName:** `lightning-vertical-navigation` +- **attributes:** `Selected item` + +#### Lightning Vertical Navigation Item + +- **name:** `json::/com/provar/salesforce/lwc/LightningVerticalNavigationItem` +- **type:** `container` +- **tagName:** `lightning-vertical-navigation-item` +- **interactions:** `Click` +- **attributes:** `Label`, `Visible`, `Name` + +#### Lightning Vertical Navigation Item Badge + +- **name:** `json::/com/provar/salesforce/lwc/LightningVerticalNavigationItemBadge` +- **type:** `container` +- **tagName:** `lightning-vertical-navigation-item-badge` +- **interactions:** `Click` +- **attributes:** `Label`, `Visible`, `Name`, `Badge count` + +#### Lightning Vertical Navigation Item Icon + +- **name:** `json::/com/provar/salesforce/lwc/LightningVerticalNavigationItemIcon` +- **type:** `container` +- **tagName:** `lightning-vertical-navigation-item-icon` +- **interactions:** `Click` +- **attributes:** `Label`, `Visible`, `Name`, `Icon name` + +#### Lightning Vertical Navigation Overflow + +- **name:** `json::/com/provar/salesforce/lwc/LightningVerticalNavigationOverflow` +- **type:** `container` +- **tagName:** `lightning-vertical-navigation-overflow` +- **interactions:** `Click` +- **attributes:** `Text`, `Visible`, `Label` + +#### Lightning Vertical Navigation Section + +- **name:** `json::/com/provar/salesforce/lwc/LightningVerticalNavigationSection` +- **type:** `container` +- **tagName:** `lightning-vertical-navigation-section` +- **attributes:** `Visible`, `Label` + +#### Number Column + +- **name:** `json::/com/provar/salesforce/lwc/NumberColumn` +- **type:** `container` +- **tagName:** `*` +- **attributes:** `Format style`, `Currency code`, `Currency display as`, `Maximum fraction digits`, `Maximum significant digits`, `Minimum fraction digits`, `Minimum integer digits`, `Minimum significant digits` + +#### Phone column + +- **name:** `json::/com/provar/salesforce/lwc/PhoneColumn` +- **type:** `container` +- **tagName:** `*` +- **attributes:** `Disabled` + +#### Text Column + +- **name:** `json::/com/provar/salesforce/lwc/TextColumn` +- **type:** `container` +- **tagName:** `*` +- **attributes:** `Linkify` + +#### Url Column + +- **name:** `json::/com/provar/salesforce/lwc/UrlColumn` +- **type:** `container` +- **tagName:** `*` +- **interactions:** `Click` +- **attributes:** `Tooltip` + +--- + +## screenflow (v1.4.14) + +Package includes definitions for Fact Component +**Requires Provar:** >=2.9.0 + +### Components + +#### Screen Flow Address + +- **name:** `json::/com/provar/Screenflows/ScreenFlowAddress` +- **type:** `container` +- **tagName:** `flowruntime-address` +- **interactions:** `Clear`, `Set`, `Set Street`, `Set City`, `Set Province`, `Set Country`, `Set Postal Code` +- **attributes:** `Address label`, `Visible`, `City`, `Country`, `Postal code`, `Province`, `Street` + +#### Screen Flow Announcer + +- **name:** `json::/com/provar/Screenflows/ScreenFlowAnnouncer` +- **type:** `container` +- **tagName:** `flowruntime-a11y-announcer` + +#### Screen Flow Checkbox Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowCheckboxInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Check`, `Uncheck`, `Toggle`, `Set` +- **attributes:** `Name`, `DataType`, `Label`, `Visible`, `Class`, `Value` + +#### Screen Flow Choice Lookup + +- **name:** `json::/com/provar/Screenflows/ScreenFlowChoiceLookup` +- **type:** `container` +- **tagName:** `flowruntime-choice-lookup` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Class`, `Required`, `Value`, `Message` + +#### Screen Flow Currency Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowCurrencyInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `FieldDataType`, `Label`, `Class`, `Required`, `Visible`, `Value`, `Message` +- **child elements:** 2 + +#### Screen Flow Date Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowDateInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Clear`, `Set`, `Set Date`, `Set Today` +- **attributes:** `Name`, `Visible`, `FieldDataType`, `Label`, `Class`, `Required`, `Value`, `Placeholder`, `Message` + +#### Screen Flow Date Time Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowDateTimeInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Clear`, `Set`, `Set Now`, `Set Date and Time`, `Set Date`, `Set Time` +- **attributes:** `Name`, `Visible`, `FieldDataType`, `Label`, `Class`, `Required`, `Value`, `Message` + +#### Screen Flow Display Text + +- **name:** `json::/com/provar/Screenflows/ScreenFlowDisplayText` +- **type:** `container` +- **tagName:** `flowruntime-display-text-lwc` +- **attributes:** `Name`, `Visible`, `Class`, `Value`, `FieldType`, `Label` + +#### Screen Flow Email + +- **name:** `json::/com/provar/Screenflows/ScreenFlowEmail` +- **type:** `container` +- **tagName:** `flowruntime-email` +- **interactions:** `Clear`, `Set` +- **attributes:** `Label`, `Visible`, `Class`, `Required`, `Disabled`, `Read only`, `Value` + +#### Screen Flow Error Content + +- **name:** `json::/com/provar/Screenflows/ScreenFlowErrorContent` +- **type:** `container` +- **tagName:** `flowruntime-error-content` +- **attributes:** `Visible`, `Error` + +#### Screen Flow Long Text Area Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowLongTextAreaInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Class`, `Required`, `Value`, `FieldType`, `Message` +- **child elements:** 2 + +#### Screen Flow Lwc Body + +- **name:** `json::/com/provar/Screenflows/ScreenFlowLwcBody` +- **type:** `container` +- **tagName:** `flowruntime-lwc-body` +- **attributes:** `FlowLabel`, `Screen Developer Name`, `Current Flow Version Id` + +#### Screen Flow Lwc Header + +- **name:** `json::/com/provar/Screenflows/ScreenFlowLwcHeader` +- **type:** `container` +- **tagName:** `flowruntime-lwc-header` + +#### Screen Flow Multi Checkbox Lwc + +- **name:** `json::/com/provar/Screenflows/ScreenFlowMultiCheckboxLwc` +- **type:** `container` +- **tagName:** `flowruntime-multi-checkbox-lwc` +- **interactions:** `Set`, `Clear`, `Select All` +- **attributes:** `Name`, `Visible`, `Label`, `Required`, `Class`, `Value`, `Message` + +#### Screen Flow Name + +- **name:** `json::/com/provar/Screenflows/ScreenFlowName` +- **type:** `container` +- **tagName:** `flowruntime-name` +- **interactions:** `Clear`, `Set`, `Set`, `Set`, `Set`, `Set`, `Set`, `Set` +- **attributes:** `Label`, `Visible`, `Disabled`, `Read only`, `Class`, `First Name`, `Last Name`, `Message` + +#### Screen Flow Navigation Bar + +- **name:** `json::/com/provar/Screenflows/ScreenFlowNavigationBar` +- **type:** `container` +- **tagName:** `flowruntime-navigation-bar` + +#### Screen Flow Number Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowNumberInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Class`, `Required`, `Value`, `FieldDataType`, `Message` + +#### Screen Flow Password Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowPasswordInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Class`, `Required`, `Value`, `FieldType` + +#### Screen Flow Phone + +- **name:** `json::/com/provar/Screenflows/ScreenFlowPhone` +- **type:** `container` +- **tagName:** `flowruntime-phone` +- **interactions:** `Clear`, `Set` +- **attributes:** `Label`, `Visible`, `Class`, `Required`, `Read only`, `Value`, `Placeholder` + +#### Screen Flow Picklist Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowPicklistInput` +- **type:** `container` +- **tagName:** `flowruntime-picklist-input-lwc` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Class`, `Options`, `Options List`, `Required`, `Value`, `Message` + +#### Screen Flow Radio Button Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowRadioButtonInput` +- **type:** `container` +- **tagName:** `flowruntime-radio-button-input-lwc` +- **interactions:** `Set`, `Clear` +- **attributes:** `Name`, `Label`, `Required`, `Class`, `Value`, `Visible`, `Message` + +#### Screen Flow Screen Field + +- **name:** `json::/com/provar/Screenflows/ScreenFlowScreenField` +- **type:** `container` +- **tagName:** `flowruntime-screen-field` +- **attributes:** `Field Name` + +#### Screen Flow Section With Header + +- **name:** `json::/com/provar/Screenflows/ScreenFlowSectionWithHeader` +- **type:** `container` +- **tagName:** `flowruntime-section-with-header` +- **attributes:** `Section Name`, `Visible`, `Section Heading` + +#### Screen Flow Slider + +- **name:** `json::/com/provar/Screenflows/ScreenFlowSlider` +- **type:** `container` +- **tagName:** `flowruntime-slider` +- **interactions:** `Set Slider Value` +- **attributes:** `Label`, `Visible`, `Disabled`, `Min`, `Max`, `Step`, `Value` + +#### Screen Flow Text Input + +- **name:** `json::/com/provar/Screenflows/ScreenFlowTextInput` +- **type:** `container` +- **tagName:** `flowruntime-flow-screen-input` +- **interactions:** `Clear`, `Set` +- **attributes:** `Name`, `Visible`, `Label`, `Class`, `Required`, `Value`, `FieldDataType`, `Message` + +#### Screen Flow Toggle + +- **name:** `json::/com/provar/Screenflows/ScreenFlowToggle` +- **type:** `container` +- **tagName:** `flowruntime-toggle` +- **interactions:** `Check`, `Uncheck`, `Toggle`, `Set` +- **attributes:** `Label`, `Visible`, `Message Toggle Active`, `Message Toggle Inactive`, `Class`, `Disabled`, `Value` + +#### Screen Flow Url + +- **name:** `json::/com/provar/Screenflows/ScreenFlowUrl` +- **type:** `container` +- **tagName:** `flowruntime-url` +- **interactions:** `Clear`, `Set` +- **attributes:** `Label`, `Visible`, `Class`, `Required`, `Disabled`, `Read only`, `Value`, `Message` + +--- + +## vlocityIns (v1.0.13) + +Package includes definitions for Vlocity Ins Components FACT elements +**Requires Provar:** >=2.10.2 + +### Components + +#### Vlocity Ins Block + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsBlock` +- **type:** `container` +- **tagName:** `vlocity_ins-block` +- **attributes:** `Data Style Id`, `Data Action Index`, `Data Omni Key` + +#### Vlocity Ins Button + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsButton` +- **type:** `container` +- **tagName:** `vlocity_ins-button` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Title`, `Variant` + +#### Vlocity Button Group + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsButtonGroup` +- **type:** `container` +- **tagName:** `c-atlas-d-x-radio-extended-s` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible` + +#### Vlocity Flex Card State + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsCardState` +- **type:** `container` +- **tagName:** `vlocity_ins-flex-card-state` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible` + +#### Vlocity Ins Checkbox + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsCheckbox` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-checkbox` +- **interactions:** `Check`, `Uncheck`, `Toggle`, `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Disabled`, `Class`, `Required`, `Checked`, `Type` +- **child elements:** 2 + +#### Vlocity Ins Combobox + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsCombobox` +- **type:** `container` +- **tagName:** `vlocity_ins-combobox` +- **interactions:** `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Options`, `Label`, `Message`, `Name`, `Placeholder`, `Value Label`, `Read only`, `Required`, `Options`, `Options List`, `Value` +- **child elements:** 2 + +#### Vlocity Ins Currency + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsCurrency` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-currency` +- **interactions:** `Clear`, `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Required`, `Value`, `Message` +- **child elements:** 2 + +#### Vlocity Ins Custom Lwc + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsCustomLwc` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-custom-lwc` +- **attributes:** `Data Omni Key`, `Visible` + +#### Vlocity Ins Date + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsDate` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-date` +- **interactions:** `Set`, `Set Date`, `Set Today`, `Clear` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Placeholder`, `Read only`, `Required`, `Display Value`, `Min`, `Max`, `Value` +- **child elements:** 2 + +#### Vlocity Ins Dr Extract Action + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsDrExtractAction` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-dr-extract-action` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Class`, `Visible`, `Disabled`, `Label`, `Name`, `Title`, `Variant` + +#### Vlocity Ins Edit Block + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsEditBlock` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-edit-block` +- **attributes:** `Data Omni Key` +- **child elements:** 1 + +#### Vlocity Ins Edit Block Wrapper + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsEditBlockWrapper` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-edit-block-wrapper` +- **attributes:** `Visible`, `Data Omni Key` +- **child elements:** 1 + +#### Vlocity Ins Email + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsEmail` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-email` +- **interactions:** `Clear`, `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Multiple`, `Value`, `Placeholder`, `Type`, `Message` +- **child elements:** 2 + +#### Vlocity Ins Flex Action + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsFlexAction` +- **type:** `container` +- **tagName:** `vlocity_ins-flex-action` +- **interactions:** `Click` +- **attributes:** `Data Style Id`, `Data Action Index`, `Data Omni Key`, `Data Omni Key`, `Visible` + +#### Vlocity Form Add Botton + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsFormAddBotton` +- **type:** `container` +- **tagName:** `div` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible` + +#### Vlocity Ins Formula + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsFormula` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-formula` +- **interactions:** `Clear`, `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Required`, `Value`, `Message` +- **child elements:** 2 + +#### Vlocity Ins Input + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsInput` +- **type:** `container` +- **tagName:** `vlocity_ins-input` +- **interactions:** `Clear`, `Set`, `Append` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Placeholder`, `Read only`, `Required`, `Message`, `Value` +- **child elements:** 2 + +#### Vlocity Ip Action + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsIpAction` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-ip-action` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Title`, `Variant` + +#### Vlocity Ins Lookup + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsLookup` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-lookup` +- **interactions:** `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Options`, `Label`, `Message`, `Name`, `Placeholder`, `Value Label`, `Read only`, `Required`, `Options`, `Options List`, `Value` +- **child elements:** 2 + +#### Vlocity Ins Multi Select + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsMultiSelect` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-multiselect` +- **interactions:** `Check`, `Uncheck` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Disabled`, `Required` + +#### Vlocity Ins Navigate Action + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsNavigateAction` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-navigate-action` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Title`, `Variant` + +#### Vlocity Ins Number + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsNumber` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-number` +- **interactions:** `Clear`, `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Disabled`, `Read only`, `Required`, `Value`, `Formatted Value`, `Placeholder`, `Type`, `Message` +- **child elements:** 2 + +#### Vlocity Ins Omniscript Block + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsOmniscriptBlock` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-block` +- **attributes:** `Data Style Id`, `Data Action Index`, `Data Omni Key` + +#### Vlocity Ins Output Field + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsOutputField` +- **type:** `container` +- **tagName:** `vlocity_ins-output-field` +- **attributes:** `Data Style Id`, `Data Omni Key` + +#### Vlocity Ins Places Type Ahead + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsPlacesTypeAhead` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-places-typeahead` +- **interactions:** `Clear`, `Set`, `Append` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Placeholder`, `Required`, `Message`, `Value` +- **child elements:** 2 + +#### Vlocity Ins Radio + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsRadio` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-radio` +- **interactions:** `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Required`, `Value`, `Type` + +#### Vlocity Ins Select + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsSelect` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-select` +- **interactions:** `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Options`, `Label`, `Message`, `Name`, `Placeholder`, `Value Label`, `Read only`, `Required`, `Options`, `Options List`, `Value` +- **child elements:** 2 + +#### Vlocity Ins Set Values + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsSetValues` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-set-values` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Title`, `Variant` + +#### Vlocity Ins Step + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsStep` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-step` +- **attributes:** `Data Omni Key` + +#### Vlocity Ins Telephone + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsTelephone` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-telephone` +- **interactions:** `Clear`, `Set` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Disabled`, `Read only`, `Required`, `Value`, `Formatted Value`, `Placeholder`, `Type`, `Message` +- **child elements:** 2 + +#### Vlocity Ins Text + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsText` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-text` +- **interactions:** `Clear`, `Set`, `Append` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Placeholder`, `Read only`, `Required`, `Message`, `Value` +- **child elements:** 2 + +#### Vlocity Ins Text Area + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsTextArea` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-textarea` +- **interactions:** `Clear`, `Set`, `Append` +- **attributes:** `Data Omni Key`, `Visible`, `Name`, `Label`, `Disabled`, `Read only`, `Class`, `Required`, `Value`, `Placeholder`, `Message`, `Max length` +- **child elements:** 2 + +#### Vlocity Ins Transform Action + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsTransformAction` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-dr-transform-action` +- **interactions:** `Click` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Title`, `Variant` + +#### Vlocity Ins Type Ahead + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsTypeAhead` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-typeahead` +- **interactions:** `Clear`, `Set`, `Append` +- **attributes:** `Data Omni Key`, `Visible`, `Disabled`, `Label`, `Name`, `Placeholder`, `Required`, `Message`, `Value` +- **child elements:** 2 + +#### Vlocity Ins Type Ahead Block + +- **name:** `json::/com/provar/vlocityInsCloud/VlocityInsTypeAheadBlock` +- **type:** `container` +- **tagName:** `vlocity_ins-omniscript-typeahead-block` +- **attributes:** `Data Omni Key` +- **child elements:** 1 + +--- diff --git a/docs/development.md b/docs/development.md index 808726e4..67462321 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,18 +19,19 @@ Everything you need to clone, build, run locally, and test changes to `@provarte - [Full test + lint gate](#full-test--lint-gate) - [Linting and formatting](#linting-and-formatting) - [Developing the MCP server locally](#developing-the-mcp-server-locally) + - [MCP Inspector (interactive tool testing)](#mcp-inspector-interactive-tool-testing) - [Troubleshooting](#troubleshooting) --- ## Prerequisites -| Tool | Minimum version | How to check | -|------|----------------|--------------| -| Node.js | 18.0.0 | `node --version` | -| npm | 8+ (ships with Node 18) | `npm --version` | -| Salesforce CLI (`sf`) | any recent | `sf --version` | -| Git | any | `git --version` | +| Tool | Minimum version | How to check | +| --------------------- | ----------------------- | ---------------- | +| Node.js | 18.0.0 | `node --version` | +| npm | 8+ (ships with Node 18) | `npm --version` | +| Salesforce CLI (`sf`) | any recent | `sf --version` | +| Git | any | `git --version` | The Salesforce CLI is required to run the compiled plugin commands in development mode (`bin/dev.js`). Install it from [developer.salesforce.com/tools/salesforcecli](https://developer.salesforce.com/tools/salesforcecli) if needed. @@ -99,11 +100,13 @@ provardx-cli/ ## Build system overview This project uses **[Wireit](https://github.com/google/wireit)** as a build orchestrator on top of npm scripts. Wireit provides: + - **Incremental builds** — only recompiles files that changed - **Dependency ordering** — `build` depends on `compile` + `lint`; `test` depends on `test:compile` + `test:only` + `lint` - **Caching** — subsequent `npm run compile` runs skip unchanged files The compile step does two things: + 1. `tsc` — transpiles `src/**/*.ts` → `lib/` 2. `shx cp src/mcp/rules/*.json lib/mcp/rules/` — copies the JSON rules file (TypeScript does not copy non-`.ts` assets automatically) @@ -144,6 +147,7 @@ node bin/dev.js provar mcp start --allowed-paths /path/to/project ``` > **Tip:** On Unix you can `chmod +x bin/dev.js` and run it as `./bin/dev.js` or add a shell alias: +> > ```sh > alias sfdev="node $(pwd)/bin/dev.js" > sfdev provar mcp start @@ -159,11 +163,11 @@ node bin/dev.js provar mcp start --allowed-paths /path/to/project Unit tests live in `test/unit/mcp/` and cover all pure validator functions (no filesystem, no network, no Salesforce CLI required). There are three ways to run them, depending on your workflow: -| Command | When to use | -|---------|-------------| -| `npm run test:dev` | **Daily development** — always executes, never cached, fastest for iteration | +| Command | When to use | +| -------------------- | ------------------------------------------------------------------------------ | +| `npm run test:dev` | **Daily development** — always executes, never cached, fastest for iteration | | `npm run test:watch` | **TDD mode** — re-runs automatically whenever a `src/` or `test/` file changes | -| `npm run test:only` | **CI / pre-commit** — wireit-managed; skips if no files changed since last run | +| `npm run test:only` | **CI / pre-commit** — wireit-managed; skips if no files changed since last run | ```sh # Always runs — no caching, best for iterating on changes @@ -188,13 +192,13 @@ The test runner is **Mocha** with `ts-node/esm` as the loader, so test files are **Current test files and what they cover:** -| File | Covers | -|------|--------| -| `testCaseValidate.test.ts` | XML schema rules TC_001–TC_035 | -| `pageObjectValidate.test.ts` | Java PO rules PO_001–PO_080 | -| `pathPolicy.test.ts` | Path security policy (allowed paths, traversal) | -| `hierarchyValidate.test.ts` | Suite/plan/project structural + naming rules, `buildHierarchySummary` | -| `bestPracticesEngine.test.ts` | Scoring formula (exact Lambda parity), `runBestPractices` | +| File | Covers | +| ----------------------------- | --------------------------------------------------------------------- | +| `testCaseValidate.test.ts` | XML schema rules TC_001–TC_035 | +| `pageObjectValidate.test.ts` | Java PO rules PO_001–PO_080 | +| `pathPolicy.test.ts` | Path security policy (allowed paths, traversal) | +| `hierarchyValidate.test.ts` | Suite/plan/project structural + naming rules, `buildHierarchySummary` | +| `bestPracticesEngine.test.ts` | Scoring formula (exact Lambda parity), `runBestPractices` | ### Coverage @@ -282,8 +286,11 @@ Update `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) "command": "node", "args": [ "/absolute/path/to/provardx-cli/bin/dev.js", - "provar", "mcp", "start", - "--allowed-paths", "/path/to/your/provar/project" + "provar", + "mcp", + "start", + "--allowed-paths", + "/path/to/your/provar/project" ] } } @@ -292,6 +299,47 @@ Update `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) Restart Claude Desktop after saving. +### MCP Inspector (interactive tool testing) + +The [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based UI for calling MCP tools directly — no AI client required. Use it to iterate on tool schemas and verify request/response shapes. + +```sh +# Compile first, then launch the Inspector against the dev server +npm run compile +npx @modelcontextprotocol/inspector node bin/dev.js provar mcp start --allowed-paths /absolute/path/to/your/provar/project +``` + +> **Run this as a single line.** Line-wrapping the command (e.g. with a newline before `--allowed-paths`) causes the shell to treat `--allowed-paths` as a separate command and fail with `command not found`. Use `\` for explicit line continuation in bash if needed. + +If the Inspector fails with `Proxy Server PORT IS IN USE at port 6277`, a previous Inspector process is still running. Free the ports and try again: + +```sh +# macOS / Linux +kill $(lsof -ti :6277) $(lsof -ti :6274) 2>/dev/null + +# Windows PowerShell +foreach ($p in 6277,6274) { $c = Get-NetTCPConnection -LocalPort $p -EA 0; if ($c) { Stop-Process -Id $c.OwningProcess -Force } } +``` + +The command starts two processes: the MCP Inspector proxy (default port **6277**) and opens a browser tab at **http://localhost:6274**. From there you can: + +- Browse all registered tools and their input schemas +- Invoke any tool with custom JSON input +- Inspect the raw JSON-RPC request and response +- View server stderr logs in the **Notifications** panel + +> **Windows path note:** Use forward slashes or escaped backslashes in `--allowed-paths` (e.g. `C:/Users/you/provar-project` or `C:\\Users\\you\\provar-project`). The path must match one of the roots configured in `provardx-properties.json`. + +To pin a specific Inspector version or avoid repeated downloads, install it once globally: + +```sh +npm install -g @modelcontextprotocol/inspector +# Then run as: +mcp-inspector node bin/dev.js provar mcp start --allowed-paths /path/to/project +``` + +--- + ### Debugging MCP tool calls The MCP server writes structured logs to **stderr**. To see them when running manually: diff --git a/package.json b/package.json index 60481051..d00705a9 100644 --- a/package.json +++ b/package.json @@ -146,10 +146,11 @@ ] }, "compile": { - "command": "tsc -p . --pretty --incremental && shx mkdir -p lib/mcp/rules && shx cp src/mcp/rules/*.json lib/mcp/rules/ && shx mkdir -p lib/mcp/docs && shx cp docs/PROVAR_TEST_STEP_REFERENCE.md lib/mcp/docs/", + "command": "tsc -p . --pretty --incremental && shx mkdir -p lib/mcp/rules && shx cp src/mcp/rules/*.json lib/mcp/rules/ && shx mkdir -p lib/mcp/docs && shx cp docs/PROVAR_TEST_STEP_REFERENCE.md lib/mcp/docs/ && shx cp docs/NITROX_COMPONENT_CATALOG.md lib/mcp/docs/", "files": [ "src/**/*.ts", "src/mcp/rules/*.json", + "docs/NITROX_COMPONENT_CATALOG.md", "**/tsconfig.json", "messages/**" ], diff --git a/scripts/generate-nitrox-catalog.cjs b/scripts/generate-nitrox-catalog.cjs new file mode 100644 index 00000000..28e7a57a --- /dev/null +++ b/scripts/generate-nitrox-catalog.cjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Developer utility: regenerate docs/NITROX_COMPONENT_CATALOG.md from the + * local Provar NitroX base package installation. + * + * Run this whenever Provar ships updated NitroX packages, then commit the result. + * + * Usage: + * node scripts/generate-nitrox-catalog.cjs + * + * Requires: ~/Provar/.nitroX/com/provar/fact/_extracted_all to exist. + * (Run Provar NitroX at least once on this machine to extract the base packages.) + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const EXTRACTED_ALL_DIR = path.join(os.homedir(), 'Provar', '.nitroX', 'com', 'provar', 'fact', '_extracted_all'); +const OUTPUT_FILE = path.join(__dirname, '..', 'docs', 'NITROX_COMPONENT_CATALOG.md'); + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +function collectComponentFiles(pkgDir) { + const componentsDir = path.join(pkgDir, 'components'); + if (!fs.existsSync(componentsDir)) return []; + try { + return fs + .readdirSync(componentsDir) + .filter((f) => f.endsWith('.cp.json') || f.endsWith('.po.json')) + .sort() + .map((f) => path.join(componentsDir, f)); + } catch { + return []; + } +} + +function renderComponent(comp) { + const lines = []; + const heading = comp.label ?? comp.name ?? '(unnamed)'; + lines.push(`#### ${heading}`, ''); + if (comp.name) lines.push(`- **name:** \`${comp.name}\``); + if (comp.type) lines.push(`- **type:** \`${comp.type}\``); + if (comp.tagName) lines.push(`- **tagName:** \`${comp.tagName}\``); + + const interactions = (comp.interactions ?? []).map((i) => i.title ?? i.name ?? '').filter(Boolean); + if (interactions.length > 0) { + lines.push(`- **interactions:** ${interactions.map((n) => `\`${n}\``).join(', ')}`); + } + + const attributes = (comp.attributes ?? []).map((a) => a.title ?? a.attributeName ?? '').filter(Boolean); + if (attributes.length > 0) { + lines.push(`- **attributes:** ${attributes.map((n) => `\`${n}\``).join(', ')}`); + } + + const elementCount = (comp.elements ?? []).length; + if (elementCount > 0) lines.push(`- **child elements:** ${elementCount}`); + + lines.push(''); + return lines.join('\n'); +} + +function buildCatalog() { + if (!fs.existsSync(EXTRACTED_ALL_DIR)) { + console.error(`ERROR: packages not found at ${EXTRACTED_ALL_DIR}`); + console.error('Run Provar NitroX on this machine to extract base packages, then retry.'); + process.exit(1); + } + + const pkgDirEntries = fs + .readdirSync(EXTRACTED_ALL_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)); + + const lines = [ + '# NitroX Component Package Catalog', + '', + 'Shipped base NitroX (Hybrid Model) component packages.', + 'Use as a reference when generating new NitroX components — match naming conventions,', + 'type strings, tagNames, interaction titles, and attribute names from these shipped packages.', + '', + '---', + '', + ]; + + for (const entry of pkgDirEntries) { + const pkgDir = path.join(EXTRACTED_ALL_DIR, entry.name); + const meta = safeReadJson(path.join(pkgDir, 'package.json')) ?? {}; + + const displayName = meta.name ?? entry.name; + const displayVersion = meta.version ? ` (v${meta.version})` : ''; + lines.push(`## ${displayName}${displayVersion}`); + + if (meta.description) lines.push('', meta.description); + if (meta.provarVersion) lines.push(`**Requires Provar:** ${meta.provarVersion}`); + lines.push(''); + + const componentFiles = collectComponentFiles(pkgDir); + if (componentFiles.length === 0) { + lines.push('_No component definitions found._', '', '---', ''); + continue; + } + + lines.push('### Components', ''); + for (const compFile of componentFiles) { + const parsed = safeReadJson(compFile); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + lines.push(renderComponent(parsed)); + } + } + + lines.push('---', ''); + } + + return lines.join('\n'); +} + +const catalog = buildCatalog(); +fs.writeFileSync(OUTPUT_FILE, catalog, 'utf-8'); +const lineCount = catalog.split('\n').length; +console.log(`Written: docs/NITROX_COMPONENT_CATALOG.md (${lineCount} lines)`); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6d63b3d6..8d3b5c9d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -106,6 +106,35 @@ export function createProvarMcpServer(config: ServerConfig): McpServer { // ── Documentation resources ────────────────────────────────────────────────── const docsDir = join(dirname(fileURLToPath(import.meta.url)), 'docs'); + + server.resource( + 'provar-nitrox-component-catalog', + 'provar://nitrox/component-catalog', + { + description: + 'Catalog of all shipped NitroX (Hybrid Model) base component packages. Lists every package with its components, types, tagNames, interactions, and attributes. Read this before calling provar_nitrox_generate to understand available component patterns and naming conventions.', + mimeType: 'text/markdown', + }, + () => { + try { + const text = readFileSync(join(docsDir, 'NITROX_COMPONENT_CATALOG.md'), 'utf-8'); + return { + contents: [{ uri: 'provar://nitrox/component-catalog', mimeType: 'text/markdown', text }], + }; + } catch { + return { + contents: [ + { + uri: 'provar://nitrox/component-catalog', + mimeType: 'text/markdown', + text: '# NitroX Component Catalog\n\nCatalog not found. If you are developing from source, rebuild the package. Otherwise, reinstall or upgrade the plugin/package and try again.', + }, + ], + }; + } + } + ); + server.resource( 'provar-step-reference', 'provar://docs/step-reference', diff --git a/src/mcp/tools/nitroXTools.ts b/src/mcp/tools/nitroXTools.ts index 7b94b1a8..1a8821b1 100644 --- a/src/mcp/tools/nitroXTools.ts +++ b/src/mcp/tools/nitroXTools.ts @@ -724,6 +724,8 @@ export function registerNitroXGenerate(server: McpServer, config: ServerConfig): 'Generate a new NitroX .po.json (Hybrid Model page object) from a component description.', "Applicable to any component type supported by Provar's Hybrid Model:", 'LWC, Screen Flow, Industry Components, Experience Cloud, HTML5.', + 'Read the provar-nitrox-component-catalog resource first to understand available component types,', + 'tagName conventions, interaction titles, and attribute patterns from shipped base packages.', 'All componentId fields are assigned fresh UUIDs. Returns JSON content;', 'writes to disk only when dry_run=false.', ].join(' '), From 446c1fe087c84d8838c6db5d294ca20ab7e4c08c Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:18:00 -0500 Subject: [PATCH 05/12] PDX-0: test(mcp): assert nitrox_generate description references catalog resource RCA: No assertions verified the generate tool description mentions the component catalog resource, leaving a gap if the reference was accidentally removed. Fix: Add MockMcpServer.registrations capture and a describe block that asserts provar_nitrox_generate description includes provar-nitrox-component-catalog. Co-Authored-By: Claude Sonnet 4.6 --- test/unit/mcp/nitroXTools.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/unit/mcp/nitroXTools.test.ts b/test/unit/mcp/nitroXTools.test.ts index b0d0c2a8..c083d729 100644 --- a/test/unit/mcp/nitroXTools.test.ts +++ b/test/unit/mcp/nitroXTools.test.ts @@ -18,13 +18,16 @@ type ToolHandler = (args: Record) => unknown; class MockMcpServer { private handlers = new Map(); + public registrations: Array<{ name: string; description: string }> = []; public tool(name: string, _desc: string, _schema: unknown, handler: ToolHandler): void { this.handlers.set(name, handler); } - public registerTool(name: string, _config: unknown, handler: ToolHandler): void { + public registerTool(name: string, config: unknown, handler: ToolHandler): void { this.handlers.set(name, handler); + const desc = (config as Record)['description']; + if (typeof desc === 'string') this.registrations.push({ name, description: desc }); } public call(name: string, args: Record): ReturnType { @@ -533,6 +536,19 @@ describe('nitroXTools', () => { }); }); + // ── provar_nitrox_generate description ──────────────────────────────────── + + describe('provar_nitrox_generate description', () => { + it('references the nitrox-component-catalog resource', () => { + const reg = server.registrations.find((r) => r.name === 'provar_nitrox_generate'); + assert.ok(reg, 'provar_nitrox_generate not registered'); + assert.ok( + reg.description.includes('provar-nitrox-component-catalog'), + 'description should reference the component catalog resource' + ); + }); + }); + // ── provar_nitrox_patch ──────────────────────────────────────────────────── describe('provar_nitrox_patch', () => { From 95282698155f31eb35a3ebb9d22dd14660dc3494 Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:19:55 -0500 Subject: [PATCH 06/12] PDX-0: docs(mcp): document provar-nitrox-component-catalog MCP resource RCA: The MCP Resources section in docs/mcp.md still described only one resource after adding the NitroX component catalog, leaving documentation incomplete. Fix: Add catalog resource entry to ToC and Resources section; add tip to NitroX section recommending agents read the catalog before calling generate. Co-Authored-By: Claude Sonnet 4.6 --- docs/mcp.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 5024925e..6cc4f8d7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -72,6 +72,7 @@ The Provar DX CLI ships with a built-in **Model Context Protocol (MCP) server** - [provar.loop.db](#provarloopdb) - [MCP Resources](#mcp-resources) - [provar://docs/step-reference](#provardocsstep-reference) + - [provar://nitrox/component-catalog](#provarnitroxcomponent-catalog) - [AI loop pattern](#ai-loop-pattern) - [Quality scores explained](#quality-scores-explained) - [API compatibility — `xml` vs `xml_content`](#api-compatibility--xml-vs-xml_content) @@ -1577,6 +1578,8 @@ NitroX is Provar's **Hybrid Model** for locators. Instead of hand-written Java P The five `provar_nitrox_*` tools let an AI agent discover existing NitroX page objects, read them as training context, validate new ones against the schema, generate fresh components from a description, and apply surgical edits via JSON merge-patch. +> **Tip:** Before calling `provar_nitrox_generate`, read the `provar://nitrox/component-catalog` resource to understand the component types, tagName conventions, interaction titles, and attribute patterns from the shipped base packages. + > **Note:** NitroX page objects are read and written directly from disk using the standard file-system path policy (`--allowed-paths`). No `sf` subprocess is involved. --- @@ -1935,7 +1938,7 @@ Generate a Provar XML test case that connects to an **external database** (SQL S ## MCP Resources -The Provar MCP server also exposes one **MCP resource** — structured reference content that AI clients can read directly from the server. +The Provar MCP server exposes **MCP resources** — structured reference content that AI clients can read directly from the server. --- @@ -1950,6 +1953,17 @@ The resource content is the same as `docs/PROVAR_TEST_STEP_REFERENCE.md` in this --- +### `provar://nitrox/component-catalog` + +Catalog of all shipped NitroX (Hybrid Model) base component packages. Lists every package with its components, types, tagNames, interactions, and attributes. Read this before calling `provar_nitrox_generate` to understand available component patterns and naming conventions. + +**URI:** `provar://nitrox/component-catalog` +**MIME type:** `text/markdown` + +The resource content is the same as `docs/NITROX_COMPONENT_CATALOG.md` in this repository, compiled into the package at build time. To regenerate the catalog after Provar ships updated NitroX packages, run `node scripts/generate-nitrox-catalog.cjs` on a machine with Provar NitroX installed, then commit the result. + +--- + ## AI loop pattern The automation tools are designed to support an **AI-driven fix loop**: an agent can iteratively improve test quality without leaving the chat session. From 46150cebce5d3134f16c4640743f7d8ba540f3c0 Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:21:05 -0500 Subject: [PATCH 07/12] PDX-0: chore(release): bump version to 1.5.0-beta.17 RCA: Version bump required on any PR that triggers a publish; this PR adds the provar-nitrox-component-catalog MCP resource (new user-visible feature). Fix: Increment beta suffix from 16 to 17 in package.json and server.json. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- server.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d00705a9..99b723a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@provartesting/provardx-cli", "description": "A plugin for the Salesforce CLI to orchestrate testing activities and report quality metrics to Provar Quality Hub", - "version": "1.5.0-beta.16", + "version": "1.5.0-beta.17", "mcpName": "io.github.ProvarTesting/provar", "license": "BSD-3-Clause", "plugins": [ diff --git a/server.json b/server.json index 54eca322..4bf8741a 100644 --- a/server.json +++ b/server.json @@ -14,12 +14,12 @@ "url": "https://github.com/ProvarTesting/provardx-cli", "source": "github" }, - "version": "1.5.0-beta.16", + "version": "1.5.0-beta.17", "packages": [ { "registryType": "npm", "identifier": "@provartesting/provardx-cli", - "version": "1.5.0-beta.16", + "version": "1.5.0-beta.17", "transport": { "type": "stdio" }, From cbf76751b8b0ca510f5abd8512877cc261328a33 Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:29:46 -0500 Subject: [PATCH 08/12] PDX-0: fix(test): correct member-ordering in MockMcpServer RCA: ESLint @typescript-eslint/member-ordering requires public fields before private fields; the initial test commit had the order reversed, causing CI lint failure. Fix: Move public registrations declaration above private handlers field. Co-Authored-By: Claude Sonnet 4.6 --- test/unit/mcp/nitroXTools.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/mcp/nitroXTools.test.ts b/test/unit/mcp/nitroXTools.test.ts index c083d729..5ae0b221 100644 --- a/test/unit/mcp/nitroXTools.test.ts +++ b/test/unit/mcp/nitroXTools.test.ts @@ -17,8 +17,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; type ToolHandler = (args: Record) => unknown; class MockMcpServer { - private handlers = new Map(); public registrations: Array<{ name: string; description: string }> = []; + private handlers = new Map(); public tool(name: string, _desc: string, _schema: unknown, handler: ToolHandler): void { this.handlers.set(name, handler); From ba9b21f2ffe6cbd7afa489c6e807b5e4d74574a8 Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:38:22 -0500 Subject: [PATCH 09/12] PDX-0: fix(mcp): resolve docsDir for bundled resources in dev and compiled modes RCA: In dev/ts-node mode the sibling lib/mcp/docs/ path doesn't exist, so both MCP resources (nitrox-component-catalog and step-reference) always fell through to the "Catalog not found" fallback text, making them unusable during development. Fix: Extract resolveDocsDir() that probes the sibling docs/ dir and falls back to repo-root docs/ when absent; add server.test.ts covering both branches. Co-Authored-By: Claude Sonnet 4.6 --- docs/development.md | 2 ++ src/mcp/server.ts | 14 +++++++++-- test/unit/mcp/server.test.ts | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 test/unit/mcp/server.test.ts diff --git a/docs/development.md b/docs/development.md index 67462321..a84cffa9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -309,6 +309,8 @@ npm run compile npx @modelcontextprotocol/inspector node bin/dev.js provar mcp start --allowed-paths /absolute/path/to/your/provar/project ``` +> **MCP resources in dev mode:** `bin/dev.js` runs the TypeScript source via ts-node. The server automatically falls back to reading bundled Markdown resources (e.g. `provar://nitrox/component-catalog`, `provar://docs/step-reference`) from the repo-root `docs/` directory when the compiled `lib/mcp/docs/` path is not present, so resources work correctly after `npm run compile` with either entry point. + > **Run this as a single line.** Line-wrapping the command (e.g. with a newline before `--allowed-paths`) causes the shell to treat `--allowed-paths` as a separate command and fail with `command not found`. Use `\` for explicit line continuation in bash if needed. If the Inspector fails with `Proxy Server PORT IS IN USE at port 6277`, a previous Inspector process is still running. Free the ports and try again: diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8d3b5c9d..56bde3e7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -6,7 +6,7 @@ */ import { createRequire } from 'node:module'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -105,7 +105,7 @@ export function createProvarMcpServer(config: ServerConfig): McpServer { registerAllPrompts(server); // ── Documentation resources ────────────────────────────────────────────────── - const docsDir = join(dirname(fileURLToPath(import.meta.url)), 'docs'); + const docsDir = resolveDocsDir(dirname(fileURLToPath(import.meta.url))); server.resource( 'provar-nitrox-component-catalog', @@ -171,3 +171,13 @@ export function createProvarMcpServer(config: ServerConfig): McpServer { return server; } + +/** + * Resolve the docs directory for bundled MCP Markdown resources. + * In compiled output (lib/mcp/) the sibling docs/ dir exists; in dev/ts-node + * mode (src/mcp/) it doesn't, so fall back two levels to the repo-root docs/. + */ +export function resolveDocsDir(currentDir: string): string { + const sibling = join(currentDir, 'docs'); + return existsSync(sibling) ? sibling : join(currentDir, '..', '..', 'docs'); +} diff --git a/test/unit/mcp/server.test.ts b/test/unit/mcp/server.test.ts new file mode 100644 index 00000000..4692c151 --- /dev/null +++ b/test/unit/mcp/server.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 Provar Limited. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.md file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { strict as assert } from 'node:assert'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { describe, it, afterEach } from 'mocha'; +import { resolveDocsDir } from '../../../src/mcp/server.js'; + +describe('resolveDocsDir', () => { + const tmpDirs: string[] = []; + + afterEach(() => { + for (const d of tmpDirs) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } + tmpDirs.length = 0; + }); + + function makeTmpDir(): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'provar-server-test-')); + tmpDirs.push(d); + return d; + } + + it('returns sibling docs/ when it exists (compiled lib/mcp/ mode)', () => { + const base = makeTmpDir(); + const sibling = path.join(base, 'docs'); + fs.mkdirSync(sibling); + assert.equal(resolveDocsDir(base), sibling); + }); + + it('falls back two levels to repo-root docs/ when sibling is absent (dev/ts-node mode)', () => { + const base = makeTmpDir(); + const expected = path.join(base, '..', '..', 'docs'); + assert.equal(resolveDocsDir(base), expected); + }); +}); From 7c5a97eca2708fd884a2f883cbb52e8a8d01597f Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:43:38 -0500 Subject: [PATCH 10/12] PDX-0: fix(mcp): resolve sf CLI for quality hub and defect tools (B1/B2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RCA: qualityHubTools and defectTools used the simple sfSpawn runSfCommand with no maxBuffer, probing, or sf_path — causing SF_NOT_FOUND on Windows standalone installer path C:\Program Files\sf\bin\sf.cmd. Fix: Elevate sfSpawn.ts to the central sf resolution module with 50 MB maxBuffer, two-phase PATH probe, Windows standalone installer paths in getSfCommonPaths, and sf_path param on all quality hub and defect tools. Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/tools/automationTools.ts | 183 +------------------------- src/mcp/tools/defectTools.ts | 70 +++++----- src/mcp/tools/qualityHubTools.ts | 117 ++++++++++------ src/mcp/tools/sfSpawn.ts | 178 +++++++++++++++++++++++-- test/unit/mcp/automationTools.test.ts | 29 ++++ test/unit/mcp/qualityHubTools.test.ts | 78 ++++++++++- 6 files changed, 386 insertions(+), 269 deletions(-) diff --git a/src/mcp/tools/automationTools.ts b/src/mcp/tools/automationTools.ts index 3433334d..b631c760 100644 --- a/src/mcp/tools/automationTools.ts +++ b/src/mcp/tools/automationTools.ts @@ -16,187 +16,10 @@ import { log } from '../logging/logger.js'; import type { ServerConfig } from '../server.js'; import { assertPathAllowed, PathPolicyError } from '../security/pathPolicy.js'; import { parseJUnitResults } from './antTools.js'; -import { sfSpawnHelper, SfNotFoundError } from './sfSpawn.js'; +import { runSfCommand } from './sfSpawn.js'; -// ── SF CLI discovery ────────────────────────────────────────────────────────── - -/** - * Returns candidate sf CLI paths in common npm/nvm/volta install locations. - * Used as a fallback when `sf` is not in PATH. - */ -export function getSfCommonPaths(): string[] { - const home = os.homedir(); - if (process.platform === 'win32') { - const appData = process.env['APPDATA'] ?? path.join(home, 'AppData', 'Roaming'); - return [ - path.join(appData, 'npm', 'sf.cmd'), - path.join('C:', 'Program Files', 'nodejs', 'sf.cmd'), - path.join('C:', 'Program Files (x86)', 'nodejs', 'sf.cmd'), - ]; - } - const candidates = [ - '/usr/local/bin/sf', - path.join(home, '.npm-global', 'bin', 'sf'), - path.join(home, '.local', 'bin', 'sf'), - path.join(home, '.volta', 'bin', 'sf'), - ]; - // nvm — scan the three most-recently installed Node versions - const nvmBinDir = path.join(process.env['NVM_DIR'] ?? path.join(home, '.nvm'), 'versions', 'node'); - if (fs.existsSync(nvmBinDir)) { - try { - for (const v of fs.readdirSync(nvmBinDir).sort().reverse().slice(0, 3)) { - candidates.push(path.join(nvmBinDir, v, 'bin', 'sf')); - } - } catch { - /* skip */ - } - } - return candidates; -} - -// ── Shared spawn helper ─────────────────────────────────────────────────────── - -const MAX_BUFFER = 50 * 1024 * 1024; // 50 MB — prevents ENOBUFS on verbose Provar runs - -interface SpawnResult { - stdout: string; - stderr: string; - exitCode: number; -} - -// Proactively resolve the sf executable path once on first use and cache it. -// This ensures sf is always found even when ENOENT is masked by other errors (e.g. ENOBUFS). -let cachedSfPath: string | null | undefined; // undefined = not yet probed - -/** - * Exposed for testing only — pre-seeds the cached sf executable path, bypassing the probe spawn. - * Pass `undefined` to reset the cache so the next call triggers a fresh probe. - */ -export function setSfPathCacheForTesting(value: string | null | undefined): void { - cachedSfPath = value; -} - -// Platform override used in tests so Windows-specific shell logic can be exercised on any OS. -let sfPlatformOverride: NodeJS.Platform | undefined; -/** Exposed for testing only — overrides process.platform for needsWindowsShell decisions. */ -export function setSfPlatformForTesting(platform: NodeJS.Platform | undefined): void { - sfPlatformOverride = platform; -} - -/** - * Returns true when spawning `executable` requires the Windows shell. - * On Windows, `.cmd` and `.bat` batch scripts cannot be executed directly by - * Node's spawnSync — they must be invoked through cmd.exe (i.e. shell: true). - * The bare name "sf" also needs this treatment on Windows because the file on - * disk is actually "sf.cmd" and Node won't auto-append the extension. - * - * The `platform` parameter defaults to `process.platform` and is exposed for - * unit testing so tests can verify both Windows and non-Windows behaviour - * without having to run on the corresponding OS. - */ -export function needsWindowsShell(executable: string, platform = process.platform): boolean { - if (platform !== 'win32') return false; - const lower = executable.toLowerCase(); - return lower.endsWith('.cmd') || lower.endsWith('.bat') || !path.extname(lower); -} - -function resolveSfExecutable(): string | null { - if (cachedSfPath !== undefined) return cachedSfPath; - const platform = sfPlatformOverride ?? process.platform; - - // Two-phase probe avoids false-positives on Windows with shell:true. - // When shell:true is used, cmd.exe spawns successfully even when `sf` is - // missing — it exits non-zero with "not recognised" in stderr but sets no - // probe.error. Trying shell:false first catches both cases correctly. - // - // First attempt: shell:false (works on Linux/macOS; gives ENOENT on Windows if - // sf.cmd is on PATH but requires the shell). - const probe = sfSpawnHelper.spawnSync('sf', ['--version'], { - encoding: 'utf-8', - shell: false, - maxBuffer: 1024 * 1024, - }); - if (!probe.error && probe.status === 0) { - cachedSfPath = 'sf'; - return cachedSfPath; - } - - // Windows fallback: retry with shell:true when the plain probe failed - // with ENOENT — meaning sf.cmd exists on PATH but can't run without the shell. - if (platform === 'win32' && (probe.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { - const probeShell = sfSpawnHelper.spawnSync('sf', ['--version'], { - encoding: 'utf-8', - shell: true, - maxBuffer: 1024 * 1024, - }); - if (!probeShell.error && probeShell.status === 0) { - cachedSfPath = 'sf'; - return cachedSfPath; - } - } - - // Fall back to common install locations - for (const candidate of getSfCommonPaths()) { - if (fs.existsSync(candidate)) { - cachedSfPath = candidate; - return cachedSfPath; - } - } - cachedSfPath = null; - return null; -} - -/** - * Reject shell metacharacters in an sf_path that will be executed via shell:true. - * On Windows, cmd.exe interprets & | ; < > ` ' " and newlines as shell syntax. - * A valid filesystem path should never contain these characters. - */ -function assertShellSafePath(sfPath: string): void { - if (/[&|;<>`'"\n\r]/.test(sfPath)) { - throw Object.assign( - new Error( - 'sf_path contains characters that are unsafe for shell execution on Windows ' + - '(& | ; < > ` \' " or line-breaks). Provide an absolute filesystem path to the sf executable.' - ), - { code: 'INVALID_SF_PATH' } - ); - } -} - -function runSfCommand(args: string[], sfPath?: string): SpawnResult { - // Use explicit path if provided; otherwise use cached probe result - const executable = sfPath ?? resolveSfExecutable(); - if (!executable) throw new SfNotFoundError(); - - const platform = sfPlatformOverride ?? process.platform; - const useShell = needsWindowsShell(executable, platform); - - // Guard against injection when shell:true is used with a user-supplied path. - // Common install locations returned by resolveSfExecutable() are safe by construction. - if (useShell && sfPath) { - assertShellSafePath(sfPath); - } - - const result = sfSpawnHelper.spawnSync(executable, args, { - encoding: 'utf-8', - shell: useShell, - maxBuffer: MAX_BUFFER, - }); - - if (result.error) { - const err = result.error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { - throw new SfNotFoundError(sfPath); - } - throw result.error; - } - - return { - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - exitCode: result.status ?? 1, - }; -} +// Re-export sf resolution helpers so existing test imports from automationTools continue to work +export { getSfCommonPaths, needsWindowsShell, setSfPathCacheForTesting, setSfPlatformForTesting } from './sfSpawn.js'; function handleSpawnError( err: unknown, diff --git a/src/mcp/tools/defectTools.ts b/src/mcp/tools/defectTools.ts index c408f01f..81ca6a70 100644 --- a/src/mcp/tools/defectTools.ts +++ b/src/mcp/tools/defectTools.ts @@ -45,8 +45,8 @@ interface FailureContext { // ── SF CLI helpers ───────────────────────────────────────────────────────────── -function runSfArgs(args: string[]): { stdout: string; stderr: string; exitCode: number } { - const { stdout, stderr, exitCode } = runSfCommand(args); +function runSfArgs(args: string[], sfPath?: string): { stdout: string; stderr: string; exitCode: number } { + const { stdout, stderr, exitCode } = runSfCommand(args, sfPath); return { stdout, stderr, exitCode }; } @@ -57,35 +57,22 @@ function formatSfCommandError(action: string, exitCode: number, stderr: string, : `${action} failed with exit code ${exitCode}`; } -function runQuery(soql: string, targetOrg: string): SfQueryResponse { - const { stdout, stderr, exitCode } = runSfArgs([ - 'data', - 'query', - '--query', - soql, - '--target-org', - targetOrg, - '--json', - ]); +function runQuery(soql: string, targetOrg: string, sfPath?: string): SfQueryResponse { + const { stdout, stderr, exitCode } = runSfArgs( + ['data', 'query', '--query', soql, '--target-org', targetOrg, '--json'], + sfPath + ); if (exitCode !== 0) { throw new Error(formatSfCommandError('Salesforce query', exitCode, stderr, stdout)); } return JSON.parse(stdout) as SfQueryResponse; } -function createRecord(sobject: string, values: string, targetOrg: string): string { - const { stdout, stderr, exitCode } = runSfArgs([ - 'data', - 'create', - 'record', - '--sobject', - sobject, - '--values', - values, - '--target-org', - targetOrg, - '--json', - ]); +function createRecord(sobject: string, values: string, targetOrg: string, sfPath?: string): string { + const { stdout, stderr, exitCode } = runSfArgs( + ['data', 'create', 'record', '--sobject', sobject, '--values', values, '--target-org', targetOrg, '--json'], + sfPath + ); if (exitCode !== 0) { throw new Error(formatSfCommandError(`Failed to create ${sobject}`, exitCode, stderr, stdout)); } @@ -115,12 +102,14 @@ export interface DefectCreateResult { export function createDefectsForRun( runId: string, targetOrg: string, - failedTestFilter?: string[] + failedTestFilter?: string[], + sfPath?: string ): { created: DefectCreateResult[]; skipped: number; message: string } { // Step 1: resolve job record ID from tracking ID const jobQuery = runQuery( `SELECT Id FROM provar__Test_Plan_Schedule_Job__c WHERE provar__Tracking_Id__c = '${soqlEscape(runId)}'`, - targetOrg + targetOrg, + sfPath ); if (jobQuery.result.totalSize === 0) { throw new Error(`No Test_Plan_Schedule_Job__c found with Tracking_Id__c = '${runId}'`); @@ -133,7 +122,8 @@ export function createDefectsForRun( FROM provar__Test_Cycle__c WHERE provar__Test_Plan_Schedule_Job__c = '${soqlEscape(jobId)}' LIMIT 1`, - targetOrg + targetOrg, + sfPath ); const cycle = cycleQuery.result.records[0] ?? {}; const browser = safeText(cycle['provar__Web_Browser__c'], 100); @@ -151,7 +141,8 @@ export function createDefectsForRun( FROM provar__Test_Execution__c WHERE provar__Test_Cycle__c = '${soqlEscape(cycleId)}' AND provar__Status__c = 'Failed'`, - targetOrg + targetOrg, + sfPath ); if (execQuery.result.totalSize === 0) { @@ -185,7 +176,8 @@ export function createDefectsForRun( AND provar__Result__c = 'Fail' ORDER BY provar__Sequence_No__c ASC LIMIT 1`, - targetOrg + targetOrg, + sfPath ); const step = stepQuery.result.records[0] ?? {}; @@ -227,7 +219,7 @@ export function createDefectsForRun( `provar__Description__c="${safeText(descLines, 2000)}" ` + 'provar__Status__c="Open"'; - const defectId = createRecord('provar__Defect__c', defectValues, targetOrg); + const defectId = createRecord('provar__Defect__c', defectValues, targetOrg, sfPath); // Step 5b: link TC → Defect const tcDefectValues = @@ -235,7 +227,7 @@ export function createDefectsForRun( `provar__Test_Case__c="${testCaseId}"` + (stepId ? ` provar__Test_Step__c="${stepId}"` : ''); - const tcDefectId = createRecord('provar__Test_Case_Defect__c', tcDefectValues, targetOrg); + const tcDefectId = createRecord('provar__Test_Case_Defect__c', tcDefectValues, targetOrg, sfPath); // Step 5c: link Execution → Defect (with step execution if available) const execDefectValues = @@ -243,7 +235,7 @@ export function createDefectsForRun( `provar__Test_Execution__c="${executionId}"` + (stepExecutionId ? ` provar__Test_Step_Execution__c="${stepExecutionId}"` : ''); - const execDefectId = createRecord('provar__Test_Execution_Defect__c', execDefectValues, targetOrg); + const execDefectId = createRecord('provar__Test_Execution_Defect__c', execDefectValues, targetOrg, sfPath); log('info', 'defect created', { defectId, tcDefectId, execDefectId, executionId }); @@ -281,14 +273,22 @@ export function registerQualityHubDefectCreate(server: McpServer): void { .describe( 'Optional filter — list of Test_Case__c record ID substrings to restrict defect creation to specific failures' ), + sf_path: z + .string() + .optional() + .describe( + 'Path to the sf CLI executable when not in PATH ' + + '(e.g. "C:\\\\Program Files\\\\sf\\\\bin\\\\sf.cmd" for the Windows standalone installer). ' + + 'Leave unset to use auto-discovery.' + ), }, }, - ({ run_id, target_org, failed_tests }) => { + ({ run_id, target_org, failed_tests, sf_path }) => { const requestId = makeRequestId(); log('info', 'provar_qualityhub_defect_create', { requestId, run_id, target_org }); try { - const result = createDefectsForRun(run_id, target_org, failed_tests); + const result = createDefectsForRun(run_id, target_org, failed_tests, sf_path); const response = { requestId, ...result }; return { content: [{ type: 'text' as const, text: JSON.stringify(response) }], diff --git a/src/mcp/tools/qualityHubTools.ts b/src/mcp/tools/qualityHubTools.ts index 74d037da..aa464af6 100644 --- a/src/mcp/tools/qualityHubTools.ts +++ b/src/mcp/tools/qualityHubTools.ts @@ -46,14 +46,25 @@ export function registerQualityHubConnect(server: McpServer): void { .optional() .default([]) .describe('Additional raw CLI flags to forward (e.g. ["--json"])'), + sf_path: z + .string() + .optional() + .describe( + 'Path to the sf CLI executable when not in PATH ' + + '(e.g. "C:\\\\Program Files\\\\sf\\\\bin\\\\sf.cmd" for the Windows standalone installer). ' + + 'Leave unset to use auto-discovery.' + ), }, }, - ({ target_org, flags }) => { + ({ target_org, flags, sf_path }) => { const requestId = makeRequestId(); log('info', 'provar_qualityhub_connect', { requestId, target_org }); try { - const result = runSfCommand(['provar', 'quality-hub', 'connect', '--target-org', target_org, ...flags]); + const result = runSfCommand( + ['provar', 'quality-hub', 'connect', '--target-org', target_org, ...flags], + sf_path + ); const response = { requestId, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; if (result.exitCode !== 0) { @@ -87,9 +98,17 @@ export function registerQualityHubDisplay(server: McpServer): void { inputSchema: { target_org: z.string().optional().describe('SF org alias or username (uses default if omitted)'), flags: z.array(z.string()).optional().default([]).describe('Additional raw CLI flags to forward'), + sf_path: z + .string() + .optional() + .describe( + 'Path to the sf CLI executable when not in PATH ' + + '(e.g. "C:\\\\Program Files\\\\sf\\\\bin\\\\sf.cmd" for the Windows standalone installer). ' + + 'Leave unset to use auto-discovery.' + ), }, }, - ({ target_org, flags }) => { + ({ target_org, flags, sf_path }) => { const requestId = makeRequestId(); log('info', 'provar_qualityhub_display', { requestId, target_org }); @@ -97,7 +116,7 @@ export function registerQualityHubDisplay(server: McpServer): void { const args = ['provar', 'quality-hub', 'display', ...flags]; if (target_org) args.splice(3, 0, '--target-org', target_org); - const result = runSfCommand(args); + const result = runSfCommand(args, sf_path); const response = { requestId, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; if (result.exitCode !== 0) { @@ -155,15 +174,26 @@ export function registerQualityHubTestRun(server: McpServer): void { .describe( 'Additional raw CLI flags (e.g. ["--plan-name", "SmokeTests"]). Avoid wildcards in --plan-name values — they skip QH plan-level reporting.' ), + sf_path: z + .string() + .optional() + .describe( + 'Path to the sf CLI executable when not in PATH ' + + '(e.g. "C:\\\\Program Files\\\\sf\\\\bin\\\\sf.cmd" for the Windows standalone installer). ' + + 'Leave unset to use auto-discovery.' + ), }, }, - ({ target_org, flags }) => { + ({ target_org, flags, sf_path }) => { const requestId = makeRequestId(); log('info', 'provar_qualityhub_testrun', { requestId, target_org }); try { const wildcardWarning = detectWildcardFlags(flags); - const result = runSfCommand(['provar', 'quality-hub', 'test', 'run', '--target-org', target_org, ...flags]); + const result = runSfCommand( + ['provar', 'quality-hub', 'test', 'run', '--target-org', target_org, ...flags], + sf_path + ); const response: Record = { requestId, exitCode: result.exitCode, @@ -207,25 +237,25 @@ export function registerQualityHubTestRunReport(server: McpServer): void { target_org: z.string().describe('SF org alias or username'), run_id: z.string().describe('Test run ID returned by provar_qualityhub_testrun'), flags: z.array(z.string()).optional().default([]).describe('Additional raw CLI flags'), + sf_path: z + .string() + .optional() + .describe( + 'Path to the sf CLI executable when not in PATH ' + + '(e.g. "C:\\\\Program Files\\\\sf\\\\bin\\\\sf.cmd" for the Windows standalone installer). ' + + 'Leave unset to use auto-discovery.' + ), }, }, - ({ target_org, run_id, flags }) => { + ({ target_org, run_id, flags, sf_path }) => { const requestId = makeRequestId(); log('info', 'provar_qualityhub_testrun_report', { requestId, target_org, run_id }); try { - const result = runSfCommand([ - 'provar', - 'quality-hub', - 'test', - 'run', - 'report', - '--target-org', - target_org, - '--run-id', - run_id, - ...flags, - ]); + const result = runSfCommand( + ['provar', 'quality-hub', 'test', 'run', 'report', '--target-org', target_org, '--run-id', run_id, ...flags], + sf_path + ); const response = { requestId, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; if (result.exitCode !== 0) { @@ -279,25 +309,25 @@ export function registerQualityHubTestRunAbort(server: McpServer): void { target_org: z.string().describe('SF org alias or username'), run_id: z.string().describe('Test run ID to abort'), flags: z.array(z.string()).optional().default([]).describe('Additional raw CLI flags'), + sf_path: z + .string() + .optional() + .describe( + 'Path to the sf CLI executable when not in PATH ' + + '(e.g. "C:\\\\Program Files\\\\sf\\\\bin\\\\sf.cmd" for the Windows standalone installer). ' + + 'Leave unset to use auto-discovery.' + ), }, }, - ({ target_org, run_id, flags }) => { + ({ target_org, run_id, flags, sf_path }) => { const requestId = makeRequestId(); log('info', 'provar_qualityhub_testrun_abort', { requestId, target_org, run_id }); try { - const result = runSfCommand([ - 'provar', - 'quality-hub', - 'test', - 'run', - 'abort', - '--target-org', - target_org, - '--run-id', - run_id, - ...flags, - ]); + const result = runSfCommand( + ['provar', 'quality-hub', 'test', 'run', 'abort', '--target-org', target_org, '--run-id', run_id, ...flags], + sf_path + ); const response = { requestId, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; if (result.exitCode !== 0) { @@ -336,22 +366,25 @@ export function registerQualityHubTestcaseRetrieve(server: McpServer): void { .optional() .default([]) .describe('Additional raw CLI flags (e.g. ["--user-story", "US-123"])'), + sf_path: z + .string() + .optional() + .describe( + 'Path to the sf CLI executable when not in PATH ' + + '(e.g. "C:\\\\Program Files\\\\sf\\\\bin\\\\sf.cmd" for the Windows standalone installer). ' + + 'Leave unset to use auto-discovery.' + ), }, }, - ({ target_org, flags }) => { + ({ target_org, flags, sf_path }) => { const requestId = makeRequestId(); log('info', 'provar_qualityhub_testcase_retrieve', { requestId, target_org }); try { - const result = runSfCommand([ - 'provar', - 'quality-hub', - 'testcase', - 'retrieve', - '--target-org', - target_org, - ...flags, - ]); + const result = runSfCommand( + ['provar', 'quality-hub', 'testcase', 'retrieve', '--target-org', target_org, ...flags], + sf_path + ); const response = { requestId, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; if (result.exitCode !== 0) { diff --git a/src/mcp/tools/sfSpawn.ts b/src/mcp/tools/sfSpawn.ts index 5b7fd3c3..89078cc8 100644 --- a/src/mcp/tools/sfSpawn.ts +++ b/src/mcp/tools/sfSpawn.ts @@ -5,6 +5,9 @@ * For full license text, see LICENSE.md file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { spawnSync as _spawnSync } from 'node:child_process'; /** @@ -20,20 +23,18 @@ export const sfSpawnHelper = { export class SfNotFoundError extends Error { public readonly code = 'SF_NOT_FOUND'; public constructor(sfPath?: string) { - const where = sfPath - ? `at explicit path "${sfPath}"` - : 'in PATH or common npm/nvm/volta install locations'; + const where = sfPath ? `at explicit path "${sfPath}"` : 'in PATH or common npm/nvm/volta install locations'; super( `sf CLI not found ${where}. ` + - 'Install Salesforce CLI (npm install -g @salesforce/cli) and ensure the install directory is in your PATH, ' + - 'or pass sf_path pointing to the sf executable directly ' + - '(e.g. "~/.nvm/versions/node/v22.0.0/bin/sf").' + 'Install Salesforce CLI (npm install -g @salesforce/cli) and ensure the install directory is in your PATH, ' + + 'or pass sf_path pointing to the sf executable directly ' + + '(e.g. "~/.nvm/versions/node/v22.0.0/bin/sf").' ); this.name = 'SfNotFoundError'; } } -// ── Shared spawn helper ─────────────────────────────────────────────────────── +// ── Shared result type ──────────────────────────────────────────────────────── export interface SpawnResult { stdout: string; @@ -41,17 +42,172 @@ export interface SpawnResult { exitCode: number; } +const MAX_BUFFER = 50 * 1024 * 1024; // 50 MB — prevents ENOBUFS on verbose Provar runs + +// ── SF CLI discovery ────────────────────────────────────────────────────────── + +/** + * Returns candidate sf CLI paths in common install locations. + * Used as a fallback when `sf` is not in PATH. + */ +export function getSfCommonPaths(): string[] { + const home = os.homedir(); + if (process.platform === 'win32') { + const appData = process.env['APPDATA'] ?? path.join(home, 'AppData', 'Roaming'); + return [ + path.join(appData, 'npm', 'sf.cmd'), + path.join('C:', 'Program Files', 'nodejs', 'sf.cmd'), + path.join('C:', 'Program Files (x86)', 'nodejs', 'sf.cmd'), + // Windows standalone installer (https://developer.salesforce.com/tools/salesforcecli) + path.join('C:', 'Program Files', 'sf', 'bin', 'sf.cmd'), + path.join('C:', 'Program Files', 'sf', 'client', 'bin', 'sf.cmd'), + ]; + } + const candidates = [ + '/usr/local/bin/sf', + path.join(home, '.npm-global', 'bin', 'sf'), + path.join(home, '.local', 'bin', 'sf'), + path.join(home, '.volta', 'bin', 'sf'), + ]; + // nvm — scan the three most-recently installed Node versions + const nvmBinDir = path.join(process.env['NVM_DIR'] ?? path.join(home, '.nvm'), 'versions', 'node'); + if (fs.existsSync(nvmBinDir)) { + try { + for (const v of fs.readdirSync(nvmBinDir).sort().reverse().slice(0, 3)) { + candidates.push(path.join(nvmBinDir, v, 'bin', 'sf')); + } + } catch { + /* skip */ + } + } + return candidates; +} + +// Proactively resolve the sf executable path once on first use and cache it. +// This ensures sf is always found even when ENOENT is masked by other errors (e.g. ENOBUFS). +let cachedSfPath: string | null | undefined; // undefined = not yet probed + +/** + * Exposed for testing only — pre-seeds the cached sf executable path, bypassing the probe spawn. + * Pass `undefined` to reset the cache so the next call triggers a fresh probe. + */ +export function setSfPathCacheForTesting(value: string | null | undefined): void { + cachedSfPath = value; +} + +// Platform override used in tests so Windows-specific shell logic can be exercised on any OS. +let sfPlatformOverride: NodeJS.Platform | undefined; + +/** Exposed for testing only — overrides process.platform for needsWindowsShell decisions. */ +export function setSfPlatformForTesting(platform: NodeJS.Platform | undefined): void { + sfPlatformOverride = platform; +} + +/** + * Returns true when spawning `executable` requires the Windows shell. + * On Windows, `.cmd` and `.bat` batch scripts cannot be executed directly by + * Node's spawnSync — they must be invoked through cmd.exe (i.e. shell: true). + * The bare name "sf" also needs this treatment on Windows because the file on + * disk is actually "sf.cmd" and Node won't auto-append the extension. + */ +export function needsWindowsShell(executable: string, platform = process.platform): boolean { + if (platform !== 'win32') return false; + const lower = executable.toLowerCase(); + return lower.endsWith('.cmd') || lower.endsWith('.bat') || !path.extname(lower); +} + +function resolveSfExecutable(): string | null { + if (cachedSfPath !== undefined) return cachedSfPath; + const platform = sfPlatformOverride ?? process.platform; + + // Two-phase probe avoids false-positives on Windows with shell:true. + // When shell:true is used, cmd.exe spawns successfully even when `sf` is + // missing — it exits non-zero with "not recognised" in stderr but sets no + // probe.error. Trying shell:false first catches both cases correctly. + // + // First attempt: shell:false (works on Linux/macOS; gives ENOENT on Windows if + // sf.cmd is on PATH but requires the shell). + const probe = sfSpawnHelper.spawnSync('sf', ['--version'], { + encoding: 'utf-8', + shell: false, + maxBuffer: 1024 * 1024, + }); + if (!probe.error && probe.status === 0) { + cachedSfPath = 'sf'; + return cachedSfPath; + } + + // Windows fallback: retry with shell:true when the plain probe failed + // with ENOENT — meaning sf.cmd exists on PATH but can't run without the shell. + if (platform === 'win32' && (probe.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { + const probeShell = sfSpawnHelper.spawnSync('sf', ['--version'], { + encoding: 'utf-8', + shell: true, + maxBuffer: 1024 * 1024, + }); + if (!probeShell.error && probeShell.status === 0) { + cachedSfPath = 'sf'; + return cachedSfPath; + } + } + + // Fall back to common install locations + for (const candidate of getSfCommonPaths()) { + if (fs.existsSync(candidate)) { + cachedSfPath = candidate; + return cachedSfPath; + } + } + cachedSfPath = null; + return null; +} + +/** + * Reject shell metacharacters in an sf_path that will be executed via shell:true. + * On Windows, cmd.exe interprets & | ; < > ` ' " and newlines as shell syntax. + * A valid filesystem path should never contain these characters. + */ +function assertShellSafePath(sfPath: string): void { + if (/[&|;<>`'"\n\r]/.test(sfPath)) { + throw Object.assign( + new Error( + 'sf_path contains characters that are unsafe for shell execution on Windows ' + + '(& | ; < > ` \' " or line-breaks). Provide an absolute filesystem path to the sf executable.' + ), + { code: 'INVALID_SF_PATH' } + ); + } +} + /** * Run `sf ` synchronously and return stdout, stderr, and exit code. - * Throws SfNotFoundError if the `sf` binary is not in PATH. + * Throws SfNotFoundError if the `sf` binary cannot be found. + * Pass `sfPath` to override auto-discovery with an explicit executable path. */ -export function runSfCommand(args: string[]): SpawnResult { - const result = sfSpawnHelper.spawnSync('sf', args, { encoding: 'utf-8', shell: false }); +export function runSfCommand(args: string[], sfPath?: string): SpawnResult { + // Use explicit path if provided; otherwise use cached probe result + const executable = sfPath ?? resolveSfExecutable(); + if (!executable) throw new SfNotFoundError(); + + const platform = sfPlatformOverride ?? process.platform; + const useShell = needsWindowsShell(executable, platform); + + // Guard against injection when shell:true is used with a user-supplied path. + // Common install locations returned by resolveSfExecutable() are safe by construction. + if (useShell && sfPath) { + assertShellSafePath(sfPath); + } + + const result = sfSpawnHelper.spawnSync(executable, args, { + encoding: 'utf-8', + shell: useShell, + maxBuffer: MAX_BUFFER, + }); if (result.error) { const err = result.error as NodeJS.ErrnoException; if (err.code === 'ENOENT') { - throw new SfNotFoundError(); + throw new SfNotFoundError(sfPath); } throw result.error; } diff --git a/test/unit/mcp/automationTools.test.ts b/test/unit/mcp/automationTools.test.ts index 381ec475..2a9c3c30 100644 --- a/test/unit/mcp/automationTools.test.ts +++ b/test/unit/mcp/automationTools.test.ts @@ -16,6 +16,7 @@ import { sfSpawnHelper } from '../../../src/mcp/tools/sfSpawn.js'; import { needsWindowsShell, setSfPlatformForTesting, + getSfCommonPaths, filterTestRunOutput, setSfResultsPathForTesting, } from '../../../src/mcp/tools/automationTools.js'; @@ -928,3 +929,31 @@ describe('filterTestRunOutput', () => { assert.ok(filtered.includes('INFO Done'), 'Real output should remain'); }); }); + +// ── getSfCommonPaths — B2a Windows standalone installer paths ───────────────── + +describe('getSfCommonPaths', () => { + it('includes Windows standalone installer paths on win32 (B2a fix)', () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + const paths = getSfCommonPaths(); + const sfBinPath = paths.find( + (p) => p.includes('Program Files') && p.includes('sf') && p.includes('bin') && !p.includes('client') + ); + const sfClientPath = paths.find((p) => p.includes('Program Files') && p.includes('sf') && p.includes('client')); + assert.ok(sfBinPath, 'Expected C:\\Program Files\\sf\\bin\\sf.cmd in win32 paths'); + assert.ok(sfClientPath, 'Expected C:\\Program Files\\sf\\client\\bin\\sf.cmd in win32 paths'); + } finally { + if (origPlatform) Object.defineProperty(process, 'platform', origPlatform); + } + }); + + it('does not include Windows standalone paths on non-Windows platforms', () => { + if (process.platform !== 'win32') { + const paths = getSfCommonPaths(); + assert.ok(!paths.some((p) => p.includes('Program Files')), 'Windows paths should not appear on non-Windows'); + assert.ok(paths.includes('/usr/local/bin/sf'), 'Linux/macOS fallback path should be present'); + } + }); +}); diff --git a/test/unit/mcp/qualityHubTools.test.ts b/test/unit/mcp/qualityHubTools.test.ts index c7e8dc2a..e35dcf2a 100644 --- a/test/unit/mcp/qualityHubTools.test.ts +++ b/test/unit/mcp/qualityHubTools.test.ts @@ -9,7 +9,7 @@ import { strict as assert } from 'node:assert'; import sinon from 'sinon'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { sfSpawnHelper } from '../../../src/mcp/tools/sfSpawn.js'; +import { sfSpawnHelper, getSfCommonPaths, setSfPathCacheForTesting } from '../../../src/mcp/tools/sfSpawn.js'; // ── Minimal mock server ─────────────────────────────────────────────────────── @@ -74,6 +74,8 @@ describe('qualityHubTools', () => { beforeEach(async () => { server = new MockMcpServer(); spawnStub = sinon.stub(sfSpawnHelper, 'spawnSync'); + // Pre-seed the sf path cache to bypass the probe spawn — tests control spawnStub directly + setSfPathCacheForTesting('sf'); const { registerAllQualityHubTools } = await import('../../../src/mcp/tools/qualityHubTools.js'); registerAllQualityHubTools(server as unknown as McpServer); @@ -81,6 +83,7 @@ describe('qualityHubTools', () => { afterEach(() => { sinon.restore(); + setSfPathCacheForTesting(undefined); // reset cache so next test probes cleanly }); // ── provar_qualityhub_connect ─────────────────────────────────────────────── @@ -391,4 +394,77 @@ describe('qualityHubTools', () => { assert.equal(parseBody(result).error_code, 'SF_NOT_FOUND'); }); }); + + // ── sf_path threading ───────────────────────────────────────────────────────── + + describe('sf_path threading', () => { + it('provar_qualityhub_connect uses explicit sf_path as the executable', () => { + spawnStub.returns(makeSpawnResult('ok', '', 0)); + server.call('provar_qualityhub_connect', { + target_org: 'myorg', + flags: [], + sf_path: '/custom/bin/sf', + }); + const [cmd] = spawnStub.firstCall.args as [string, string[]]; + assert.equal(cmd, '/custom/bin/sf'); + }); + + it('provar_qualityhub_display uses explicit sf_path as the executable', () => { + spawnStub.returns(makeSpawnResult('ok', '', 0)); + server.call('provar_qualityhub_display', { flags: [], sf_path: '/custom/bin/sf' }); + const [cmd] = spawnStub.firstCall.args as [string, string[]]; + assert.equal(cmd, '/custom/bin/sf'); + }); + + it('returns SF_NOT_FOUND with path hint when explicit sf_path gives ENOENT', () => { + spawnStub.returns(makeEnoentResult()); + const result = server.call('provar_qualityhub_connect', { + target_org: 'myorg', + flags: [], + sf_path: '/nonexistent/sf', + }); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body.error_code, 'SF_NOT_FOUND'); + assert.ok((body.message as string).includes('/nonexistent/sf'), 'message should include the bad path'); + }); + + it('returns SF_NOT_FOUND when no sf found and cache is null', () => { + setSfPathCacheForTesting(null); // simulate: probe already ran, nothing found + const result = server.call('provar_qualityhub_connect', { target_org: 'myorg', flags: [] }); + assert.ok(isError(result)); + assert.equal(parseBody(result).error_code, 'SF_NOT_FOUND'); + }); + }); + + // ── getSfCommonPaths — B2a Windows standalone installer paths ───────────────── + + describe('getSfCommonPaths', () => { + it('includes Windows standalone installer paths on win32', () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + const paths = getSfCommonPaths(); + assert.ok( + paths.some((p) => p.includes('Program Files') && p.includes('sf') && p.includes('bin')), + 'Expected C:\\Program Files\\sf\\bin\\sf.cmd in common paths' + ); + assert.ok( + paths.some((p) => p.includes('Program Files') && p.includes('sf') && p.includes('client')), + 'Expected C:\\Program Files\\sf\\client\\bin\\sf.cmd in common paths' + ); + } finally { + if (origPlatform) Object.defineProperty(process, 'platform', origPlatform); + } + }); + + it('returns non-empty list on non-Windows platforms', () => { + // On the current test platform (Linux/macOS), paths should include /usr/local/bin/sf + if (process.platform !== 'win32') { + const paths = getSfCommonPaths(); + assert.ok(paths.length > 0); + assert.ok(paths.includes('/usr/local/bin/sf')); + } + }); + }); }); From 71e42e39508b4268fb769c252a02d2daa40da76a Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:51:45 -0500 Subject: [PATCH 11/12] PDX-0: test(mcp): add direct unit tests for sfSpawn.ts RCA: sfSpawn.ts was elevated to the central sf resolution module but had no dedicated test file, leaving 31 code paths (SfNotFoundError, soqlEscape, getSfCommonPaths, needsWindowsShell, runSfCommand probe logic, injection guard) uncovered by direct assertions. Fix: Add test/unit/mcp/sfSpawn.test.ts covering all exported functions including two-phase probe, Windows shell injection guard, B2a standalone installer paths, and ENOENT handling. Co-Authored-By: Claude Sonnet 4.6 --- test/unit/mcp/sfSpawn.test.ts | 342 ++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 test/unit/mcp/sfSpawn.test.ts diff --git a/test/unit/mcp/sfSpawn.test.ts b/test/unit/mcp/sfSpawn.test.ts new file mode 100644 index 00000000..9ae3a4b5 --- /dev/null +++ b/test/unit/mcp/sfSpawn.test.ts @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2024 Provar Limited. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.md file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { strict as assert } from 'node:assert'; +import os from 'node:os'; +import path from 'node:path'; +import sinon from 'sinon'; +import { + sfSpawnHelper, + SfNotFoundError, + getSfCommonPaths, + needsWindowsShell, + runSfCommand, + setSfPathCacheForTesting, + setSfPlatformForTesting, + soqlEscape, +} from '../../../src/mcp/tools/sfSpawn.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeSpawnOk( + stdout = '', + stderr = '' +): { stdout: string; stderr: string; status: number; error: undefined; pid: number; output: never[]; signal: null } { + return { stdout, stderr, status: 0, error: undefined, pid: 1, output: [], signal: null }; +} + +function makeSpawnFail( + exitCode = 1, + stdout = '', + stderr = '' +): { stdout: string; stderr: string; status: number; error: undefined; pid: number; output: never[]; signal: null } { + return { stdout, stderr, status: exitCode, error: undefined, pid: 1, output: [], signal: null }; +} + +function makeEnoent(): { + stdout: string; + stderr: string; + status: null; + error: Error & { code: string }; + pid: undefined; + output: never[]; + signal: null; +} { + return { + stdout: '', + stderr: '', + status: null, + error: Object.assign(new Error('spawn sf ENOENT'), { code: 'ENOENT' }), + pid: undefined, + output: [], + signal: null, + }; +} + +// ── SfNotFoundError ─────────────────────────────────────────────────────────── + +describe('SfNotFoundError', () => { + it('has code SF_NOT_FOUND', () => { + const err = new SfNotFoundError(); + assert.equal(err.code, 'SF_NOT_FOUND'); + assert.equal(err.name, 'SfNotFoundError'); + }); + + it('generic message mentions PATH and npm install hint', () => { + const err = new SfNotFoundError(); + assert.ok(err.message.includes('npm install -g @salesforce/cli')); + assert.ok(err.message.includes('PATH')); + }); + + it('path-specific message names the explicit path', () => { + const err = new SfNotFoundError('/custom/sf'); + assert.ok(err.message.includes('/custom/sf')); + assert.ok(err.message.includes('at explicit path')); + }); +}); + +// ── soqlEscape ──────────────────────────────────────────────────────────────── + +describe('soqlEscape', () => { + it('leaves strings without quotes unchanged', () => { + assert.equal(soqlEscape('hello world'), 'hello world'); + }); + + it('escapes single quotes', () => { + assert.equal(soqlEscape("O'Brien"), "O\\'Brien"); + }); + + it('escapes multiple quotes', () => { + assert.equal(soqlEscape("it's a 'test'"), "it\\'s a \\'test\\'"); + }); + + it('handles empty string', () => { + assert.equal(soqlEscape(''), ''); + }); +}); + +// ── getSfCommonPaths ────────────────────────────────────────────────────────── + +describe('getSfCommonPaths', () => { + it('returns an array of strings', () => { + const paths = getSfCommonPaths(); + assert.ok(Array.isArray(paths)); + assert.ok(paths.length > 0); + for (const p of paths) assert.equal(typeof p, 'string'); + }); + + it('includes Windows standalone installer paths on win32', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + try { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const paths = getSfCommonPaths(); + assert.ok( + paths.some((p) => p.includes(path.join('Program Files', 'sf', 'bin'))), + 'should include C:\\Program Files\\sf\\bin\\sf.cmd' + ); + assert.ok( + paths.some((p) => p.includes(path.join('Program Files', 'sf', 'client', 'bin'))), + 'should include C:\\Program Files\\sf\\client\\bin\\sf.cmd' + ); + } finally { + if (originalDescriptor) Object.defineProperty(process, 'platform', originalDescriptor); + } + }); + + it('all Windows paths end with .cmd', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + try { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const paths = getSfCommonPaths(); + for (const p of paths) assert.ok(p.endsWith('.cmd'), `expected .cmd: ${p}`); + } finally { + if (originalDescriptor) Object.defineProperty(process, 'platform', originalDescriptor); + } + }); + + it('includes home-relative paths on non-Windows', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + try { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const paths = getSfCommonPaths(); + assert.ok(paths.includes('/usr/local/bin/sf')); + assert.ok(paths.some((p) => p.includes(os.homedir()))); + } finally { + if (originalDescriptor) Object.defineProperty(process, 'platform', originalDescriptor); + } + }); +}); + +// ── needsWindowsShell ───────────────────────────────────────────────────────── + +describe('needsWindowsShell', () => { + it('returns false on non-Windows regardless of executable', () => { + assert.equal(needsWindowsShell('sf', 'linux'), false); + assert.equal(needsWindowsShell('sf.cmd', 'darwin'), false); + assert.equal(needsWindowsShell('/usr/bin/sf', 'linux'), false); + }); + + it('returns true for .cmd on win32', () => { + assert.equal(needsWindowsShell('sf.cmd', 'win32'), true); + assert.equal(needsWindowsShell('C:\\npm\\sf.cmd', 'win32'), true); + }); + + it('returns true for .bat on win32', () => { + assert.equal(needsWindowsShell('sf.bat', 'win32'), true); + }); + + it('returns true for bare name (no extension) on win32', () => { + assert.equal(needsWindowsShell('sf', 'win32'), true); + }); + + it('returns false for .exe on win32', () => { + assert.equal(needsWindowsShell('sf.exe', 'win32'), false); + }); + + it('is case-insensitive for extension check', () => { + assert.equal(needsWindowsShell('SF.CMD', 'win32'), true); + assert.equal(needsWindowsShell('SF.BAT', 'win32'), true); + }); +}); + +// ── runSfCommand ────────────────────────────────────────────────────────────── + +describe('runSfCommand', () => { + let spawnStub: sinon.SinonStub; + + beforeEach(() => { + spawnStub = sinon.stub(sfSpawnHelper, 'spawnSync'); + setSfPathCacheForTesting('sf'); + setSfPlatformForTesting('linux'); + }); + + afterEach(() => { + sinon.restore(); + setSfPathCacheForTesting(undefined); + setSfPlatformForTesting(undefined); + }); + + it('returns stdout, stderr, and exitCode on success', () => { + spawnStub.returns(makeSpawnOk('output', 'warn')); + const result = runSfCommand(['data', 'query']); + assert.equal(result.stdout, 'output'); + assert.equal(result.stderr, 'warn'); + assert.equal(result.exitCode, 0); + }); + + it('passes args array to spawnSync', () => { + spawnStub.returns(makeSpawnOk()); + runSfCommand(['provar', 'quality-hub', 'connect', '--target-org', 'myorg']); + const [cmd, args] = spawnStub.firstCall.args as [string, string[]]; + assert.equal(cmd, 'sf'); + assert.deepEqual(args, ['provar', 'quality-hub', 'connect', '--target-org', 'myorg']); + }); + + it('uses explicit sfPath instead of cached path', () => { + spawnStub.returns(makeSpawnOk()); + runSfCommand(['--version'], '/custom/path/sf'); + const [cmd] = spawnStub.firstCall.args as [string, string[]]; + assert.equal(cmd, '/custom/path/sf'); + }); + + it('throws SfNotFoundError when cache is null and no sfPath given', () => { + setSfPathCacheForTesting(null); + assert.throws( + () => runSfCommand(['--version']), + (err) => { + assert.ok(err instanceof SfNotFoundError); + assert.equal(err.code, 'SF_NOT_FOUND'); + return true; + } + ); + }); + + it('throws SfNotFoundError with path hint when explicit sfPath gives ENOENT', () => { + spawnStub.returns(makeEnoent()); + assert.throws( + () => runSfCommand(['--version'], '/bad/path/sf'), + (err) => { + assert.ok(err instanceof SfNotFoundError); + assert.ok(err.message.includes('/bad/path/sf')); + return true; + } + ); + }); + + it('rethrows non-ENOENT errors', () => { + const genericErr = new Error('unexpected failure'); + spawnStub.returns({ + stdout: '', + stderr: '', + status: null, + error: genericErr, + pid: undefined, + output: [], + signal: null, + }); + assert.throws(() => runSfCommand(['--version']), genericErr); + }); + + it('returns exitCode 1 when status is null and no error', () => { + spawnStub.returns({ stdout: '', stderr: '', status: null, error: undefined, pid: 1, output: [], signal: null }); + const result = runSfCommand(['--version']); + assert.equal(result.exitCode, 1); + }); + + it('returns non-zero exitCode from failed command', () => { + spawnStub.returns(makeSpawnFail(2, '', 'error text')); + const result = runSfCommand(['bad', 'command']); + assert.equal(result.exitCode, 2); + assert.equal(result.stderr, 'error text'); + }); + + describe('Windows shell path injection guard', () => { + beforeEach(() => { + setSfPlatformForTesting('win32'); + }); + + it('rejects sfPath containing & on win32', () => { + // Path ends with .cmd (triggers shell) but has & in directory name + assert.throws(() => runSfCommand(['--version'], 'C:\\sf & malicious\\sf.cmd'), /unsafe for shell execution/); + }); + + it('rejects sfPath containing | on win32', () => { + assert.throws(() => runSfCommand(['--version'], 'C:\\sf|evil\\sf.cmd'), /unsafe for shell execution/); + }); + + it('accepts clean .cmd path on win32', () => { + spawnStub.returns(makeSpawnOk('ok')); + // Should not throw — clean path + const result = runSfCommand(['--version'], 'C:\\Program Files\\sf\\bin\\sf.cmd'); + assert.equal(result.exitCode, 0); + }); + }); + + describe('probe-based resolution', () => { + beforeEach(() => { + setSfPathCacheForTesting(undefined); // force a fresh probe + setSfPlatformForTesting('linux'); + }); + + it('caches "sf" when shell:false probe succeeds', () => { + spawnStub.returns(makeSpawnOk('sf/2.0.0')); + runSfCommand(['--version']); + const [, args, opts] = spawnStub.firstCall.args as [string, string[], { shell: boolean }]; + assert.deepEqual(args, ['--version']); // probe call + assert.equal(opts.shell, false); + }); + + it('returns SF_NOT_FOUND when probe fails and no common path exists', () => { + // Both probe attempts fail with ENOENT, no common paths match + spawnStub.returns(makeEnoent()); + assert.throws( + () => runSfCommand(['--version']), + (err) => { + assert.ok(err instanceof SfNotFoundError); + return true; + } + ); + }); + + it('Windows two-phase probe: retries with shell:true on ENOENT', () => { + setSfPlatformForTesting('win32'); + // First call (shell:false) → ENOENT + spawnStub.onFirstCall().returns(makeEnoent()); + // Second call (shell:true) → success + spawnStub.onSecondCall().returns(makeSpawnOk('sf/2.0.0')); + // Third call → the actual command + spawnStub.onThirdCall().returns(makeSpawnOk('result')); + + const result = runSfCommand(['--version']); + assert.equal(result.stdout, 'result'); + + // Verify second probe used shell:true + const [, , opts] = spawnStub.secondCall.args as [string, string[], { shell: boolean }]; + assert.equal(opts.shell, true); + }); + }); +}); From bc9e11ac804895d1b6bb41f0a86beb28319e5e3c Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Thu, 7 May 2026 15:56:44 -0500 Subject: [PATCH 12/12] PDX-0: fix(mcp): address PR #151 review comments RCA: Two issues flagged by Copilot review: (1) runSfCommand passed empty-string sfPath directly to spawnSync instead of falling through to auto-discovery, producing a misleading SF_NOT_FOUND with no hint; (2) getSfCommonPaths test predicate includes(bin) was too broad and would pass even if only the client path existed. Fix: Normalize empty/whitespace sfPath to undefined in runSfCommand so auto-discovery runs; tighten qualityHubTools getSfCommonPaths assertions to exact path matching via path.join; add two tests for empty/whitespace sfPath normalization. Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/tools/sfSpawn.ts | 13 ++++++++----- test/unit/mcp/qualityHubTools.test.ts | 13 +++++-------- test/unit/mcp/sfSpawn.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/mcp/tools/sfSpawn.ts b/src/mcp/tools/sfSpawn.ts index 89078cc8..f103385b 100644 --- a/src/mcp/tools/sfSpawn.ts +++ b/src/mcp/tools/sfSpawn.ts @@ -185,8 +185,11 @@ function assertShellSafePath(sfPath: string): void { * Pass `sfPath` to override auto-discovery with an explicit executable path. */ export function runSfCommand(args: string[], sfPath?: string): SpawnResult { - // Use explicit path if provided; otherwise use cached probe result - const executable = sfPath ?? resolveSfExecutable(); + // Treat empty/whitespace sfPath as absent so auto-discovery runs instead of + // throwing SfNotFoundError with no useful path hint. + const trimmedSfPath = sfPath?.trim(); + const resolvedSfPath = trimmedSfPath !== '' ? trimmedSfPath : undefined; + const executable = resolvedSfPath ?? resolveSfExecutable(); if (!executable) throw new SfNotFoundError(); const platform = sfPlatformOverride ?? process.platform; @@ -194,8 +197,8 @@ export function runSfCommand(args: string[], sfPath?: string): SpawnResult { // Guard against injection when shell:true is used with a user-supplied path. // Common install locations returned by resolveSfExecutable() are safe by construction. - if (useShell && sfPath) { - assertShellSafePath(sfPath); + if (useShell && resolvedSfPath) { + assertShellSafePath(resolvedSfPath); } const result = sfSpawnHelper.spawnSync(executable, args, { @@ -207,7 +210,7 @@ export function runSfCommand(args: string[], sfPath?: string): SpawnResult { if (result.error) { const err = result.error as NodeJS.ErrnoException; if (err.code === 'ENOENT') { - throw new SfNotFoundError(sfPath); + throw new SfNotFoundError(resolvedSfPath); } throw result.error; } diff --git a/test/unit/mcp/qualityHubTools.test.ts b/test/unit/mcp/qualityHubTools.test.ts index e35dcf2a..11274d2f 100644 --- a/test/unit/mcp/qualityHubTools.test.ts +++ b/test/unit/mcp/qualityHubTools.test.ts @@ -7,6 +7,7 @@ /* eslint-disable camelcase */ import { strict as assert } from 'node:assert'; +import path from 'node:path'; import sinon from 'sinon'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { sfSpawnHelper, getSfCommonPaths, setSfPathCacheForTesting } from '../../../src/mcp/tools/sfSpawn.js'; @@ -445,14 +446,10 @@ describe('qualityHubTools', () => { Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); try { const paths = getSfCommonPaths(); - assert.ok( - paths.some((p) => p.includes('Program Files') && p.includes('sf') && p.includes('bin')), - 'Expected C:\\Program Files\\sf\\bin\\sf.cmd in common paths' - ); - assert.ok( - paths.some((p) => p.includes('Program Files') && p.includes('sf') && p.includes('client')), - 'Expected C:\\Program Files\\sf\\client\\bin\\sf.cmd in common paths' - ); + const expectedBin = path.join('C:', 'Program Files', 'sf', 'bin', 'sf.cmd'); + const expectedClientBin = path.join('C:', 'Program Files', 'sf', 'client', 'bin', 'sf.cmd'); + assert.ok(paths.includes(expectedBin), `Expected ${expectedBin} in common paths`); + assert.ok(paths.includes(expectedClientBin), `Expected ${expectedClientBin} in common paths`); } finally { if (origPlatform) Object.defineProperty(process, 'platform', origPlatform); } diff --git a/test/unit/mcp/sfSpawn.test.ts b/test/unit/mcp/sfSpawn.test.ts index 9ae3a4b5..60317464 100644 --- a/test/unit/mcp/sfSpawn.test.ts +++ b/test/unit/mcp/sfSpawn.test.ts @@ -223,6 +223,21 @@ describe('runSfCommand', () => { assert.equal(cmd, '/custom/path/sf'); }); + it('falls through to auto-discovery when sfPath is empty string', () => { + // Empty string is not a valid path — should behave as if sfPath was absent + spawnStub.returns(makeSpawnOk('ok')); + runSfCommand(['--version'], ''); + const [cmd] = spawnStub.firstCall.args as [string, string[]]; + assert.equal(cmd, 'sf'); // uses the cached path, not the empty string + }); + + it('falls through to auto-discovery when sfPath is whitespace only', () => { + spawnStub.returns(makeSpawnOk('ok')); + runSfCommand(['--version'], ' '); + const [cmd] = spawnStub.firstCall.args as [string, string[]]; + assert.equal(cmd, 'sf'); + }); + it('throws SfNotFoundError when cache is null and no sfPath given', () => { setSfPathCacheForTesting(null); assert.throws(