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', {