From d0335193b41506efa07491134aa72a0a1f717b6e Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Tue, 19 May 2026 15:59:59 -0500 Subject: [PATCH 1/2] =?UTF-8?q?PDX-493:=20feat(mcp)=20=E2=80=94=20emit=20v?= =?UTF-8?q?alueClass=3Ddate|datetime|boolean|integer=20via=20inferSalesfor?= =?UTF-8?q?ceValueClass=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RCA: Provar runtime silently discards Salesforce date/datetime fields whose XML emits valueClass="string" — the failure surfaces only when a later validation rule reads the empty field. The existing buildArgumentValue dispatch in src/mcp/tools/testCaseGenerate.ts fell through to a hard-coded valueClass="string" fallback after handling variable / compound / uiTarget / uiLocator cases, so every literal date or boolean argument flowed out untyped. A prior helper (inferValueClass) introduced in 3c1545c that covered the boolean case alone was refactored away in 2d4240c, regressing literal-true/false handling too. Datetime, integer, and explicit org-describe-driven type hints were never covered. Fix: Introduce an exported inferSalesforceValueClass(key, val, fieldTypeHint?) helper that returns one of 'date' | 'datetime' | 'boolean' | 'integer' | 'string'. Detection order: explicit fieldTypeHint wins; then ISO-8601 datetime regex /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; then ISO-8601 date regex /^\d{4}-\d{2}-\d{2}$/; then literal 'true'/'false'; then integer-only string /^-?\d+$/; else string. buildArgumentValue now calls this helper before the (now-removed) string fallback and emits . The fieldTypeHint param is wired into the helper signature but not yet exposed on the MCP tool input schema — that exposure lands in PDX-492 H2b, which sits behind PDX-491 H2a (provar_org_describe). Until then callers omit the hint and detection falls through to the regex layer. Tool description and docs/mcp.md argument-conventions table updated to spell out the auto-detection rules. Tests: New PDX-493 — inferSalesforceValueClass helper block exercises the helper directly across all six branches (datetime with fractional seconds + zone, plain date, true, false, positive integer, negative integer, plain string), the edge case where a short numeric string like "12" must resolve to integer not date (date regex requires full ISO yyyy-mm-dd), strings that look like dates but miss the ISO format must stay string, and explicit fieldTypeHint wins over format detection in all three directions (string-shape + date hint, date-shape + string hint, integer-shape + boolean hint). A second PDX-493 — valueClass emission in generated XML block round-trips through provar_testcase_generate and asserts the inferred attribute reaches the emitted XML for date, datetime, boolean (true and false), positive integer, negative integer, and plain string. Validation: node_modules/.bin/nyc node_modules/.bin/mocha "test/**/*.test.ts" — 1172 passing / 0 failing; yarn compile clean; yarn lint clean (lint:script-names + lint). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/mcp.md | 20 ++- src/mcp/tools/testCaseGenerate.ts | 39 +++++- test/unit/mcp/testCaseGenerate.test.ts | 185 ++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 9 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 33d23421..4068f543 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -729,13 +729,19 @@ The tool's chip-level `title` — `Generate Test Case (full steps in one call)` **Argument XML conventions** (automatically applied by the generator): -| Argument key / value pattern | Emitted XML class | API context | -| ------------------------------------ | ----------------------------------- | ----------------------- | -| `target` key | `class="uiTarget"` | UiWithScreen, UiWithRow | -| `locator` key | `class="uiLocator"` | UiDoAction, UiAssert | -| Value matches `{VarName}` or `{A.B}` | `class="variable"` + `` | Any step | -| SetValues attributes | `class="valueList"/` | SetValues only | -| All other values | `class="value" valueClass="string"` | Any step | +| Argument key / value pattern | Emitted XML class | API context | +| --------------------------------------- | ------------------------------------- | ----------------------- | +| `target` key | `class="uiTarget"` | UiWithScreen, UiWithRow | +| `locator` key | `class="uiLocator"` | UiDoAction, UiAssert | +| Value matches `{VarName}` or `{A.B}` | `class="variable"` + `` | Any step | +| SetValues attributes | `class="valueList"/` | SetValues only | +| Value `YYYY-MM-DDTHH:MM:SS…` (ISO-8601) | `class="value" valueClass="datetime"` | Any step | +| Value `YYYY-MM-DD` (ISO-8601) | `class="value" valueClass="date"` | Any step | +| Value `true` / `false` | `class="value" valueClass="boolean"` | Any step | +| Value matches `^-?\d+$` | `class="value" valueClass="integer"` | Any step | +| All other values | `class="value" valueClass="string"` | Any step | + +`valueClass` is inferred automatically by `inferSalesforceValueClass(key, val, fieldTypeHint?)`. Detection order: explicit `fieldTypeHint` (wired in a follow-up tool surface — `field_type_hints` param) → ISO-8601 datetime → ISO-8601 date → boolean → integer → string. Provar runtime silently discards date fields emitted as `valueClass="string"`, so always pass date / datetime values in ISO-8601 form. AssertValues uses **flat** argument structure (`expectedValue`, `actualValue`, `comparisonType`) — not the `valueList`/namedValues format. diff --git a/src/mcp/tools/testCaseGenerate.ts b/src/mcp/tools/testCaseGenerate.ts index 6e97b736..244533a9 100644 --- a/src/mcp/tools/testCaseGenerate.ts +++ b/src/mcp/tools/testCaseGenerate.ts @@ -150,6 +150,10 @@ const TOOL_DESCRIPTION = [ 'Variable references: pass values as "{VarName}" (braces); emitted as class="variable" .', 'target argument (UiWithScreen/UiWithRow): pass the URI value; emitted as class="uiTarget" uri="...".', 'locator argument (UiDoAction/UiAssert): pass the URI value; emitted as class="uiLocator" uri="...".', + 'valueClass auto-detection: argument values are typed automatically before XML emission. ' + + 'ISO-8601 date "YYYY-MM-DD" → valueClass="date"; ISO-8601 datetime "YYYY-MM-DDTHH:MM:SS" → "datetime"; ' + + '"true"/"false" → "boolean"; integer-only string (e.g. "42", "-5") → "integer"; otherwise "string". ' + + 'Pass dates / booleans / integers in those formats — Provar runtime silently discards date fields emitted as valueClass="string".', 'Edit page objects: action=Edit targets require a compiled page object for the SF object. ' + 'If none exists in the project page-objects directory, the locator binding will fail at runtime. ' + 'For objects without a compiled Edit page object, use inline edit instead: sfIleActivate to activate the field, ' + @@ -374,6 +378,35 @@ export function registerTestCaseGenerate(server: McpServer, config: ServerConfig // ── XML builder ─────────────────────────────────────────────────────────────── +// PDX-493 (H3): infer the Salesforce `valueClass` attribute that should be emitted on a +// `` element from an argument's key + string value. +// +// Detection order: +// 1. Explicit `fieldTypeHint` (from `field_type_hints` param or `provar_org_describe` cache — wired +// in PDX-492 H2b) wins if provided. +// 2. ISO-8601 datetime → 'datetime' (e.g. "2026-05-19T10:30:00", with optional fractional/zone). +// 3. ISO-8601 date → 'date' (e.g. "2026-05-19"). +// 4. Literal 'true' / 'false' → 'boolean'. +// 5. Integer-only string (optional leading '-') → 'integer'. +// 6. Else → 'string'. +// +// The `key` argument is reserved for future heuristics (e.g. SF naming conventions like *__c) +// but is intentionally not consulted today — explicit hints + value-shape regexes are the +// safer signal until org-describe is wired. +export function inferSalesforceValueClass( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + key: string, + val: string, + fieldTypeHint?: 'date' | 'datetime' | 'boolean' | 'integer' | 'string' +): 'date' | 'datetime' | 'boolean' | 'integer' | 'string' { + if (fieldTypeHint) return fieldTypeHint; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) return 'datetime'; + if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return 'date'; + if (val === 'true' || val === 'false') return 'boolean'; + if (/^-?\d+$/.test(val)) return 'integer'; + return 'string'; +} + // F1/F3: build class="compound" for strings that mix literal text with {VarName} tokens. function buildCompoundValue(val: string, indent: string): string { const i = `${indent} `; @@ -423,7 +456,11 @@ function buildArgumentValue(key: string, val: string, indent: string, inNamedVal return `${indent}`; } } - return `${indent}${escapeXmlContent(val)}`; + // PDX-493 (H3): infer valueClass for date / datetime / boolean / integer / string. The + // `fieldTypeHint` parameter on `inferSalesforceValueClass` is intentionally not threaded + // through here yet — it lands in PDX-492 (H2b) along with the `field_type_hints` tool input. + const inferred = inferSalesforceValueClass(key, val); + return `${indent}${escapeXmlContent(val)}`; } function buildArgumentsXml(attributes: Record, baseIndent = ' ', apiId = ''): string { diff --git a/test/unit/mcp/testCaseGenerate.test.ts b/test/unit/mcp/testCaseGenerate.test.ts index 95c7579b..66460f05 100644 --- a/test/unit/mcp/testCaseGenerate.test.ts +++ b/test/unit/mcp/testCaseGenerate.test.ts @@ -11,7 +11,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, it, beforeEach, afterEach } from 'mocha'; -import { registerTestCaseGenerate } from '../../../src/mcp/tools/testCaseGenerate.js'; +import { registerTestCaseGenerate, inferSalesforceValueClass } from '../../../src/mcp/tools/testCaseGenerate.js'; import type { ServerConfig } from '../../../src/mcp/server.js'; // ── Minimal McpServer mock ───────────────────────────────────────────────────── @@ -699,6 +699,189 @@ describe('provar_testcase_generate', () => { }); }); + // PDX-493 (H3): date/datetime/boolean/integer valueClass dispatch via inferSalesforceValueClass. + describe('PDX-493 — inferSalesforceValueClass helper', () => { + it('returns "datetime" for ISO-8601 datetime string', () => { + assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19T10:30:00'), 'datetime'); + }); + + it('returns "datetime" for ISO-8601 datetime with fractional seconds + zone', () => { + assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19T10:30:00.123Z'), 'datetime'); + }); + + it('returns "date" for ISO-8601 date string', () => { + assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19'), 'date'); + }); + + it('returns "boolean" for "true"', () => { + assert.equal(inferSalesforceValueClass('IsActive', 'true'), 'boolean'); + }); + + it('returns "boolean" for "false"', () => { + assert.equal(inferSalesforceValueClass('IsActive', 'false'), 'boolean'); + }); + + it('returns "integer" for positive integer string', () => { + assert.equal(inferSalesforceValueClass('Quantity', '42'), 'integer'); + }); + + it('returns "integer" for negative integer string', () => { + assert.equal(inferSalesforceValueClass('Delta', '-5'), 'integer'); + }); + + it('returns "string" for plain text', () => { + assert.equal(inferSalesforceValueClass('Name', 'Acme Corp'), 'string'); + }); + + it('returns "integer" not "date" for a short numeric string like "12"', () => { + // Edge case: the date regex requires the full ISO yyyy-mm-dd form, so a bare "12" + // is integer, not date. Guards against false-positive date detection on numeric IDs. + assert.equal(inferSalesforceValueClass('Code', '12'), 'integer'); + }); + + it('returns "string" for date-looking strings that miss the ISO format', () => { + // Confirms the regex is strict: month/day shape matters. + assert.equal(inferSalesforceValueClass('CloseDate', '2026/05/19'), 'string'); + assert.equal(inferSalesforceValueClass('CloseDate', '05-19-2026'), 'string'); + }); + + it('explicit fieldTypeHint wins over format detection', () => { + // Value looks like a string but the hint says it's a date — hint wins. + assert.equal(inferSalesforceValueClass('CloseDate', 'today', 'date'), 'date'); + // Value looks like a date but the hint says string — hint wins (e.g. an external + // ID that happens to look like a date). + assert.equal(inferSalesforceValueClass('ExternalId', '2026-05-19', 'string'), 'string'); + // Value is integer, hint says boolean — hint wins. + assert.equal(inferSalesforceValueClass('IsActive', '1', 'boolean'), 'boolean'); + }); + }); + + describe('PDX-493 — valueClass emission in generated XML', () => { + it('emits valueClass="date" for an ISO-8601 date string', () => { + const result = server.call('provar_testcase_generate', { + test_case_name: 'DateField', + steps: [ + { + api_id: 'ApexCreateObject', + name: 'Create Opp', + attributes: { CloseDate: '2026-05-19' }, + }, + ], + dry_run: true, + overwrite: false, + }); + + const xml = parseText(result)['xml_content'] as string; + assert.ok( + xml.includes('valueClass="date">2026-05-19'), + `Expected valueClass="date" for ISO date; got: ${xml}` + ); + }); + + it('emits valueClass="datetime" for an ISO-8601 datetime string', () => { + const result = server.call('provar_testcase_generate', { + test_case_name: 'DatetimeField', + steps: [ + { + api_id: 'ApexCreateObject', + name: 'Create Event', + attributes: { StartTime: '2026-05-19T10:30:00' }, + }, + ], + dry_run: true, + overwrite: false, + }); + + const xml = parseText(result)['xml_content'] as string; + assert.ok( + xml.includes('valueClass="datetime">2026-05-19T10:30:00'), + `Expected valueClass="datetime" for ISO datetime; got: ${xml}` + ); + }); + + it('emits valueClass="boolean" for "true" / "false" literals', () => { + const result = server.call('provar_testcase_generate', { + test_case_name: 'BoolField', + steps: [ + { + api_id: 'ApexCreateObject', + name: 'Create Account', + attributes: { IsActive: 'true', IsDeleted: 'false' }, + }, + ], + dry_run: true, + overwrite: false, + }); + + const xml = parseText(result)['xml_content'] as string; + assert.ok( + xml.includes('valueClass="boolean">true'), + `Expected valueClass="boolean" for "true"; got: ${xml}` + ); + assert.ok( + xml.includes('valueClass="boolean">false'), + `Expected valueClass="boolean" for "false"; got: ${xml}` + ); + }); + + it('emits valueClass="integer" for an integer-only string', () => { + const result = server.call('provar_testcase_generate', { + test_case_name: 'IntField', + steps: [ + { + api_id: 'ApexCreateObject', + name: 'Create Opp', + attributes: { Quantity: '42' }, + }, + ], + dry_run: true, + overwrite: false, + }); + + const xml = parseText(result)['xml_content'] as string; + assert.ok(xml.includes('valueClass="integer">42'), `Expected valueClass="integer" for "42"; got: ${xml}`); + }); + + it('emits valueClass="integer" for a negative integer string', () => { + const result = server.call('provar_testcase_generate', { + test_case_name: 'NegIntField', + steps: [ + { + api_id: 'ApexCreateObject', + name: 'Create Adjustment', + attributes: { Delta: '-5' }, + }, + ], + dry_run: true, + overwrite: false, + }); + + const xml = parseText(result)['xml_content'] as string; + assert.ok(xml.includes('valueClass="integer">-5'), `Expected valueClass="integer" for "-5"; got: ${xml}`); + }); + + it('emits valueClass="string" for plain text', () => { + const result = server.call('provar_testcase_generate', { + test_case_name: 'StringField', + steps: [ + { + api_id: 'ApexCreateObject', + name: 'Create Account', + attributes: { Name: 'Acme Corp' }, + }, + ], + dry_run: true, + overwrite: false, + }); + + const xml = parseText(result)['xml_content'] as string; + assert.ok( + xml.includes('valueClass="string">Acme Corp'), + `Expected valueClass="string" for "Acme Corp"; got: ${xml}` + ); + }); + }); + describe('target_uri — non-SF page object (ui:) nesting', () => { it('wraps steps in UiWithScreen when target_uri uses ?pageId= format', () => { const result = server.call('provar_testcase_generate', { From 8def6af8fe98d29c02816bb218f5c02df1ee095a Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Wed, 20 May 2026 07:19:53 -0500 Subject: [PATCH 2/2] =?UTF-8?q?PDX-493:=20fix(mcp)=20=E2=80=94=20align=20n?= =?UTF-8?q?umeric=20valueClass=20with=20canonical=20reference=20(integer?= =?UTF-8?q?=20=E2=86=92=20decimal)=20+=20anchor=20datetime=20regex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RCA: The H3 plan AC specified `valueClass="integer"` for integer-only strings, but `docs/PROVAR_TEST_STEP_REFERENCE.md` lines 1338 and 1428 are explicit: numbers in Provar test steps use `valueClass="decimal"` (covering both `42` and `3.14`). No `integer` valueClass exists in the canonical reference grammar, so the initial H3 implementation would have emitted XML that diverges from the Provar runtime contract for every integer field — silently produced bad XML for any numeric Salesforce field. Separately, the datetime detection regex `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/` was not end-anchored, so trailing garbage after seconds (e.g. `2026-05-19T10:30:00not-a-zone`) was accepted as a valid datetime and emitted as `valueClass="datetime"`. The canonical reference permits optional fractional seconds and timezone — the regex must enforce that shape explicitly. Fix: In `inferSalesforceValueClass` (src/mcp/tools/testCaseGenerate.ts): - Replace the `'integer'` arm of the return-type union with `'decimal'`. Both the parameter and return type now use the canonical-reference set: `'date' | 'datetime' | 'boolean' | 'decimal' | 'string'`. - Broaden the numeric regex from `/^-?\d+$/` to `/^-?\d+(\.\d+)?$/` so it matches integers and decimals; return `'decimal'` for both. - Anchor the datetime regex end with explicit optional fractional-seconds and timezone groups: `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/`. - Update doc-comment to spell out the canonical reference (lines 1338/1428). Update tool description text (line ~155) to say "numeric string → decimal" and explicitly call out that there is no separate `integer` valueClass. Update `docs/mcp.md` argument-conventions table row to match (one merged numeric row covering `42`, `-5`, `3.14`). Tests: - Rewrite `integer` assertions in test/unit/mcp/testCaseGenerate.test.ts to `decimal` (positive int, negative int, short numeric string). - Add positive coverage for decimals (`3.14`, `-12.5`) at both helper and XML-emission levels. - Add datetime end-anchor coverage (numeric timezone offset accepted; trailing garbage rejected as `string`). Addresses Copilot review feedback on PR #183. --- docs/mcp.md | 26 +++++----- src/mcp/tools/testCaseGenerate.ts | 26 ++++++---- test/unit/mcp/testCaseGenerate.test.ts | 68 +++++++++++++++++++++----- 3 files changed, 84 insertions(+), 36 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 4068f543..1be19505 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -729,19 +729,19 @@ The tool's chip-level `title` — `Generate Test Case (full steps in one call)` **Argument XML conventions** (automatically applied by the generator): -| Argument key / value pattern | Emitted XML class | API context | -| --------------------------------------- | ------------------------------------- | ----------------------- | -| `target` key | `class="uiTarget"` | UiWithScreen, UiWithRow | -| `locator` key | `class="uiLocator"` | UiDoAction, UiAssert | -| Value matches `{VarName}` or `{A.B}` | `class="variable"` + `` | Any step | -| SetValues attributes | `class="valueList"/` | SetValues only | -| Value `YYYY-MM-DDTHH:MM:SS…` (ISO-8601) | `class="value" valueClass="datetime"` | Any step | -| Value `YYYY-MM-DD` (ISO-8601) | `class="value" valueClass="date"` | Any step | -| Value `true` / `false` | `class="value" valueClass="boolean"` | Any step | -| Value matches `^-?\d+$` | `class="value" valueClass="integer"` | Any step | -| All other values | `class="value" valueClass="string"` | Any step | - -`valueClass` is inferred automatically by `inferSalesforceValueClass(key, val, fieldTypeHint?)`. Detection order: explicit `fieldTypeHint` (wired in a follow-up tool surface — `field_type_hints` param) → ISO-8601 datetime → ISO-8601 date → boolean → integer → string. Provar runtime silently discards date fields emitted as `valueClass="string"`, so always pass date / datetime values in ISO-8601 form. +| Argument key / value pattern | Emitted XML class | API context | +| ---------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------- | +| `target` key | `class="uiTarget"` | UiWithScreen, UiWithRow | +| `locator` key | `class="uiLocator"` | UiDoAction, UiAssert | +| Value matches `{VarName}` or `{A.B}` | `class="variable"` + `` | Any step | +| SetValues attributes | `class="valueList"/` | SetValues only | +| Value `YYYY-MM-DDTHH:MM:SS` + optional `.fff` + optional `Z`/`±HH:MM` (ISO-8601, end-anchored) | `class="value" valueClass="datetime"` | Any step | +| Value `YYYY-MM-DD` (ISO-8601) | `class="value" valueClass="date"` | Any step | +| Value `true` / `false` | `class="value" valueClass="boolean"` | Any step | +| Numeric value `^-?\d+(\.\d+)?$` (`42`, `-5`, `3.14`) | `class="value" valueClass="decimal"` | Any step | +| All other values | `class="value" valueClass="string"` | Any step | + +`valueClass` is inferred automatically by `inferSalesforceValueClass(key, val, fieldTypeHint?)`. Detection order: explicit `fieldTypeHint` (wired in a follow-up tool surface — `field_type_hints` param) → ISO-8601 datetime (with optional fractional seconds and timezone, end-anchored) → ISO-8601 date → boolean → decimal → string. Per the canonical Provar reference numbers always emit as `valueClass="decimal"` (there is no separate `integer` valueClass). Provar runtime silently discards date fields emitted as `valueClass="string"`, so always pass date / datetime values in ISO-8601 form. AssertValues uses **flat** argument structure (`expectedValue`, `actualValue`, `comparisonType`) — not the `valueList`/namedValues format. diff --git a/src/mcp/tools/testCaseGenerate.ts b/src/mcp/tools/testCaseGenerate.ts index 244533a9..cea87f73 100644 --- a/src/mcp/tools/testCaseGenerate.ts +++ b/src/mcp/tools/testCaseGenerate.ts @@ -151,9 +151,10 @@ const TOOL_DESCRIPTION = [ 'target argument (UiWithScreen/UiWithRow): pass the URI value; emitted as class="uiTarget" uri="...".', 'locator argument (UiDoAction/UiAssert): pass the URI value; emitted as class="uiLocator" uri="...".', 'valueClass auto-detection: argument values are typed automatically before XML emission. ' + - 'ISO-8601 date "YYYY-MM-DD" → valueClass="date"; ISO-8601 datetime "YYYY-MM-DDTHH:MM:SS" → "datetime"; ' + - '"true"/"false" → "boolean"; integer-only string (e.g. "42", "-5") → "integer"; otherwise "string". ' + - 'Pass dates / booleans / integers in those formats — Provar runtime silently discards date fields emitted as valueClass="string".', + 'ISO-8601 date "YYYY-MM-DD" → valueClass="date"; ISO-8601 datetime "YYYY-MM-DDTHH:MM:SS" (optional fractional seconds + timezone) → "datetime"; ' + + '"true"/"false" → "boolean"; numeric string (e.g. "42", "-5", "3.14") → "decimal"; otherwise "string". ' + + 'Pass dates / booleans / numbers in those formats — Provar runtime silently discards date fields emitted as valueClass="string". ' + + 'Note: numbers always emit valueClass="decimal" per the canonical Provar reference (there is no separate "integer" valueClass).', 'Edit page objects: action=Edit targets require a compiled page object for the SF object. ' + 'If none exists in the project page-objects directory, the locator binding will fail at runtime. ' + 'For objects without a compiled Edit page object, use inline edit instead: sfIleActivate to activate the field, ' + @@ -384,10 +385,15 @@ export function registerTestCaseGenerate(server: McpServer, config: ServerConfig // Detection order: // 1. Explicit `fieldTypeHint` (from `field_type_hints` param or `provar_org_describe` cache — wired // in PDX-492 H2b) wins if provided. -// 2. ISO-8601 datetime → 'datetime' (e.g. "2026-05-19T10:30:00", with optional fractional/zone). +// 2. ISO-8601 datetime → 'datetime' (e.g. "2026-05-19T10:30:00", with optional fractional seconds +// and optional timezone). The regex is end-anchored so trailing garbage (e.g. +// "2026-05-19T10:30:00not-a-zone") is rejected as plain 'string'. // 3. ISO-8601 date → 'date' (e.g. "2026-05-19"). // 4. Literal 'true' / 'false' → 'boolean'. -// 5. Integer-only string (optional leading '-') → 'integer'. +// 5. Numeric string (integer or decimal, optional leading '-') → 'decimal'. +// Per `docs/PROVAR_TEST_STEP_REFERENCE.md` (lines 1338, 1428) the canonical Provar +// valueClass for numbers is `decimal` — there is no `integer` valueClass in the +// reference grammar, so both `42` and `3.14` emit as `valueClass="decimal"`. // 6. Else → 'string'. // // The `key` argument is reserved for future heuristics (e.g. SF naming conventions like *__c) @@ -397,13 +403,13 @@ export function inferSalesforceValueClass( // eslint-disable-next-line @typescript-eslint/no-unused-vars key: string, val: string, - fieldTypeHint?: 'date' | 'datetime' | 'boolean' | 'integer' | 'string' -): 'date' | 'datetime' | 'boolean' | 'integer' | 'string' { + fieldTypeHint?: 'date' | 'datetime' | 'boolean' | 'decimal' | 'string' +): 'date' | 'datetime' | 'boolean' | 'decimal' | 'string' { if (fieldTypeHint) return fieldTypeHint; - if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) return 'datetime'; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/.test(val)) return 'datetime'; if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return 'date'; if (val === 'true' || val === 'false') return 'boolean'; - if (/^-?\d+$/.test(val)) return 'integer'; + if (/^-?\d+(\.\d+)?$/.test(val)) return 'decimal'; return 'string'; } @@ -456,7 +462,7 @@ function buildArgumentValue(key: string, val: string, indent: string, inNamedVal return `${indent}`; } } - // PDX-493 (H3): infer valueClass for date / datetime / boolean / integer / string. The + // PDX-493 (H3): infer valueClass for date / datetime / boolean / decimal / string. The // `fieldTypeHint` parameter on `inferSalesforceValueClass` is intentionally not threaded // through here yet — it lands in PDX-492 (H2b) along with the `field_type_hints` tool input. const inferred = inferSalesforceValueClass(key, val); diff --git a/test/unit/mcp/testCaseGenerate.test.ts b/test/unit/mcp/testCaseGenerate.test.ts index 66460f05..7c286e1a 100644 --- a/test/unit/mcp/testCaseGenerate.test.ts +++ b/test/unit/mcp/testCaseGenerate.test.ts @@ -699,7 +699,9 @@ describe('provar_testcase_generate', () => { }); }); - // PDX-493 (H3): date/datetime/boolean/integer valueClass dispatch via inferSalesforceValueClass. + // PDX-493 (H3): date/datetime/boolean/decimal valueClass dispatch via inferSalesforceValueClass. + // Numbers emit `valueClass="decimal"` per canonical reference (PROVAR_TEST_STEP_REFERENCE.md + // lines 1338, 1428) — there is no `integer` valueClass in the Provar grammar. describe('PDX-493 — inferSalesforceValueClass helper', () => { it('returns "datetime" for ISO-8601 datetime string', () => { assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19T10:30:00'), 'datetime'); @@ -709,6 +711,17 @@ describe('provar_testcase_generate', () => { assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19T10:30:00.123Z'), 'datetime'); }); + it('returns "datetime" for ISO-8601 datetime with numeric timezone offset', () => { + assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19T10:30:00+05:30'), 'datetime'); + assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19T10:30:00-0800'), 'datetime'); + }); + + it('returns "string" for datetime-looking values with trailing garbage (end-anchored)', () => { + // Guards against the un-anchored regex bug: trailing junk after seconds must not + // be silently accepted as datetime. + assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19T10:30:00not-a-zone'), 'string'); + }); + it('returns "date" for ISO-8601 date string', () => { assert.equal(inferSalesforceValueClass('CloseDate', '2026-05-19'), 'date'); }); @@ -721,22 +734,30 @@ describe('provar_testcase_generate', () => { assert.equal(inferSalesforceValueClass('IsActive', 'false'), 'boolean'); }); - it('returns "integer" for positive integer string', () => { - assert.equal(inferSalesforceValueClass('Quantity', '42'), 'integer'); + it('returns "decimal" for positive integer string', () => { + assert.equal(inferSalesforceValueClass('Quantity', '42'), 'decimal'); + }); + + it('returns "decimal" for negative integer string', () => { + assert.equal(inferSalesforceValueClass('Delta', '-5'), 'decimal'); }); - it('returns "integer" for negative integer string', () => { - assert.equal(inferSalesforceValueClass('Delta', '-5'), 'integer'); + it('returns "decimal" for positive decimal string', () => { + assert.equal(inferSalesforceValueClass('Amount', '3.14'), 'decimal'); + }); + + it('returns "decimal" for negative decimal string', () => { + assert.equal(inferSalesforceValueClass('Adjustment', '-12.5'), 'decimal'); }); it('returns "string" for plain text', () => { assert.equal(inferSalesforceValueClass('Name', 'Acme Corp'), 'string'); }); - it('returns "integer" not "date" for a short numeric string like "12"', () => { + it('returns "decimal" not "date" for a short numeric string like "12"', () => { // Edge case: the date regex requires the full ISO yyyy-mm-dd form, so a bare "12" - // is integer, not date. Guards against false-positive date detection on numeric IDs. - assert.equal(inferSalesforceValueClass('Code', '12'), 'integer'); + // is decimal, not date. Guards against false-positive date detection on numeric IDs. + assert.equal(inferSalesforceValueClass('Code', '12'), 'decimal'); }); it('returns "string" for date-looking strings that miss the ISO format', () => { @@ -751,7 +772,7 @@ describe('provar_testcase_generate', () => { // Value looks like a date but the hint says string — hint wins (e.g. an external // ID that happens to look like a date). assert.equal(inferSalesforceValueClass('ExternalId', '2026-05-19', 'string'), 'string'); - // Value is integer, hint says boolean — hint wins. + // Value is decimal, hint says boolean — hint wins. assert.equal(inferSalesforceValueClass('IsActive', '1', 'boolean'), 'boolean'); }); }); @@ -824,7 +845,7 @@ describe('provar_testcase_generate', () => { ); }); - it('emits valueClass="integer" for an integer-only string', () => { + it('emits valueClass="decimal" for an integer-only string', () => { const result = server.call('provar_testcase_generate', { test_case_name: 'IntField', steps: [ @@ -839,10 +860,10 @@ describe('provar_testcase_generate', () => { }); const xml = parseText(result)['xml_content'] as string; - assert.ok(xml.includes('valueClass="integer">42'), `Expected valueClass="integer" for "42"; got: ${xml}`); + assert.ok(xml.includes('valueClass="decimal">42'), `Expected valueClass="decimal" for "42"; got: ${xml}`); }); - it('emits valueClass="integer" for a negative integer string', () => { + it('emits valueClass="decimal" for a negative integer string', () => { const result = server.call('provar_testcase_generate', { test_case_name: 'NegIntField', steps: [ @@ -857,7 +878,28 @@ describe('provar_testcase_generate', () => { }); const xml = parseText(result)['xml_content'] as string; - assert.ok(xml.includes('valueClass="integer">-5'), `Expected valueClass="integer" for "-5"; got: ${xml}`); + assert.ok(xml.includes('valueClass="decimal">-5'), `Expected valueClass="decimal" for "-5"; got: ${xml}`); + }); + + it('emits valueClass="decimal" for a positive decimal string', () => { + const result = server.call('provar_testcase_generate', { + test_case_name: 'DecimalField', + steps: [ + { + api_id: 'ApexCreateObject', + name: 'Create Opp', + attributes: { Amount: '3.14' }, + }, + ], + dry_run: true, + overwrite: false, + }); + + const xml = parseText(result)['xml_content'] as string; + assert.ok( + xml.includes('valueClass="decimal">3.14'), + `Expected valueClass="decimal" for "3.14"; got: ${xml}` + ); }); it('emits valueClass="string" for plain text', () => {