diff --git a/.gitignore b/.gitignore index 25e73a53..0b11144d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,11 @@ mochawesome-report # Claude .claude/skills -CLAUDE.md \ No newline at end of file +CLAUDE.md + +# NitroX schema files — do not commit until IP/licensing confirmed with Provar team +# See: src/mcp/tools/nitroXTools.ts and plan notes +src/mcp/rules/FactComponent.schema.json +src/mcp/rules/FactPackage.schema.json +FactComponent.schema +FactPackage.schema \ No newline at end of file diff --git a/README.md b/README.md index 7598bca8..8b519961 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ $ sf plugins uninstall @provartesting/provardx-cli # MCP Server (AI-Assisted Quality) -The Provar DX CLI includes a built-in **Model Context Protocol (MCP) server** that connects AI assistants (Claude Desktop, Claude Code, Cursor) directly to your Provar project. Once connected, an AI agent can inspect your project structure, generate Page Objects and test cases, and validate every level of the test hierarchy with quality scores that match the Provar Quality Hub API. +The Provar DX CLI includes a built-in **Model Context Protocol (MCP) server** that connects AI assistants (Claude Desktop, Claude Code, Cursor) directly to your Provar project. Once connected, an AI agent can inspect your project structure, generate Page Objects and test cases, validate every level of the test hierarchy with quality scores that match the Provar Quality Hub API, and work with NitroX (Hybrid Model) component page objects for LWC, Screen Flow, Industry Components, Experience Cloud, and HTML5. ```sh sf provar mcp start --allowed-paths /path/to/your/provar/project diff --git a/docs/mcp-pilot-guide.md b/docs/mcp-pilot-guide.md index 0dbc3ed0..d5038d74 100644 --- a/docs/mcp-pilot-guide.md +++ b/docs/mcp-pilot-guide.md @@ -13,6 +13,7 @@ The Provar MCP server is a built-in component of the Provar DX CLI that exposes - Validate test cases, suites, plans, and the full project hierarchy against quality rules - Read, generate, and update `provardx-properties.json` run configurations - Trigger Provar Automation test runs and Quality Hub managed runs directly from the AI chat +- Discover, validate, generate, and edit NitroX (Hybrid Model) `.po.json` component page objects for LWC, Screen Flow, Industry Components, Experience Cloud, and HTML5 The server runs **locally on your machine**. It does not phone home, transmit your project files to Provar servers, or require any Provar-side infrastructure changes. @@ -225,6 +226,55 @@ Pre-requisite: `sf org login web -a MyQHOrg` then `sf provar quality-hub connect --- +### Scenario 7: NitroX (Hybrid Model) Page Object Generation + +**Goal:** Have the AI discover, understand, and generate NitroX component page objects. + +NitroX is Provar's Hybrid Model for locators — it maps Salesforce component-based UIs (LWC, Screen Flow, Industry Components, Experience Cloud, HTML5) into `.po.json` files stored in the `nitroX/` directory of your Provar project. + +**Step 1 — Discover existing page objects:** + +> "Discover all NitroX page objects in my Provar project at `/path/to/my/project` and tell me how many there are." + +**What to look for:** The AI calls `provar.nitrox.discover`, finds the `nitroX/` directory, and reports the file count. + +**Step 2 — Read examples for context:** + +> "Read up to 5 NitroX page objects from my project so you understand the structure." + +**What to look for:** The AI calls `provar.nitrox.read` and summarises the patterns it sees (tagName, qualifier, element types, interactions). + +**Step 3 — Generate a new component:** + +> "Generate a NitroX page object for a `lightning-combobox` component named `/com/force/ui/ComboBox`. It should have a `value` qualifier parameter and a single element with a click interaction. Save it to `/path/to/my/project/nitroX/lwc/ComboBox.po.json`." + +**What to look for:** + +- The AI calls `provar.nitrox.generate` with `dry_run: true` first, then writes after your confirmation +- Generated JSON has valid UUIDs for all `componentId` fields +- `tagName`, `parameters`, and `elements` match your description + +**Step 4 — Validate the result:** + +> "Validate the file you just wrote and tell me the score." + +**What to look for:** + +- `provar.nitrox.validate` returns `valid: true` and `score: 100` +- Any issues are listed with rule IDs (NX001–NX010) and suggestions + +**Step 5 — Apply a targeted edit:** + +> "Update the qualifier parameter comparisonType from `equals` to `contains`." + +**What to look for:** + +- The AI calls `provar.nitrox.patch` with `dry_run: true` to show the change +- After confirmation, calls again with `dry_run: false` +- `validate_after: true` (the default) confirms the patch didn't break the schema + +--- + ## Security Model ### What the server does @@ -232,6 +282,8 @@ Pre-requisite: `sf org login web -a MyQHOrg` then `sf provar quality-hub connect - Reads and writes files **only within the paths you specify via `--allowed-paths`** - Validates all incoming paths against those roots before any file operation - Blocks path traversal attempts (`../`) with a `PATH_TRAVERSAL` error +- Resolves symlinks via `fs.realpathSync` before the containment check — a symlink inside an allowed directory pointing outside it cannot bypass the restriction +- Validates all path-type input fields (e.g. `provar_home`, `project_path`, `results_path` in `provar.ant.generate`) before any file operation, not just the output path - Invokes `sf` CLI subprocesses for Quality Hub and Automation tools — these use the SF CLI's existing credential store (`~/.sf/credentials.json`), which the MCP server does not read directly ### Licence validation @@ -277,12 +329,12 @@ The Quality Hub and Automation tools invoke `sf` subprocesses. Salesforce org cr ``` PathPolicy: assertPathAllowed(filePath, allowedPaths) - → PATH_TRAVERSAL if filePath contains ".." - → PATH_NOT_ALLOWED if resolved path is outside all allowed roots + → PATH_TRAVERSAL if filePath contains ".." segments + → PATH_NOT_ALLOWED if resolved (symlink-dereferenced) path is outside all allowed roots → passes otherwise ``` -This check runs before every file read and write. The allowed roots are set at server startup via `--allowed-paths` and cannot be changed while the server is running. +This check runs before every file read and write, including all path-type input fields — not just output file paths. Symlinks are dereferenced so that a symlink inside an allowed directory cannot escape containment. The allowed roots are set at server startup via `--allowed-paths` and cannot be changed while the server is running. ### Audit log diff --git a/docs/mcp.md b/docs/mcp.md index f3707b67..e74c9237 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -45,6 +45,12 @@ The Provar DX CLI ships with a built-in **Model Context Protocol (MCP) server** - [provar.testplan.add-instance](#provartestplanadinstance) - [provar.testplan.create-suite](#provartestplancreatetsuite) - [provar.testplan.remove-instance](#provartestplanremoveinstance) + - [NitroX — Hybrid Model page objects](#nitrox--hybrid-model-page-objects) + - [provar.nitrox.discover](#provarnitroxdiscover) + - [provar.nitrox.read](#provarnitroxread) + - [provar.nitrox.validate](#provarnitroxvalidate) + - [provar.nitrox.generate](#provarnitroxgenerate) + - [provar.nitrox.patch](#provarnitroxpatch) - [AI loop pattern](#ai-loop-pattern) - [Quality scores explained](#quality-scores-explained) - [API compatibility — `xml` vs `xml_content`](#api-compatibility--xml-vs-xml_content) @@ -136,7 +142,9 @@ If the license check fails, the server exits with a clear error message explaini ## Path security -All file-system operations (read, write, generate) are restricted to the paths supplied via `--allowed-paths`. Any attempt to access a path outside those roots is rejected with a `PATH_NOT_ALLOWED` error. Path traversal sequences (`../`) are also blocked. +All file-system operations (read, write, generate) are restricted to the paths supplied via `--allowed-paths`. Any attempt to access a path outside those roots is rejected with a `PATH_NOT_ALLOWED` error. Path traversal sequences (`../`) are blocked with a `PATH_TRAVERSAL` error. + +Symlinks are resolved via `fs.realpathSync` before the containment check, so a symlink inside an allowed directory that points outside it cannot bypass the restriction. For tools that accept multiple path inputs (such as `provar.ant.generate`'s `provar_home`, `project_path`, and `results_path`), all path fields are validated before any file operation occurs — not just the output path. --- @@ -1024,6 +1032,171 @@ Remove a `.testinstance` file from a plan suite. Path is validated to stay withi --- +--- + +## NitroX — Hybrid Model page objects + +NitroX is Provar's **Hybrid Model** for locators. Instead of hand-written Java Page Objects it uses component-based `.po.json` files that map UI elements for any Salesforce component type: LWC, Screen Flow, Industry / OmniStudio, Experience Cloud, and standard HTML5. These files live in `nitroX/` directories inside your Provar project. + +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. + +> **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. + +--- + +### `provar.nitrox.discover` + +Scan a set of directories for Provar projects (identified by a `.testproject` marker file) and inventory each project's `nitroX/` and `nitroXPackages/` directories. Useful as a first step before reading or generating files. + +By default the tool scans `cwd`. If no project is found there it widens the search to `~/git` and `~/Provar`. + +| Input | Type | Required | Default | Description | +| ----------------- | --------- | -------- | ------------------------ | -------------------------------------------------------------- | +| `search_roots` | string[] | no | `[cwd()]` | Directories to scan; falls back to `~/git`, `~/Provar` if empty and cwd has no project | +| `max_depth` | number | no | `6` | Maximum directory depth for `.testproject` search (max 20) | +| `include_packages`| boolean | no | `true` | Return `nitroXPackages/` package names in output | + +| Output field | Description | +| ------------------ | -------------------------------------------------------- | +| `projects` | Array of project result objects (see below) | +| `searched_roots` | Directories actually searched | + +Each project result: + +| Field | Description | +| ------------------- | --------------------------------------------------- | +| `project_path` | Absolute path to the project root | +| `nitrox_dir` | Absolute path to `nitroX/`, or `null` | +| `nitrox_file_count` | Number of `.po.json` files found | +| `nitrox_files` | Full paths to each `.po.json` | +| `packages_dir` | Absolute path to `nitroXPackages/`, or `null` | +| `packages` | Array of `{ path, name? }` package entries | + +Directories named `node_modules`, `.git`, or any hidden directory (`.`-prefixed) are skipped. + +--- + +### `provar.nitrox.read` + +Read one or more NitroX `.po.json` files and return their parsed content for context or training. Provide specific `file_paths` or a `project_path` to read all files from a project's `nitroX/` directory. + +| Input | Type | Required | Default | Description | +| -------------- | -------- | ----------------- | ------- | -------------------------------------------------------- | +| `file_paths` | string[] | one of these two | — | Specific `.po.json` paths to read | +| `project_path` | string | one of these two | — | Provar project root — reads all files from `nitroX/` | +| `max_files` | number | no | `20` | Cap on files returned to avoid context overflow | + +| Output field | Description | +| ------------- | ------------------------------------------------------------------------ | +| `files` | Array of `{ file_path, content, size_bytes }` (or `{ file_path, error }` on failure) | +| `truncated` | `true` when more files exist than `max_files` | +| `total_found` | Total number of `.po.json` files discovered before the cap | + +Path policy is enforced per-file. A missing or unparseable file returns an `error` field inside the file entry rather than failing the whole call. + +**Error codes:** `MISSING_INPUT`, `FILE_NOT_FOUND`, `PATH_NOT_ALLOWED`, `PATH_TRAVERSAL` + +--- + +### `provar.nitrox.validate` + +Validate a NitroX `.po.json` (Hybrid Model component page object) against the FACT schema rules. Returns a quality score (0–100) and a list of issues. + +Score formula: `100 − (20 × errors) − (5 × warnings) − (1 × infos)`, minimum 0. + +| Input | Type | Required | Description | +| ----------- | ------ | -------------- | ----------------------------------- | +| `content` | string | one of these | JSON string to validate | +| `file_path` | string | one of these | Path to a `.po.json` file | + +| Output field | Description | +| ------------- | ---------------------------------------- | +| `valid` | `true` when no ERROR-severity issues | +| `score` | 0–100 | +| `issue_count` | Total issues | +| `issues` | Array of `ValidationIssue` (see below) | + +**Validation rules:** + +| Rule | Severity | Description | +| ----- | -------- | --------------------------------------------------------------------------- | +| NX000 | ERROR | Content is not valid JSON or not a JSON object | +| NX001 | ERROR | `componentId` is missing or not a valid UUID | +| NX002 | ERROR | Root component (no `parentId`) missing `name`, `type`, `pageStructureElement`, or `fieldDetailsElement` | +| NX003 | ERROR | `tagName` contains whitespace | +| NX004 | ERROR | Interaction missing required field (`defaultInteraction`, `implementations` ≥ 1, `interactionType`, `name`, `testStepTitlePattern`, `title`) | +| NX005 | ERROR | Implementation missing `javaScriptSnippet` | +| NX006 | ERROR | Selector missing `xpath` | +| NX007 | WARNING | Element missing `type` | +| NX008 | WARNING | `comparisonType` not one of `"equals"`, `"starts-with"`, `"contains"` | +| NX009 | INFO | Interaction `name` contains characters outside `[A-Za-z0-9 ]` | +| NX010 | INFO | `bodyTagName` contains whitespace | + +**Error codes:** `MISSING_INPUT`, `NX000`, `FILE_NOT_FOUND`, `PATH_NOT_ALLOWED` + +--- + +### `provar.nitrox.generate` + +Generate a new NitroX `.po.json` from a component description. All `componentId` fields are assigned fresh UUIDs. Returns the JSON content; writes to disk only when `dry_run=false`. + +Applicable to any component type: LWC, Screen Flow, Industry Components, Experience Cloud, HTML5. + +| Input | Type | Required | Default | Description | +| ----------------------- | -------- | -------- | --------- | -------------------------------------------------------- | +| `name` | string | yes | — | Path-like name, e.g. `/com/force/myapp/ButtonComponent` | +| `tag_name` | string | yes | — | LWC or HTML tag, e.g. `lightning-button`, `c-my-cmp` | +| `type` | string | no | `"Block"` | `"Block"` or `"Page"` | +| `page_structure_element`| boolean | no | `true` | Whether this is a page structure element | +| `field_details_element` | boolean | no | `false` | Whether this is a field details element | +| `parameters` | object[] | no | — | Qualifier parameters (see below) | +| `elements` | object[] | no | — | Child elements (see below) | +| `output_path` | string | no | — | File path to write when `dry_run=false` | +| `overwrite` | boolean | no | `false` | Overwrite existing file | +| `dry_run` | boolean | no | `true` | Return JSON without writing | + +**Parameter object:** `{ name, value, comparisonType?: "equals"|"starts-with"|"contains", default?: boolean }` + +**Element object:** `{ label, type_ref, tag_name?, parameters?, selector_xpath? }` + +| Output field | Description | +| ------------ | ---------------------------------------- | +| `content` | Generated JSON string (pretty-printed) | +| `file_path` | Resolved absolute path (if `output_path` set) | +| `written` | `true` when file was written to disk | +| `dry_run` | Echo of the `dry_run` input | + +**Error codes:** `FILE_EXISTS`, `PATH_NOT_ALLOWED`, `PATH_TRAVERSAL`, `GENERATE_ERROR` + +--- + +### `provar.nitrox.patch` + +Apply a [JSON merge-patch (RFC 7396)](https://www.rfc-editor.org/rfc/rfc7396) to an existing `.po.json` file. Reads the file, merges the patch, optionally validates the result, and writes back. Use `dry_run=true` (default) to preview changes before committing. + +Patch semantics: a key with a `null` value removes that key; any other value replaces it (or recursively merges if both target and patch values are objects). + +| Input | Type | Required | Default | Description | +| --------------- | ------- | -------- | ------- | ------------------------------------------------------------- | +| `file_path` | string | yes | — | Path to the existing `.po.json` | +| `patch` | object | yes | — | JSON merge-patch to apply | +| `dry_run` | boolean | no | `true` | Return merged result without writing | +| `validate_after`| boolean | no | `true` | Run NX validation; blocks write if errors found | + +| Output field | Description | +| ------------ | ----------------------------------------------- | +| `content` | Merged JSON string (pretty-printed) | +| `file_path` | Absolute path of the file | +| `written` | `true` when file was written | +| `dry_run` | Echo of the `dry_run` input | +| `validation` | Validation result (present when `validate_after=true`) | + +When `validate_after=true` and the merged content has errors, the write is blocked and the tool returns `isError=true` with code `VALIDATION_FAILED`. Set `validate_after=false` to force-write despite errors. + +**Error codes:** `FILE_NOT_FOUND`, `PARSE_ERROR`, `VALIDATION_FAILED`, `PATH_NOT_ALLOWED`, `PATH_TRAVERSAL` + +--- + ## 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. @@ -1053,4 +1226,14 @@ provar.qualityhub.testcase.retrieve → pull test cases scoped to a user story provar.qualityhub.defect.create → file defects for failures automatically ``` +NitroX (Hybrid Model) component page object loop: + +``` +provar.nitrox.discover → find all NitroX projects and .po.json files on the machine +provar.nitrox.read → load existing page objects as AI training context +provar.nitrox.validate → check a generated or edited .po.json for schema issues +provar.nitrox.generate → create a new .po.json from a component description +provar.nitrox.patch → apply targeted edits to an existing .po.json (RFC 7396) +``` + > **Note:** `provar.automation.*` and `provar.qualityhub.*` tools invoke `sf` CLI subprocesses. The Salesforce CLI must be installed and in `PATH`, or pass `sf_path` pointing to the executable directly (e.g. `~/.nvm/versions/node/v22.0.0/bin/sf`). A missing `sf` binary returns the error code `SF_NOT_FOUND` with an installation hint. diff --git a/scripts/mcp-smoke.cjs b/scripts/mcp-smoke.cjs index 952f283f..5fab8678 100644 --- a/scripts/mcp-smoke.cjs +++ b/scripts/mcp-smoke.cjs @@ -267,6 +267,42 @@ async function runTests() { instance_path: 'plans/SmokePlan/SmokeSuite/smoke.testinstance', }); + // ── 34. provar.nitrox.discover ──────────────────────────────────────────── + // TMP has no .testproject → empty projects list, no crash + await callTool('provar.nitrox.discover', { search_roots: [TMP] }); + + // ── 35. provar.nitrox.validate ──────────────────────────────────────────── + // Minimal valid root component → score 100 + await callTool('provar.nitrox.validate', { + content: JSON.stringify({ + componentId: '550e8400-e29b-41d4-a716-446655440000', + name: '/com/smoke/SmokeComponent', + type: 'Block', + pageStructureElement: true, + fieldDetailsElement: false, + }), + }); + + // ── 36. provar.nitrox.generate (dry_run) ───────────────────────────────── + await callTool('provar.nitrox.generate', { + name: '/com/smoke/SmokeComponent', + tag_name: 'c-smoke', + dry_run: true, + }); + + // ── 37. provar.nitrox.read ──────────────────────────────────────────────── + // Non-existent file → FILE_NOT_FOUND result (not a protocol error) + await callTool('provar.nitrox.read', { + file_paths: [path.join(TMP, 'nonexistent.po.json')], + }); + + // ── 38. provar.nitrox.patch ─────────────────────────────────────────────── + // Non-existent file → FILE_NOT_FOUND result (not a protocol error) + await callTool('provar.nitrox.patch', { + file_path: path.join(TMP, 'nonexistent.po.json'), + patch: { name: '/com/smoke/Patched' }, + }); + server.stdin.end(); } @@ -275,8 +311,8 @@ async function runTests() { // ---------------------------------------------------------------------------- server.on('close', () => { clearTimeout(overallTimer); - // initialize + tools/list + 32 tools (setup excluded from default count) - const TOTAL_EXPECTED = 33 + (INCLUDE_SETUP ? 1 : 0); + // initialize + tools/list + 36 tools (setup excluded from default count) + const TOTAL_EXPECTED = 38 + (INCLUDE_SETUP ? 1 : 0); let passed = 0; let failed = 0; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a81320d7..c73f2c03 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,6 +27,7 @@ import { registerAllDefectTools } from './tools/defectTools.js'; import { registerAllAntTools } from './tools/antTools.js'; import { registerAllRcaTools } from './tools/rcaTools.js'; import { registerAllTestPlanTools } from './tools/testPlanTools.js'; +import { registerAllNitroXTools } from './tools/nitroXTools.js'; export interface ServerConfig { allowedPaths: string[]; @@ -72,6 +73,7 @@ export function createProvarMcpServer(config: ServerConfig): McpServer { registerAllAntTools(server, config); registerAllRcaTools(server); registerAllTestPlanTools(server, config); + registerAllNitroXTools(server, config); return server; } diff --git a/src/mcp/tools/nitroXTools.ts b/src/mcp/tools/nitroXTools.ts new file mode 100644 index 00000000..fba1b888 --- /dev/null +++ b/src/mcp/tools/nitroXTools.ts @@ -0,0 +1,896 @@ +/* + * 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 + */ + +/* eslint-disable camelcase */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ServerConfig } from '../server.js'; +import { assertPathAllowed, PathPolicyError } from '../security/pathPolicy.js'; +import { makeError, makeRequestId, type ValidationIssue } from '../schemas/common.js'; +import { log } from '../logging/logger.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface NitroXIssue extends ValidationIssue { + field?: string; +} + +interface NitroXValidationResult { + valid: boolean; + score: number; + issue_count: number; + issues: NitroXIssue[]; +} + +type JsonObj = Record; + +function isObj(v: unknown): v is JsonObj { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +// ── Directory Utilities ─────────────────────────────────────────────────────── + +const SKIP_DIRS = new Set(['node_modules', '.git']); + +/** + * Recursively walk directories looking for .testproject marker files. + * Skips node_modules, .git, and hidden dirs (names starting with '.'). + */ +function findProvarProjects(roots: string[], maxDepth: number): string[] { + const projects: string[] = []; + + function walk(dir: string, depth: number): void { + if (depth > maxDepth) return; + if (fs.existsSync(path.join(dir, '.testproject'))) { + projects.push(dir); + return; // don't recurse into a found project + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue; + walk(path.join(dir, entry.name), depth + 1); + } + } + + for (const root of roots) { + try { + if (!fs.existsSync(root)) continue; + walk(root, 0); + } catch { + // Root inaccessible — skip gracefully + } + } + return projects; +} + +/** Collect all *.po.json files under a directory, recursively. */ +function collectPoJsonFiles(dir: string): string[] { + const files: string[] = []; + function walk(d: string): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(d, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + walk(path.join(d, entry.name)); + } else if (entry.isFile() && entry.name.endsWith('.po.json')) { + files.push(path.join(d, entry.name)); + } + } + } + walk(dir); + return files; +} + +// ── NitroX Validator ───────────────────────────────────────────────────────── + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const VALID_COMPARISON_TYPES = ['equals', 'starts-with', 'contains']; +const INTERACTION_NAME_RE = /^[A-Za-z0-9\s]*$/; + +/** Validate root-level scalar properties (NX001, NX002, NX003, NX010). */ +function validateRootProperties(obj: JsonObj, issues: NitroXIssue[]): void { + // NX001: componentId must be present and a valid UUID + if (obj['componentId'] === undefined || obj['componentId'] === null) { + issues.push({ + rule_id: 'NX001', severity: 'ERROR', + message: 'componentId is required.', + applies_to: 'root', field: 'componentId', + }); + } else if (typeof obj['componentId'] !== 'string' || !UUID_RE.test(obj['componentId'])) { + issues.push({ + rule_id: 'NX001', severity: 'ERROR', + message: `componentId must be a valid UUID, got: "${String(obj['componentId'])}".`, + applies_to: 'root', field: 'componentId', + }); + } + + // NX002: Root (no parentId) requires name, type, pageStructureElement, fieldDetailsElement + const hasParentId = obj['parentId'] !== undefined && obj['parentId'] !== null; + if (!hasParentId) { + for (const field of ['name', 'type', 'pageStructureElement', 'fieldDetailsElement'] as const) { + if (obj[field] === undefined || obj[field] === null) { + issues.push({ + rule_id: 'NX002', severity: 'ERROR', + message: `Root component requires "${field}".`, + applies_to: 'root', field, + suggestion: `Add a "${field}" property to the root component object.`, + }); + } + } + } + + // NX003: tagName must not contain whitespace + if (typeof obj['tagName'] === 'string' && /\s/.test(obj['tagName'])) { + issues.push({ + rule_id: 'NX003', severity: 'ERROR', + message: 'tagName should not contain spaces.', + applies_to: 'root', field: 'tagName', + suggestion: 'Remove whitespace from tagName.', + }); + } + + // NX010: bodyTagName (if present) must not contain whitespace + if (typeof obj['bodyTagName'] === 'string' && /\s/.test(obj['bodyTagName'])) { + issues.push({ + rule_id: 'NX010', severity: 'INFO', + message: 'bodyTagName should not contain spaces.', + applies_to: 'root', field: 'bodyTagName', + suggestion: 'Remove whitespace from bodyTagName.', + }); + } +} + +/** Validate a parsed NitroX .po.json object against schema-derived rules. */ +export function validateNitroXContent(obj: JsonObj): NitroXValidationResult { + const issues: NitroXIssue[] = []; + + validateRootProperties(obj, issues); + + // Validate root-level parameters + if (Array.isArray(obj['parameters'])) { + for (const param of obj['parameters']) { + if (isObj(param)) validateParameter(param, 'root', issues); + } + } + + // Validate root-level interactions + if (Array.isArray(obj['interactions'])) { + for (const interaction of obj['interactions']) { + if (isObj(interaction)) validateInteraction(interaction, 'root', issues); + } + } + + // Validate root-level selectors + if (Array.isArray(obj['selectors'])) { + for (const sel of obj['selectors']) { + if (isObj(sel)) validateSelector(sel, issues); + } + } + + // Validate elements recursively + if (Array.isArray(obj['elements'])) { + for (const el of obj['elements']) { + if (isObj(el)) validateElement(el, issues); + } + } + + const errorCount = issues.filter((i) => i.severity === 'ERROR').length; + const warningCount = issues.filter((i) => i.severity === 'WARNING').length; + const infoCount = issues.filter((i) => i.severity === 'INFO').length; + const score = Math.max(0, 100 - 20 * errorCount - 5 * warningCount - 1 * infoCount); + + return { valid: errorCount === 0, score, issue_count: issues.length, issues }; +} + +function validateElement(el: JsonObj, issues: NitroXIssue[]): void { + // NX007: Element should have type + if (!el['type']) { + issues.push({ + rule_id: 'NX007', severity: 'WARNING', + message: 'Element is missing required "type".', + applies_to: 'element', + suggestion: 'Add a "type" field to the element (e.g. "content" or "component::UUID").', + }); + } + + if (Array.isArray(el['selectors'])) { + for (const sel of el['selectors']) { + if (isObj(sel)) validateSelector(sel, issues); + } + } + if (Array.isArray(el['interactions'])) { + for (const interaction of el['interactions']) { + if (isObj(interaction)) validateInteraction(interaction, 'element', issues); + } + } + if (Array.isArray(el['parameters'])) { + for (const param of el['parameters']) { + if (isObj(param)) validateParameter(param, 'element', issues); + } + } + if (Array.isArray(el['elements'])) { + for (const nested of el['elements']) { + if (isObj(nested)) validateElement(nested, issues); + } + } +} + +function validateInteraction(interaction: JsonObj, context: string, issues: NitroXIssue[]): void { + // NX004: required fields + for (const field of ['defaultInteraction', 'interactionType', 'name', 'testStepTitlePattern', 'title'] as const) { + if (interaction[field] === undefined || interaction[field] === null) { + issues.push({ + rule_id: 'NX004', severity: 'ERROR', + message: `Interaction in ${context} missing required field "${field}".`, + applies_to: 'interaction', field, + }); + } + } + if (!Array.isArray(interaction['implementations']) || interaction['implementations'].length === 0) { + issues.push({ + rule_id: 'NX004', severity: 'ERROR', + message: `Interaction in ${context} must have at least one implementation.`, + applies_to: 'interaction', field: 'implementations', + }); + } else { + for (const impl of interaction['implementations']) { + if (isObj(impl)) validateImplementation(impl, context, issues); + } + } + + // NX009: name should match ^[A-Za-z0-9\s]*$ + if (typeof interaction['name'] === 'string' && !INTERACTION_NAME_RE.test(interaction['name'])) { + issues.push({ + rule_id: 'NX009', severity: 'INFO', + message: `Interaction name "${interaction['name']}" should contain only alphanumeric characters and spaces.`, + applies_to: 'interaction', field: 'name', + suggestion: 'Remove special characters from the interaction name.', + }); + } +} + +function validateImplementation(impl: JsonObj, context: string, issues: NitroXIssue[]): void { + // NX005: must have javaScriptSnippet + if (!impl['javaScriptSnippet']) { + issues.push({ + rule_id: 'NX005', severity: 'ERROR', + message: `Implementation in ${context} missing required "javaScriptSnippet".`, + applies_to: 'implementation', field: 'javaScriptSnippet', + }); + } +} + +function validateSelector(sel: JsonObj, issues: NitroXIssue[]): void { + // NX006: must have xpath + if (!sel['xpath']) { + issues.push({ + rule_id: 'NX006', severity: 'ERROR', + message: 'Selector missing required "xpath".', + applies_to: 'selector', field: 'xpath', + suggestion: 'Add an "xpath" property to the selector.', + }); + } +} + +function validateParameter(param: JsonObj, context: string, issues: NitroXIssue[]): void { + // NX008: comparisonType must be one of valid enum values + if (param['comparisonType'] !== undefined && !VALID_COMPARISON_TYPES.includes(String(param['comparisonType']))) { + issues.push({ + rule_id: 'NX008', severity: 'WARNING', + message: `Parameter in ${context} has invalid comparisonType "${String(param['comparisonType'])}". Must be one of: ${VALID_COMPARISON_TYPES.join(', ')}.`, + applies_to: 'parameter', field: 'comparisonType', + suggestion: `Use one of: ${VALID_COMPARISON_TYPES.join(', ')}`, + }); + } +} + +// ── Generate Builder ────────────────────────────────────────────────────────── + +interface ParameterInput { + name: string; + value: string; + comparisonType?: string; + default?: boolean; +} + +interface ElementInput { + label: string; + type_ref: string; + tag_name?: string; + parameters?: ParameterInput[]; + selector_xpath?: string; +} + +interface GenerateInput { + name: string; + tag_name: string; + type: 'Block' | 'Page'; + page_structure_element: boolean; + field_details_element: boolean; + parameters?: ParameterInput[]; + elements?: ElementInput[]; +} + +function buildNitroXJson(input: GenerateInput): JsonObj { + const result: JsonObj = { + componentId: randomUUID(), + name: input.name, + tagName: input.tag_name, + type: input.type, + pageStructureElement: input.page_structure_element, + fieldDetailsElement: input.field_details_element, + }; + + if (input.parameters && input.parameters.length > 0) { + result['parameters'] = input.parameters.map((p, i) => ({ + name: p.name, + value: p.value, + ...(p.comparisonType !== undefined && { comparisonType: p.comparisonType }), + ...(p.default !== undefined && { default: p.default }), + index: i, + })); + } + + if (input.elements && input.elements.length > 0) { + result['elements'] = input.elements.map((el) => buildElement(el)); + } + + return result; +} + +function buildElement(el: ElementInput): JsonObj { + const element: JsonObj = { + componentId: randomUUID(), + type: el.type_ref, + label: el.label, + }; + + if (el.tag_name) { + element['elementTagName'] = el.tag_name; + } + if (el.parameters && el.parameters.length > 0) { + element['parameters'] = el.parameters.map((p, i) => ({ + name: p.name, + value: p.value, + ...(p.comparisonType !== undefined && { comparisonType: p.comparisonType }), + ...(p.default !== undefined && { default: p.default }), + index: i, + })); + } + if (el.selector_xpath) { + element['selectors'] = [{ xpath: el.selector_xpath }]; + } + + return element; +} + +// ── RFC 7396 Merge-Patch ────────────────────────────────────────────────────── + +function applyMergePatch(target: JsonObj, patch: JsonObj): JsonObj { + const result: JsonObj = { ...target }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete result[key]; + } else if (isObj(value) && isObj(result[key])) { + result[key] = applyMergePatch(result[key] as JsonObj, value); + } else { + result[key] = value; + } + } + return result; +} + +// ── Tool Registrations ──────────────────────────────────────────────────────── + +export function registerNitroXDiscover(server: McpServer): void { + server.tool( + 'provar.nitrox.discover', + [ + 'Discover Provar projects containing NitroX (Hybrid Model) page objects.', + 'Scans directories for .testproject marker files, then inventories nitroX/ and nitroXPackages/ directories.', + 'NitroX is Provar\'s Hybrid Model for locators — component-based page objects for LWC,', + 'Screen Flow, Industry Components, Experience Cloud, and HTML5 components.', + 'Results provide file paths and package info for use with provar.nitrox.read, validate, and generate.', + ].join(' '), + { + search_roots: z + .array(z.string()) + .optional() + .describe('Directories to scan (default: cwd; if empty, falls back to ~/git and ~/Provar)'), + max_depth: z + .number() + .int() + .min(1) + .max(20) + .default(6) + .describe('Maximum directory depth for .testproject search'), + include_packages: z + .boolean() + .default(true) + .describe('Include nitroXPackages/ package.json metadata in results'), + }, + ({ search_roots, max_depth, include_packages }) => { + const requestId = makeRequestId(); + log('info', 'provar.nitrox.discover', { requestId, search_roots, max_depth }); + + try { + let roots = search_roots && search_roots.length > 0 ? search_roots : [process.cwd()]; + let projects = findProvarProjects(roots, max_depth); + + // If no .testproject found in cwd, widen to home-dir defaults + if (projects.length === 0 && (!search_roots || search_roots.length === 0)) { + const fallbackRoots = [ + path.join(os.homedir(), 'git'), + path.join(os.homedir(), 'Provar'), + ]; + const fallbackProjects = findProvarProjects(fallbackRoots, max_depth); + if (fallbackProjects.length > 0) { + projects = fallbackProjects; + roots = fallbackRoots; + } + } + + const projectResults = projects.map((projectPath) => { + const nitroxDir = path.join(projectPath, 'nitroX'); + const packagesDir = path.join(projectPath, 'nitroXPackages'); + const hasNitrox = fs.existsSync(nitroxDir); + const hasPackages = fs.existsSync(packagesDir); + const nitroxFiles = hasNitrox ? collectPoJsonFiles(nitroxDir) : []; + + let packages: Array<{ path: string; name?: string; error?: string }> = []; + if (include_packages && hasPackages) { + try { + packages = fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => { + const pkgDir = path.join(packagesDir, e.name); + const pkgJson = path.join(pkgDir, 'package.json'); + if (!fs.existsSync(pkgJson)) return { path: pkgDir }; + try { + const parsed = JSON.parse(fs.readFileSync(pkgJson, 'utf-8')) as JsonObj; + return { path: pkgDir, name: String(parsed['name'] ?? '') }; + } catch { + return { path: pkgDir, error: 'invalid JSON' }; + } + }); + } catch { + // packagesDir inaccessible — return empty packages + } + } + + return { + project_path: projectPath, + nitrox_dir: hasNitrox ? nitroxDir : null, + nitrox_file_count: nitroxFiles.length, + nitrox_files: nitroxFiles, + packages_dir: hasPackages ? packagesDir : null, + packages, + }; + }); + + const result = { requestId, projects: projectResults, searched_roots: roots }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + } catch (err: unknown) { + const error = err as Error; + const errResult = makeError('DISCOVER_ERROR', error.message, requestId, false); + log('error', 'provar.nitrox.discover failed', { requestId, error: error.message }); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(errResult) }] }; + } + } + ); +} + +export function registerNitroXRead(server: McpServer, config: ServerConfig): void { + server.tool( + 'provar.nitrox.read', + [ + 'Read one or more NitroX .po.json (Hybrid Model page object) files and return their parsed content.', + 'Use this to load examples before generating or validating.', + 'Provide file_paths for specific files, or project_path to read all .po.json files from a project\'s nitroX/ directory.', + ].join(' '), + { + file_paths: z.array(z.string()).optional().describe('Specific .po.json file paths to read'), + project_path: z + .string() + .optional() + .describe('Provar project path — reads all .po.json files from nitroX/ directory'), + max_files: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe('Maximum number of files to return (prevents context overflow)'), + }, + ({ file_paths, project_path, max_files }) => { + const requestId = makeRequestId(); + log('info', 'provar.nitrox.read', { + requestId, + file_count: file_paths?.length, + project_path, + }); + + try { + if (!file_paths?.length && !project_path) { + const err = makeError('MISSING_INPUT', 'Provide either file_paths or project_path.', requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + + let targets: string[] = []; + + if (file_paths?.length) { + targets = file_paths; + } else if (project_path) { + assertPathAllowed(project_path, config.allowedPaths); + const resolved = path.resolve(project_path); + if (!fs.existsSync(resolved)) { + const err = makeError('FILE_NOT_FOUND', `Project path not found: ${resolved}`, requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + const nitroxDir = path.join(resolved, 'nitroX'); + if (!fs.existsSync(nitroxDir)) { + const err = makeError( + 'FILE_NOT_FOUND', + `No nitroX/ directory found in: ${resolved}`, + requestId + ); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + targets = collectPoJsonFiles(nitroxDir); + } + + const truncated = targets.length > max_files; + const toRead = targets.slice(0, max_files); + + const files = toRead.map((filePath) => { + const resolved = path.resolve(filePath); + try { + assertPathAllowed(resolved, config.allowedPaths); + } catch (e: unknown) { + const policyErr = e as PathPolicyError; + return { file_path: resolved, error: policyErr.message, content: null, size_bytes: 0 }; + } + if (!fs.existsSync(resolved)) { + return { file_path: resolved, error: 'FILE_NOT_FOUND', content: null, size_bytes: 0 }; + } + try { + const raw = fs.readFileSync(resolved, 'utf-8'); + const content = JSON.parse(raw) as unknown; + return { file_path: resolved, content, size_bytes: raw.length }; + } catch { + return { file_path: resolved, error: 'PARSE_ERROR', content: null, size_bytes: 0 }; + } + }); + + const result = { requestId, files, truncated, total_found: targets.length }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + } catch (err: unknown) { + const error = err as Error & { code?: string }; + const errResult = makeError( + error instanceof PathPolicyError ? error.code : 'READ_ERROR', + error.message, + requestId, + false + ); + log('error', 'provar.nitrox.read failed', { requestId, error: error.message }); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(errResult) }] }; + } + } + ); +} + +export function registerNitroXValidate(server: McpServer, config: ServerConfig): void { + server.tool( + 'provar.nitrox.validate', + [ + 'Validate a NitroX .po.json (Hybrid Model component page object) against schema rules.', + 'Works for any NitroX-mapped component type: LWC, Screen Flow, Industry Components, Experience Cloud, HTML5.', + 'Returns a quality score (0–100) and a list of issues with rule IDs (NX001–NX010), severity, and suggestions.', + 'Score formula: 100 − (20 × errors) − (5 × warnings) − (1 × infos).', + ].join(' '), + { + content: z.string().optional().describe('JSON string of the .po.json content to validate'), + file_path: z.string().optional().describe('Path to a .po.json file to validate'), + }, + ({ content, file_path }) => { + const requestId = makeRequestId(); + log('info', 'provar.nitrox.validate', { requestId, has_content: !!content, file_path }); + + try { + let source = content; + + if (!source && file_path) { + assertPathAllowed(file_path, config.allowedPaths); + const resolved = path.resolve(file_path); + if (!fs.existsSync(resolved)) { + const err = makeError('FILE_NOT_FOUND', `File not found: ${resolved}`, requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + source = fs.readFileSync(resolved, 'utf-8'); + } + + if (!source) { + const err = makeError('MISSING_INPUT', 'Provide either content or file_path.', requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + const err = makeError('NX000', 'Invalid JSON: could not parse content.', requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + + if (!isObj(parsed)) { + const err = makeError('NX000', 'Content must be a JSON object.', requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + + const validation = validateNitroXContent(parsed); + const result = { requestId, ...validation }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + } catch (err: unknown) { + const error = err as Error & { code?: string }; + const errResult = makeError( + error instanceof PathPolicyError ? error.code : 'VALIDATE_ERROR', + error.message, + requestId, + false + ); + log('error', 'provar.nitrox.validate failed', { requestId, error: error.message }); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(errResult) }] }; + } + } + ); +} + +const ParameterInputSchema = z.object({ + name: z.string().describe('Parameter/qualifier name'), + value: z.string().describe('Parameter value'), + comparisonType: z.enum(['equals', 'starts-with', 'contains']).optional(), + default: z.boolean().optional().describe('Whether this is the default parameter value'), +}); + +const ElementInputSchema = z.object({ + label: z.string().describe('Human-readable element label'), + type_ref: z + .string() + .describe('Component type reference (e.g. "component::UUID" or "content")'), + tag_name: z.string().optional().describe('Optional HTML/LWC tag name override'), + parameters: z.array(ParameterInputSchema).optional(), + selector_xpath: z.string().optional().describe('XPath selector for this element'), +}); + +export function registerNitroXGenerate(server: McpServer, config: ServerConfig): void { + server.tool( + 'provar.nitrox.generate', + [ + '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.', + 'All componentId fields are assigned fresh UUIDs. Returns JSON content;', + 'writes to disk only when dry_run=false.', + ].join(' '), + { + name: z + .string() + .describe('Path-like component name, e.g. /com/force/myapp/ButtonComponent'), + tag_name: z + .string() + .describe('LWC or HTML tag name, e.g. lightning-button or c-my-component'), + type: z.enum(['Block', 'Page']).default('Block').describe('Component type'), + page_structure_element: z + .boolean() + .default(true) + .describe('Whether this is a page structure element'), + field_details_element: z + .boolean() + .default(false) + .describe('Whether this is a field details element'), + parameters: z.array(ParameterInputSchema).optional().describe('Component parameters/qualifiers'), + elements: z.array(ElementInputSchema).optional().describe('Child elements'), + output_path: z + .string() + .optional() + .describe('File path to write (requires dry_run=false)'), + overwrite: z.boolean().default(false).describe('Overwrite if output_path already exists'), + dry_run: z + .boolean() + .default(true) + .describe('Return JSON without writing to disk (default)'), + }, + (input) => { + const requestId = makeRequestId(); + log('info', 'provar.nitrox.generate', { requestId, name: input.name, dry_run: input.dry_run }); + + try { + const generated = buildNitroXJson({ + name: input.name, + tag_name: input.tag_name, + type: input.type, + page_structure_element: input.page_structure_element, + field_details_element: input.field_details_element, + parameters: input.parameters, + elements: input.elements, + }); + const content = JSON.stringify(generated, null, 2); + let filePath: string | undefined; + let written = false; + + if (input.output_path && !input.dry_run) { + filePath = path.resolve(input.output_path); + assertPathAllowed(filePath, config.allowedPaths); + if (fs.existsSync(filePath) && !input.overwrite) { + const err = makeError( + 'FILE_EXISTS', + `File already exists: ${filePath}. Set overwrite=true to replace.`, + requestId + ); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + written = true; + log('info', 'provar.nitrox.generate: wrote file', { requestId, filePath }); + } + + const result = { requestId, content, file_path: filePath, written, dry_run: input.dry_run }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + } catch (err: unknown) { + const error = err as Error & { code?: string }; + const errResult = makeError( + error instanceof PathPolicyError ? error.code : (error.code ?? 'GENERATE_ERROR'), + error.message, + requestId, + false + ); + log('error', 'provar.nitrox.generate failed', { requestId, error: error.message }); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(errResult) }] }; + } + } + ); +} + +export function registerNitroXPatch(server: McpServer, config: ServerConfig): void { + server.tool( + 'provar.nitrox.patch', + [ + 'Apply a JSON merge-patch (RFC 7396) to an existing NitroX .po.json file.', + 'Reads the file, merges the patch (null values remove keys, other values replace or recurse into objects),', + 'optionally validates the merged result, and writes back.', + 'Use dry_run=true (default) to preview the merged output without writing.', + ].join(' '), + { + file_path: z.string().describe('Path to the existing .po.json file to patch'), + patch: z + .record(z.unknown()) + .describe( + 'JSON merge-patch to apply (RFC 7396: null removes key, any other value replaces)' + ), + dry_run: z + .boolean() + .default(true) + .describe('Return merged result without writing to disk (default)'), + validate_after: z + .boolean() + .default(true) + .describe('Run NX validation on merged result; blocks write if errors found'), + }, + ({ file_path, patch, dry_run, validate_after }) => { + const requestId = makeRequestId(); + log('info', 'provar.nitrox.patch', { requestId, file_path, dry_run }); + + try { + assertPathAllowed(file_path, config.allowedPaths); + const resolved = path.resolve(file_path); + + if (!fs.existsSync(resolved)) { + const err = makeError('FILE_NOT_FOUND', `File not found: ${resolved}`, requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + + let original: unknown; + try { + original = JSON.parse(fs.readFileSync(resolved, 'utf-8')); + } catch { + const err = makeError('PARSE_ERROR', 'File contains invalid JSON.', requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + + if (!isObj(original)) { + const err = makeError('PARSE_ERROR', 'File content must be a JSON object.', requestId); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + + const merged = applyMergePatch(original, patch as JsonObj); + const content = JSON.stringify(merged, null, 2); + + let validation: NitroXValidationResult | undefined; + if (validate_after) { + validation = validateNitroXContent(merged); + if (!dry_run && !validation.valid) { + const errCount = validation.issues.filter((i) => i.severity === 'ERROR').length; + const err = makeError( + 'VALIDATION_FAILED', + `Patched content has ${errCount} error(s). Fix issues or set validate_after=false to skip.`, + requestId, + false, + { validation } + ); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(err) }] }; + } + } + + let written = false; + if (!dry_run) { + fs.writeFileSync(resolved, content, 'utf-8'); + written = true; + log('info', 'provar.nitrox.patch: wrote file', { requestId, filePath: resolved }); + } + + const result = { + requestId, + content, + file_path: resolved, + written, + dry_run, + ...(validation !== undefined && { validation }), + }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + } catch (err: unknown) { + const error = err as Error & { code?: string }; + const errResult = makeError( + error instanceof PathPolicyError ? error.code : (error.code ?? 'PATCH_ERROR'), + error.message, + requestId, + false + ); + log('error', 'provar.nitrox.patch failed', { requestId, error: error.message }); + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(errResult) }] }; + } + } + ); +} + +export function registerAllNitroXTools(server: McpServer, config: ServerConfig): void { + registerNitroXDiscover(server); + registerNitroXRead(server, config); + registerNitroXValidate(server, config); + registerNitroXGenerate(server, config); + registerNitroXPatch(server, config); +} diff --git a/test/unit/mcp/antTools.test.ts b/test/unit/mcp/antTools.test.ts index 1cb386e6..80fa8f0e 100644 --- a/test/unit/mcp/antTools.test.ts +++ b/test/unit/mcp/antTools.test.ts @@ -46,12 +46,13 @@ function isError(result: unknown): boolean { // Minimal valid inputs for the generate tool (all fields with defaults supplied explicitly, // since we bypass Zod and defaults are not applied by the mock server). +// Path fields use tmpDir so they pass assertPathAllowed when called with a strict server. function minimalInput(overrides: Record = {}): Record { return { - provar_home: 'C:/Program Files/Provar/', - project_path: '..', - results_path: '../ANT/Results', - filesets: [{ dir: '../tests' }], + provar_home: tmpDir, + project_path: tmpDir, + results_path: path.join(tmpDir, 'Results'), + filesets: [{ dir: '../tests' }], // filesets.dir is not path-checked at runtime web_browser: 'Chrome', web_browser_configuration: 'Full Screen', web_browser_provider_name: 'Desktop', @@ -93,7 +94,9 @@ let config: ServerConfig; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'anttools-test-')); server = new MockMcpServer(); - config = { allowedPaths: [tmpDir] }; + // Use unrestricted mode for XML-generation tests; dedicated path-policy tests below + // use their own strictServer with { allowedPaths: [tmpDir] }. + config = { allowedPaths: [] }; registerAntGenerate(server as never, config); }); @@ -179,9 +182,10 @@ describe('provar.ant.generate', () => { }); it('sets the provar.home property to the provided provar_home value', () => { - const xml = getXml({ provar_home: 'D:/Provar/' }); + const customHome = path.join(tmpDir, 'custom-provar'); + const xml = getXml({ provar_home: customHome }); assert.ok( - xml.includes(''), + xml.includes(``), 'Expected provar.home property' ); }); @@ -403,13 +407,24 @@ describe('provar.ant.generate', () => { }); describe('path policy', () => { + // Helper that overrides the three required path inputs to be within tmpDir, + // so tests can isolate a single invalid field at a time. + function strictInput(overrides: Record = {}): Record { + return minimalInput({ + provar_home: tmpDir, + project_path: tmpDir, + results_path: path.join(tmpDir, 'Results'), + ...overrides, + }); + } + it('returns PATH_NOT_ALLOWED when output_path is outside allowedPaths', () => { const strictServer = new MockMcpServer(); registerAntGenerate(strictServer as never, { allowedPaths: [tmpDir] }); const result = strictServer.call( 'provar.ant.generate', - minimalInput({ + strictInput({ output_path: path.join(os.tmpdir(), 'evil-build.xml'), dry_run: false, overwrite: false, @@ -424,16 +439,82 @@ describe('provar.ant.generate', () => { ); }); - it('does NOT check path policy in dry_run=true mode', () => { + it('output_path is not checked in dry_run=true mode', () => { + const strictServer = new MockMcpServer(); + registerAntGenerate(strictServer as never, { allowedPaths: [tmpDir] }); + + // Input paths are within tmpDir; only the output_path is outside — but dry_run + // skips the write so the output_path should not be validated. + const result = strictServer.call( + 'provar.ant.generate', + strictInput({ output_path: '/etc/evil-build.xml', dry_run: true }) + ); + + assert.equal(isError(result), false, 'output_path should not be checked in dry_run mode'); + }); + + it('rejects provar_home outside allowedPaths', () => { + const strictServer = new MockMcpServer(); + registerAntGenerate(strictServer as never, { allowedPaths: [tmpDir] }); + + const result = strictServer.call( + 'provar.ant.generate', + strictInput({ provar_home: path.join(os.tmpdir(), 'evil-provar'), dry_run: true }) + ); + + assert.equal(isError(result), true); + const code = parseText(result)['error_code'] as string; + assert.ok( + code === 'PATH_NOT_ALLOWED' || code === 'PATH_TRAVERSAL', + `Unexpected error code: ${code}` + ); + }); + + it('rejects project_path containing ".." (PATH_TRAVERSAL)', () => { + const strictServer = new MockMcpServer(); + registerAntGenerate(strictServer as never, { allowedPaths: [tmpDir] }); + + const result = strictServer.call( + 'provar.ant.generate', + strictInput({ project_path: '../evil', dry_run: true }) + ); + + assert.equal(isError(result), true); + assert.equal(parseText(result)['error_code'], 'PATH_TRAVERSAL'); + }); + + it('rejects results_path outside allowedPaths', () => { + const strictServer = new MockMcpServer(); + registerAntGenerate(strictServer as never, { allowedPaths: [tmpDir] }); + + const result = strictServer.call( + 'provar.ant.generate', + strictInput({ results_path: path.join(os.tmpdir(), 'evil-results'), dry_run: true }) + ); + + assert.equal(isError(result), true); + const code = parseText(result)['error_code'] as string; + assert.ok( + code === 'PATH_NOT_ALLOWED' || code === 'PATH_TRAVERSAL', + `Unexpected error code: ${code}` + ); + }); + + it('rejects optional license_path outside allowedPaths when provided', () => { const strictServer = new MockMcpServer(); registerAntGenerate(strictServer as never, { allowedPaths: [tmpDir] }); const result = strictServer.call( 'provar.ant.generate', - minimalInput({ output_path: '/etc/evil-build.xml', dry_run: true }) + strictInput({ license_path: path.join(os.tmpdir(), 'evil-licenses'), dry_run: true }) ); - assert.equal(isError(result), false, 'dry_run should not trigger path check'); + assert.equal(isError(result), true); + const code = parseText(result)['error_code'] as string; + assert.ok( + code === 'PATH_NOT_ALLOWED' || code === 'PATH_TRAVERSAL', + `Unexpected error code: ${code}` + ); }); }); }); diff --git a/test/unit/mcp/automationTools.test.ts b/test/unit/mcp/automationTools.test.ts index 66a8acdf..e15b85b8 100644 --- a/test/unit/mcp/automationTools.test.ts +++ b/test/unit/mcp/automationTools.test.ts @@ -484,8 +484,10 @@ describe('automationTools', () => { }); it('rejects properties_path with .. traversal', () => { + // Use string concatenation (not path.join) so the ".." segment is preserved + // in the raw string that assertPathAllowed inspects. const result = restrictedServer.call('provar.automation.config.load', { - properties_path: path.join(allowedDir, '..', 'etc', 'passwd'), + properties_path: allowedDir + '/../etc/passwd', }); assert.ok(isError(result)); assert.equal(parseBody(result).error_code, 'PATH_TRAVERSAL'); diff --git a/test/unit/mcp/nitroXTools.test.ts b/test/unit/mcp/nitroXTools.test.ts new file mode 100644 index 00000000..e290315a --- /dev/null +++ b/test/unit/mcp/nitroXTools.test.ts @@ -0,0 +1,644 @@ +/* + * 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 + */ +/* eslint-disable camelcase */ + +import { strict as assert } from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// ── Minimal mock server ─────────────────────────────────────────────────────── + +type ToolHandler = (args: Record) => unknown; + +class MockMcpServer { + private handlers = new Map(); + + public tool(name: string, _desc: string, _schema: unknown, handler: ToolHandler): void { + this.handlers.set(name, handler); + } + + public call(name: string, args: Record): ReturnType { + const h = this.handlers.get(name); + if (!h) throw new Error(`Tool not registered: ${name}`); + return h(args); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function parseBody(result: unknown): Record { + const r = result as { content: Array<{ text: string }> }; + return JSON.parse(r.content[0].text) as Record; +} + +function isError(result: unknown): boolean { + return (result as { isError?: boolean }).isError === true; +} + +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'; +const VALID_ROOT = { + componentId: VALID_UUID, + name: '/com/test/MyComponent', + type: 'Block', + pageStructureElement: true, + fieldDetailsElement: false, +}; + +const VALID_INTERACTION = { + name: 'Click', + title: 'Click', + interactionType: 'click', + defaultInteraction: true, + testStepTitlePattern: '{label}', + implementations: [{ javaScriptSnippet: 'yield interactions.click(element);' }], +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('nitroXTools', () => { + let server: MockMcpServer; + let tmpDir: string; + + beforeEach(async () => { + server = new MockMcpServer(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nitrox-test-')); + + const { registerAllNitroXTools } = await import('../../../src/mcp/tools/nitroXTools.js'); + registerAllNitroXTools(server as unknown as McpServer, { allowedPaths: [tmpDir] }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ── provar.nitrox.discover ───────────────────────────────────────────────── + + describe('provar.nitrox.discover', () => { + it('finds project when .testproject marker exists', () => { + fs.writeFileSync(path.join(tmpDir, '.testproject'), ''); + const nitroxDir = path.join(tmpDir, 'nitroX'); + fs.mkdirSync(nitroxDir); + fs.writeFileSync(path.join(nitroxDir, 'Component.po.json'), JSON.stringify(VALID_ROOT)); + + const result = server.call('provar.nitrox.discover', { search_roots: [tmpDir] }); + const body = parseBody(result); + const projects = body['projects'] as Array>; + assert.ok(Array.isArray(projects)); + assert.equal(projects.length, 1); + assert.equal(projects[0]['project_path'], tmpDir); + assert.equal(projects[0]['nitrox_file_count'], 1); + }); + + it('returns empty projects when no .testproject found', () => { + const result = server.call('provar.nitrox.discover', { search_roots: [tmpDir] }); + const body = parseBody(result); + const projects = body['projects'] as unknown[]; + assert.deepEqual(projects, []); + }); + + it('handles non-existent search root gracefully', () => { + const result = server.call('provar.nitrox.discover', { + search_roots: [path.join(tmpDir, 'does-not-exist')], + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.deepEqual(body['projects'], []); + }); + + it('skips node_modules and .git directories', () => { + // Put .testproject inside node_modules — should NOT be found + const nmDir = path.join(tmpDir, 'node_modules', 'some-pkg'); + fs.mkdirSync(nmDir, { recursive: true }); + fs.writeFileSync(path.join(nmDir, '.testproject'), ''); + + const result = server.call('provar.nitrox.discover', { search_roots: [tmpDir] }); + const body = parseBody(result); + assert.deepEqual(body['projects'], []); + }); + + it('skips hidden directories', () => { + const hiddenDir = path.join(tmpDir, '.hidden'); + fs.mkdirSync(hiddenDir); + fs.writeFileSync(path.join(hiddenDir, '.testproject'), ''); + + const result = server.call('provar.nitrox.discover', { search_roots: [tmpDir] }); + const body = parseBody(result); + assert.deepEqual(body['projects'], []); + }); + + it('reads nitroXPackages package.json when include_packages=true', () => { + fs.writeFileSync(path.join(tmpDir, '.testproject'), ''); + const pkgDir = path.join(tmpDir, 'nitroXPackages', 'my-pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: 'my-pkg', version: '1.0.0' }) + ); + + const result = server.call('provar.nitrox.discover', { + search_roots: [tmpDir], + include_packages: true, + }); + const body = parseBody(result); + const projects = body['projects'] as Array>; + assert.equal(projects.length, 1); + const packages = projects[0]['packages'] as Array>; + assert.ok(Array.isArray(packages)); + assert.equal(packages[0]['name'], 'my-pkg'); + }); + }); + + // ── provar.nitrox.read ───────────────────────────────────────────────────── + + describe('provar.nitrox.read', () => { + it('returns content for a valid .po.json file', () => { + const filePath = path.join(tmpDir, 'Component.po.json'); + fs.writeFileSync(filePath, JSON.stringify(VALID_ROOT)); + + const result = server.call('provar.nitrox.read', { file_paths: [filePath] }); + assert.ok(!isError(result)); + const body = parseBody(result); + const files = body['files'] as Array>; + assert.equal(files.length, 1); + assert.ok(files[0]['content']); + assert.ok(!files[0]['error']); + }); + + it('returns FILE_NOT_FOUND error for missing file', () => { + const missing = path.join(tmpDir, 'missing.po.json'); + const result = server.call('provar.nitrox.read', { file_paths: [missing] }); + assert.ok(!isError(result)); // tool-level success, per-file error + const body = parseBody(result); + const files = body['files'] as Array>; + assert.equal(files[0]['error'], 'FILE_NOT_FOUND'); + }); + + it('truncates results at max_files and reports total_found', () => { + for (let i = 0; i < 5; i++) { + fs.writeFileSync(path.join(tmpDir, `c${i}.po.json`), JSON.stringify(VALID_ROOT)); + } + + const result = server.call('provar.nitrox.read', { + file_paths: [ + path.join(tmpDir, 'c0.po.json'), + path.join(tmpDir, 'c1.po.json'), + path.join(tmpDir, 'c2.po.json'), + ], + max_files: 2, + }); + const body = parseBody(result); + const files = body['files'] as unknown[]; + assert.equal(files.length, 2); + assert.equal(body['truncated'], true); + assert.equal(body['total_found'], 3); + }); + + it('reads all .po.json files from project_path', () => { + fs.writeFileSync(path.join(tmpDir, '.testproject'), ''); + const nitroxDir = path.join(tmpDir, 'nitroX'); + fs.mkdirSync(nitroxDir); + fs.writeFileSync(path.join(nitroxDir, 'A.po.json'), JSON.stringify(VALID_ROOT)); + fs.writeFileSync(path.join(nitroxDir, 'B.po.json'), JSON.stringify(VALID_ROOT)); + + const result = server.call('provar.nitrox.read', { project_path: tmpDir }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.equal(body['total_found'], 2); + }); + + it('returns PATH_NOT_ALLOWED error when path is outside allowed roots', () => { + // The server was created with allowedPaths=[tmpDir], so system tmp root is blocked + const outsidePath = path.join(os.tmpdir(), 'outside.po.json'); + // Write a real file so it's not FILE_NOT_FOUND + fs.writeFileSync(outsidePath, JSON.stringify(VALID_ROOT)); + try { + const result = server.call('provar.nitrox.read', { file_paths: [outsidePath] }); + const body = parseBody(result); + const files = body['files'] as Array>; + // Per-file path policy error + assert.ok(files[0]['error'] !== null && files[0]['error'] !== undefined); + } finally { + fs.unlinkSync(outsidePath); + } + }); + + it('returns MISSING_INPUT when neither file_paths nor project_path provided', () => { + const result = server.call('provar.nitrox.read', {}); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'MISSING_INPUT'); + }); + }); + + // ── provar.nitrox.validate ───────────────────────────────────────────────── + + describe('provar.nitrox.validate', () => { + it('scores a fully valid root component as 100', () => { + const result = server.call('provar.nitrox.validate', { + content: JSON.stringify(VALID_ROOT), + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.equal(body['valid'], true); + assert.equal(body['score'], 100); + assert.equal(body['issue_count'], 0); + }); + + it('NX001 ERROR: missing componentId', () => { + const obj = { name: '/com/test/C', type: 'Block', pageStructureElement: true, fieldDetailsElement: false }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX001' && i['severity'] === 'ERROR')); + }); + + it('NX001 ERROR: invalid UUID format', () => { + const obj = { ...VALID_ROOT, componentId: 'not-a-uuid' }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX001' && i['severity'] === 'ERROR')); + }); + + it('NX002 ERROR: root missing required fields', () => { + const obj = { componentId: VALID_UUID }; // no parentId, so root — missing name/type etc + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.filter((i) => i['rule_id'] === 'NX002').length >= 4); + }); + + it('NX002 does not fire when parentId is set', () => { + const obj = { componentId: VALID_UUID, parentId: VALID_UUID }; // child — NX002 should not fire + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(!issues.some((i) => i['rule_id'] === 'NX002')); + }); + + it('NX003 ERROR: tagName contains whitespace', () => { + const obj = { ...VALID_ROOT, tagName: 'my tag' }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX003' && i['severity'] === 'ERROR')); + }); + + it('NX004 ERROR: interaction missing required fields', () => { + const badInteraction = { name: 'Click' }; // missing required fields + const obj = { ...VALID_ROOT, interactions: [badInteraction] }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX004' && i['severity'] === 'ERROR')); + }); + + it('NX005 ERROR: implementation missing javaScriptSnippet', () => { + const badInteraction = { ...VALID_INTERACTION, implementations: [{}] }; + const obj = { ...VALID_ROOT, interactions: [badInteraction] }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX005' && i['severity'] === 'ERROR')); + }); + + it('NX006 ERROR: selector missing xpath', () => { + const obj = { ...VALID_ROOT, selectors: [{ priority: 1 }] }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX006' && i['severity'] === 'ERROR')); + }); + + it('NX007 WARNING: element missing type', () => { + const obj = { ...VALID_ROOT, elements: [{ label: 'My Field' }] }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX007' && i['severity'] === 'WARNING')); + }); + + it('NX008 WARNING: invalid comparisonType', () => { + const obj = { ...VALID_ROOT, parameters: [{ name: 'p', value: 'v', comparisonType: 'startsWith' }] }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX008' && i['severity'] === 'WARNING')); + }); + + it('NX008 accepts "starts-with" (hyphenated)', () => { + const obj = { ...VALID_ROOT, parameters: [{ name: 'p', value: 'v', comparisonType: 'starts-with' }] }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(!issues.some((i) => i['rule_id'] === 'NX008')); + }); + + it('NX009 INFO: interaction name with special characters', () => { + const specialInteraction = { ...VALID_INTERACTION, name: 'Click! Now' }; + const obj = { ...VALID_ROOT, interactions: [specialInteraction] }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX009' && i['severity'] === 'INFO')); + }); + + it('NX010 INFO: bodyTagName contains whitespace', () => { + const obj = { ...VALID_ROOT, bodyTagName: 'body tag' }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX010' && i['severity'] === 'INFO')); + }); + + it('score formula: 2 errors = score 60', () => { + // Missing componentId (NX001) + missing root fields (NX002 × 4) + const obj = { name: '/test' }; // missing componentId + no type/pageStructureElement/fieldDetailsElement + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + assert.equal(body['valid'], false); + assert.ok((body['score'] as number) < 100); + }); + + it('returns FILE_NOT_FOUND when file_path does not exist', () => { + const result = server.call('provar.nitrox.validate', { + file_path: path.join(tmpDir, 'missing.po.json'), + }); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'FILE_NOT_FOUND'); + }); + + it('returns MISSING_INPUT when neither content nor file_path provided', () => { + const result = server.call('provar.nitrox.validate', {}); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'MISSING_INPUT'); + }); + + it('returns NX000 for invalid JSON content', () => { + const result = server.call('provar.nitrox.validate', { content: 'not json {' }); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'NX000'); + }); + + it('validates nested elements recursively', () => { + const obj = { + ...VALID_ROOT, + elements: [ + { + type: 'content', + elements: [ + { type: 'content', selectors: [{}] }, // NX006: no xpath + ], + }, + ], + }; + const result = server.call('provar.nitrox.validate', { content: JSON.stringify(obj) }); + const body = parseBody(result); + const issues = body['issues'] as Array>; + assert.ok(issues.some((i) => i['rule_id'] === 'NX006')); + }); + }); + + // ── provar.nitrox.generate ───────────────────────────────────────────────── + + describe('provar.nitrox.generate', () => { + it('dry_run=true returns JSON without writing', () => { + const result = server.call('provar.nitrox.generate', { + name: '/com/test/ButtonComponent', + tag_name: 'lightning-button', + dry_run: true, + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.ok(typeof body['content'] === 'string'); + assert.equal(body['written'], false); + + const generated = JSON.parse(body['content']) as Record; + assert.ok(generated['componentId']); + assert.equal(generated['name'], '/com/test/ButtonComponent'); + assert.equal(generated['tagName'], 'lightning-button'); + }); + + it('writes file when dry_run=false', () => { + const outPath = path.join(tmpDir, 'Button.po.json'); + const result = server.call('provar.nitrox.generate', { + name: '/com/test/ButtonComponent', + tag_name: 'lightning-button', + output_path: outPath, + dry_run: false, + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.equal(body['written'], true); + assert.ok(fs.existsSync(outPath)); + }); + + it('returns FILE_EXISTS when overwrite=false and file exists', () => { + const outPath = path.join(tmpDir, 'Exists.po.json'); + fs.writeFileSync(outPath, '{}'); + + const result = server.call('provar.nitrox.generate', { + name: '/com/test/C', + tag_name: 'c-test', + output_path: outPath, + overwrite: false, + dry_run: false, + }); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'FILE_EXISTS'); + }); + + it('overwrites file when overwrite=true', () => { + const outPath = path.join(tmpDir, 'Overwrite.po.json'); + fs.writeFileSync(outPath, '{"old": true}'); + + const result = server.call('provar.nitrox.generate', { + name: '/com/test/C', + tag_name: 'c-test', + output_path: outPath, + overwrite: true, + dry_run: false, + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.equal(body['written'], true); + const written = JSON.parse(fs.readFileSync(outPath, 'utf-8')) as Record; + assert.equal(written['name'], '/com/test/C'); + }); + + it('returns PATH_NOT_ALLOWED when output_path is outside allowed roots', () => { + const outPath = path.join(os.tmpdir(), 'outside-allowed.po.json'); + const result = server.call('provar.nitrox.generate', { + name: '/com/test/C', + tag_name: 'c-test', + output_path: outPath, + dry_run: false, + }); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'PATH_NOT_ALLOWED'); + }); + + it('generates elements with parameters and selectors', () => { + const result = server.call('provar.nitrox.generate', { + name: '/com/test/FormComponent', + tag_name: 'c-form', + elements: [ + { + label: 'Name Field', + type_ref: 'content', + tag_name: 'input', + selector_xpath: "//input[@name='firstName']", + parameters: [{ name: 'attr', value: 'firstName', comparisonType: 'equals', default: true }], + }, + ], + dry_run: true, + }); + assert.ok(!isError(result)); + const body = parseBody(result); + const generated = JSON.parse(body['content'] as string) as Record; + const elements = generated['elements'] as Array>; + assert.equal(elements.length, 1); + assert.equal(elements[0]['label'], 'Name Field'); + assert.equal(elements[0]['elementTagName'], 'input'); + const selectors = elements[0]['selectors'] as Array>; + assert.equal(selectors[0]['xpath'], "//input[@name='firstName']"); + const params = elements[0]['parameters'] as Array>; + assert.equal(params[0]['comparisonType'], 'equals'); + }); + + it('assigns unique UUIDs to root and each element', () => { + const result = server.call('provar.nitrox.generate', { + name: '/com/test/Multi', + tag_name: 'c-multi', + elements: [ + { label: 'Field A', type_ref: 'content' }, + { label: 'Field B', type_ref: 'content' }, + ], + dry_run: true, + }); + const body = parseBody(result); + const generated = JSON.parse(body['content'] as string) as Record; + const elements = generated['elements'] as Array>; + const ids = [ + generated['componentId'], + elements[0]['componentId'], + elements[1]['componentId'], + ]; + const unique = new Set(ids); + assert.equal(unique.size, 3); + }); + }); + + // ── provar.nitrox.patch ──────────────────────────────────────────────────── + + describe('provar.nitrox.patch', () => { + it('returns FILE_NOT_FOUND for missing file', () => { + const result = server.call('provar.nitrox.patch', { + file_path: path.join(tmpDir, 'missing.po.json'), + patch: { name: '/new' }, + }); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'FILE_NOT_FOUND'); + }); + + it('dry_run=true merges and returns content without writing', () => { + const filePath = path.join(tmpDir, 'Component.po.json'); + fs.writeFileSync(filePath, JSON.stringify(VALID_ROOT)); + + const result = server.call('provar.nitrox.patch', { + file_path: filePath, + patch: { name: '/com/test/Updated' }, + dry_run: true, + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.equal(body['written'], false); + const merged = JSON.parse(body['content'] as string) as Record; + assert.equal(merged['name'], '/com/test/Updated'); + // Original file unchanged + const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + assert.equal(onDisk['name'], VALID_ROOT.name); + }); + + it('dry_run=false writes merged file', () => { + const filePath = path.join(tmpDir, 'Component.po.json'); + fs.writeFileSync(filePath, JSON.stringify(VALID_ROOT)); + + const result = server.call('provar.nitrox.patch', { + file_path: filePath, + patch: { name: '/com/test/Patched' }, + dry_run: false, + validate_after: false, + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.equal(body['written'], true); + const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + assert.equal(onDisk['name'], '/com/test/Patched'); + }); + + it('RFC 7396: null patch value removes key', () => { + const filePath = path.join(tmpDir, 'Component.po.json'); + fs.writeFileSync(filePath, JSON.stringify({ ...VALID_ROOT, qualifier: 'some-qualifier' })); + + const result = server.call('provar.nitrox.patch', { + file_path: filePath, + patch: { qualifier: null }, + dry_run: true, + validate_after: false, + }); + const body = parseBody(result); + const merged = JSON.parse(body['content'] as string) as Record; + assert.ok(!('qualifier' in merged)); + }); + + it('validate_after=true blocks write when merged result has errors', () => { + const filePath = path.join(tmpDir, 'Component.po.json'); + fs.writeFileSync(filePath, JSON.stringify(VALID_ROOT)); + + // Remove componentId via patch — will trigger NX001 error + const result = server.call('provar.nitrox.patch', { + file_path: filePath, + patch: { componentId: null }, + dry_run: false, + validate_after: true, + }); + assert.ok(isError(result)); + const body = parseBody(result); + assert.equal(body['error_code'], 'VALIDATION_FAILED'); + // File should be unchanged + const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + assert.ok(onDisk['componentId']); + }); + + it('includes validation result in response when validate_after=true', () => { + const filePath = path.join(tmpDir, 'Component.po.json'); + fs.writeFileSync(filePath, JSON.stringify(VALID_ROOT)); + + const result = server.call('provar.nitrox.patch', { + file_path: filePath, + patch: { name: '/com/test/Updated' }, + dry_run: true, + validate_after: true, + }); + assert.ok(!isError(result)); + const body = parseBody(result); + assert.ok(body['validation']); + const validation = body['validation'] as Record; + assert.equal(validation['valid'], true); + assert.equal(validation['score'], 100); + }); + }); +}); diff --git a/test/unit/mcp/pathPolicy.test.ts b/test/unit/mcp/pathPolicy.test.ts index 20afc9fa..01276d83 100644 --- a/test/unit/mcp/pathPolicy.test.ts +++ b/test/unit/mcp/pathPolicy.test.ts @@ -93,8 +93,9 @@ describe('pathPolicy', () => { it('allows a real path inside an allowed dir (not a symlink)', () => { symlinkDir = fs.mkdtempSync(path.join(tmp, 'pathpolicy-real-')); const real = path.join(symlinkDir, 'real-file.txt'); + const allowedDir = symlinkDir; fs.writeFileSync(real, 'content'); - assert.doesNotThrow(() => assertPathAllowed(real, [symlinkDir])); + assert.doesNotThrow(() => assertPathAllowed(real, [allowedDir])); }); }); }); diff --git a/test/unit/mcp/qualityHubTools.test.ts b/test/unit/mcp/qualityHubTools.test.ts index 4ff63842..81cf1a0b 100644 --- a/test/unit/mcp/qualityHubTools.test.ts +++ b/test/unit/mcp/qualityHubTools.test.ts @@ -192,6 +192,77 @@ describe('qualityHubTools', () => { const result = server.call('provar.qualityhub.testrun.report', { target_org: 'myorg', run_id: 'abc-123', flags: [] }); assert.equal(parseBody(result).error_code, 'SF_NOT_FOUND'); }); + + describe('failure detection', () => { + it('sets suggestion when JSON result.status is "FAILED"', () => { + spawnStub.returns(makeSpawnResult( + JSON.stringify({ result: { status: 'FAILED' } }), + '', + 0 + )); + const result = server.call('provar.qualityhub.testrun.report', { target_org: 'myorg', run_id: 'abc-123', flags: [] }); + const body = parseBody(result); + assert.ok(typeof body.suggestion === 'string' && body.suggestion.length > 0, 'Expected suggestion when FAILED'); + }); + + it('sets suggestion when JSON result.status is "FAIL"', () => { + spawnStub.returns(makeSpawnResult( + JSON.stringify({ result: { status: 'FAIL' } }), + '', + 0 + )); + const result = server.call('provar.qualityhub.testrun.report', { target_org: 'myorg', run_id: 'abc-123', flags: [] }); + const body = parseBody(result); + assert.ok(typeof body.suggestion === 'string' && body.suggestion.length > 0, 'Expected suggestion when FAIL'); + }); + + it('does NOT set suggestion when status is "RUNNING"', () => { + spawnStub.returns(makeSpawnResult( + JSON.stringify({ result: { status: 'RUNNING' } }), + '', + 0 + )); + const result = server.call('provar.qualityhub.testrun.report', { target_org: 'myorg', run_id: 'abc-123', flags: [] }); + const body = parseBody(result); + assert.ok(!body.suggestion, 'Expected no suggestion for non-failure status'); + }); + + it('does NOT set suggestion when status is "PASSED"', () => { + spawnStub.returns(makeSpawnResult( + JSON.stringify({ result: { status: 'PASSED' } }), + '', + 0 + )); + const result = server.call('provar.qualityhub.testrun.report', { target_org: 'myorg', run_id: 'abc-123', flags: [] }); + const body = parseBody(result); + assert.ok(!body.suggestion, 'Expected no suggestion when PASSED'); + }); + + it('does NOT false-positive on "failure" in plain text output (word in non-status context)', () => { + // Before PR #110 the check was /fail/i which would match "failure" anywhere in output; + // now it only matches the "status" field value. + spawnStub.returns(makeSpawnResult( + '{"message": "No failure detected in this output", "result": {"status": "PASSED"}}', + '', + 0 + )); + const result = server.call('provar.qualityhub.testrun.report', { target_org: 'myorg', run_id: 'abc-123', flags: [] }); + const body = parseBody(result); + assert.ok(!body.suggestion, 'Expected no suggestion — "failure" in message should not trigger detection'); + }); + + it('falls back to regex extraction when stdout is not valid JSON', () => { + // Non-JSON output with "status": "FAILED" substring + spawnStub.returns(makeSpawnResult( + '"status": "FAILED"', + '', + 0 + )); + const result = server.call('provar.qualityhub.testrun.report', { target_org: 'myorg', run_id: 'abc-123', flags: [] }); + const body = parseBody(result); + assert.ok(typeof body.suggestion === 'string' && body.suggestion.length > 0, 'Expected suggestion from regex fallback'); + }); + }); }); // ── provar.qualityhub.testrun.abort ────────────────────────────────────────── diff --git a/yarn.lock b/yarn.lock index efba173a..b1c44ecb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1902,7 +1902,7 @@ strip-ansi "6.0.1" ts-retry-promise "^0.8.0" -"@salesforce/core@^6.4.7", "@salesforce/core@^6.5.1", "@salesforce/core@^6.5.2": +"@salesforce/core@^6.4.7": version "6.5.2" resolved "https://registry.npmjs.org/@salesforce/core/-/core-6.5.2.tgz" integrity sha512-/tviKhMQRMNZlbG/IldCXy6dLAOtCX9gysdiVeCoEsgWcXT72rj02fJg4PQMtc69GAu2vnRSbaRewfrC8Mrw8g== @@ -1926,30 +1926,7 @@ semver "^7.5.4" ts-retry-promise "^0.7.1" -"@salesforce/core@^7.2.0", "@salesforce/core@^7.3.3": - version "7.3.3" - resolved "https://registry.npmjs.org/@salesforce/core/-/core-7.3.3.tgz" - integrity sha512-THjYnOrfj0vW+qvlm70NDasH3RHD03cm884yi1+1axA4ugS4FFxXrPDPWAEU5ve5B4vnT7CJfuD/Q56l67ug8w== - dependencies: - "@jsforce/jsforce-node" "^3.2.0" - "@salesforce/kit" "^3.1.1" - "@salesforce/schemas" "^1.7.0" - "@salesforce/ts-types" "^2.0.9" - ajv "^8.12.0" - change-case "^4.1.2" - faye "^1.4.0" - form-data "^4.0.0" - js2xmlparser "^4.0.1" - jsonwebtoken "9.0.2" - jszip "3.10.1" - pino "^8.21.0" - pino-abstract-transport "^1.1.0" - pino-pretty "^10.3.1" - proper-lockfile "^4.1.2" - semver "^7.6.0" - ts-retry-promise "^0.7.1" - -"@salesforce/core@^7.3.9": +"@salesforce/core@^7.0.0", "@salesforce/core@^7.3.9": version "7.5.0" resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-7.5.0.tgz#cfa57281978c9d5df6f7419e5bc58ea914726cf5" integrity sha512-mPg9Tj2Qqe/TY7q+CRNSeYYTV+dj/LflM7Fu/32EPLCEPGVIiSp/RaTFLTZwDcFX9BVYHOa2h6oliuO2Qnno+A== @@ -1973,6 +1950,29 @@ semver "^7.6.2" ts-retry-promise "^0.8.1" +"@salesforce/core@^7.2.0", "@salesforce/core@^7.3.3": + version "7.3.3" + resolved "https://registry.npmjs.org/@salesforce/core/-/core-7.3.3.tgz" + integrity sha512-THjYnOrfj0vW+qvlm70NDasH3RHD03cm884yi1+1axA4ugS4FFxXrPDPWAEU5ve5B4vnT7CJfuD/Q56l67ug8w== + dependencies: + "@jsforce/jsforce-node" "^3.2.0" + "@salesforce/kit" "^3.1.1" + "@salesforce/schemas" "^1.7.0" + "@salesforce/ts-types" "^2.0.9" + ajv "^8.12.0" + change-case "^4.1.2" + faye "^1.4.0" + form-data "^4.0.0" + js2xmlparser "^4.0.1" + jsonwebtoken "9.0.2" + jszip "3.10.1" + pino "^8.21.0" + pino-abstract-transport "^1.1.0" + pino-pretty "^10.3.1" + proper-lockfile "^4.1.2" + semver "^7.6.0" + ts-retry-promise "^0.7.1" + "@salesforce/dev-config@^4.1.0": version "4.1.0" resolved "https://registry.npmjs.org/@salesforce/dev-config/-/dev-config-4.1.0.tgz" @@ -2039,16 +2039,16 @@ resolved "https://registry.yarnpkg.com/@salesforce/schemas/-/schemas-1.10.3.tgz#52c867fdd60679cf216110aa49542b7ad391f5d1" integrity sha512-FKfvtrYTcvTXE9advzS25/DEY9yJhEyLvStm++eQFtnAaX1pe4G3oGHgiQ0q55BM5+0AlCh0+0CVtQv1t4oJRA== -"@salesforce/sf-plugins-core@^7.1.4": - version "7.1.8" - resolved "https://registry.npmjs.org/@salesforce/sf-plugins-core/-/sf-plugins-core-7.1.8.tgz" - integrity sha512-5QAcxCQ/YX1hywfKHRIe6RAVsHM27nMUOJlIW9+H2pdJeZbXg1TtMITjv7oQfxmGFlRG2UzgAm5ZfUlrl0IHtQ== +"@salesforce/sf-plugins-core@^9.0.0", "@salesforce/sf-plugins-core@^9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@salesforce/sf-plugins-core/-/sf-plugins-core-9.1.1.tgz#8818fdb23e0f174d9e6dded0cf34a88be5e3cc44" + integrity sha512-5d4vGLqb1NZoHvDpuTu96TsFg/lexdnQNWC0h7GhOqxikJBpxk6P1DEbk9HrZWL18Gs1YXO9OCj2g8nKqbIC/Q== dependencies: - "@inquirer/confirm" "^2.0.17" - "@inquirer/password" "^1.1.16" - "@oclif/core" "^3.18.2" - "@salesforce/core" "^6.5.2" - "@salesforce/kit" "^3.0.15" + "@inquirer/confirm" "^3.1.9" + "@inquirer/password" "^2.1.9" + "@oclif/core" "^3.26.6" + "@salesforce/core" "^7.3.9" + "@salesforce/kit" "^3.1.2" "@salesforce/ts-types" "^2.0.9" chalk "^5.3.0" @@ -2065,19 +2065,6 @@ "@salesforce/ts-types" "^2.0.9" chalk "^5.3.0" -"@salesforce/sf-plugins-core@^9.1.1": - version "9.1.1" - resolved "https://registry.yarnpkg.com/@salesforce/sf-plugins-core/-/sf-plugins-core-9.1.1.tgz#8818fdb23e0f174d9e6dded0cf34a88be5e3cc44" - integrity sha512-5d4vGLqb1NZoHvDpuTu96TsFg/lexdnQNWC0h7GhOqxikJBpxk6P1DEbk9HrZWL18Gs1YXO9OCj2g8nKqbIC/Q== - dependencies: - "@inquirer/confirm" "^3.1.9" - "@inquirer/password" "^2.1.9" - "@oclif/core" "^3.26.6" - "@salesforce/core" "^7.3.9" - "@salesforce/kit" "^3.1.2" - "@salesforce/ts-types" "^2.0.9" - chalk "^5.3.0" - "@salesforce/ts-types@^2.0.10", "@salesforce/ts-types@^2.0.12", "@salesforce/ts-types@^2.0.9": version "2.0.12" resolved "https://registry.npmjs.org/@salesforce/ts-types/-/ts-types-2.0.12.tgz" @@ -10273,11 +10260,6 @@ yargs@^17.0.0, yargs@^17.2.1: y18n "^5.0.5" yargs-parser "^21.1.1" -yarn@^1.22.22: - version "1.22.22" - resolved "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz" - integrity sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg== - yeoman-environment@^3.15.1: version "3.18.3" resolved "https://registry.npmjs.org/yeoman-environment/-/yeoman-environment-3.18.3.tgz"