@provartesting/provardx-cli 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +5 -37
  2. package/lib/commands/provar/automation/project/validate.js +5 -3
  3. package/lib/commands/provar/automation/project/validate.js.map +1 -1
  4. package/lib/mcp/docs/PROVAR_TEST_STEP_REFERENCE.md +269 -79
  5. package/lib/mcp/docs/VALIDATION_RULE_REGISTRY.md +225 -0
  6. package/lib/mcp/prompts/loopPrompts.js +4 -3
  7. package/lib/mcp/prompts/loopPrompts.js.map +1 -1
  8. package/lib/mcp/rules/comparisonTypeSets.d.ts +21 -0
  9. package/lib/mcp/rules/comparisonTypeSets.js +45 -0
  10. package/lib/mcp/rules/comparisonTypeSets.js.map +1 -0
  11. package/lib/mcp/rules/provar_best_practices_rules.json +178 -8
  12. package/lib/mcp/rules/provar_layer1_rules.json +151 -0
  13. package/lib/mcp/rules/provar_test_step_schema.json +3005 -0
  14. package/lib/mcp/server.d.ts +15 -0
  15. package/lib/mcp/server.js +64 -1
  16. package/lib/mcp/server.js.map +1 -1
  17. package/lib/mcp/tools/automationTools.js +38 -1
  18. package/lib/mcp/tools/automationTools.js.map +1 -1
  19. package/lib/mcp/tools/bestPracticesEngine.js +1368 -10
  20. package/lib/mcp/tools/bestPracticesEngine.js.map +1 -1
  21. package/lib/mcp/tools/hierarchyValidate.d.ts +2 -1
  22. package/lib/mcp/tools/hierarchyValidate.js +7 -1
  23. package/lib/mcp/tools/hierarchyValidate.js.map +1 -1
  24. package/lib/mcp/tools/projectValidateFromPath.js +1 -2
  25. package/lib/mcp/tools/projectValidateFromPath.js.map +1 -1
  26. package/lib/mcp/tools/sfSpawn.d.ts +23 -0
  27. package/lib/mcp/tools/sfSpawn.js +72 -1
  28. package/lib/mcp/tools/sfSpawn.js.map +1 -1
  29. package/lib/mcp/tools/testCaseGenerate.js +377 -23
  30. package/lib/mcp/tools/testCaseGenerate.js.map +1 -1
  31. package/lib/mcp/tools/testCaseValidate.d.ts +46 -0
  32. package/lib/mcp/tools/testCaseValidate.js +313 -43
  33. package/lib/mcp/tools/testCaseValidate.js.map +1 -1
  34. package/lib/mcp/tools/testPlanValidate.js +3 -2
  35. package/lib/mcp/tools/testPlanValidate.js.map +1 -1
  36. package/lib/mcp/tools/testSuiteValidate.js +3 -2
  37. package/lib/mcp/tools/testSuiteValidate.js.map +1 -1
  38. package/lib/mcp/tools/uiActionApiIds.d.ts +23 -0
  39. package/lib/mcp/tools/uiActionApiIds.js +46 -0
  40. package/lib/mcp/tools/uiActionApiIds.js.map +1 -0
  41. package/lib/mcp/utils/qualityThreshold.d.ts +8 -0
  42. package/lib/mcp/utils/qualityThreshold.js +42 -0
  43. package/lib/mcp/utils/qualityThreshold.js.map +1 -0
  44. package/lib/mcp/utils/testCaseId.d.ts +23 -0
  45. package/lib/mcp/utils/testCaseId.js +156 -0
  46. package/lib/mcp/utils/testCaseId.js.map +1 -0
  47. package/lib/services/projectValidation.js +2 -1
  48. package/lib/services/projectValidation.js.map +1 -1
  49. package/messages/sf.provar.automation.project.validate.md +1 -1
  50. package/messages/sf.provar.mcp.start.md +1 -0
  51. package/oclif.manifest.json +4 -4
  52. package/package.json +4 -2
@@ -12,8 +12,10 @@ import { z } from 'zod';
12
12
  import { assertPathAllowed, PathPolicyError } from '../security/pathPolicy.js';
13
13
  import { makeError, makeRequestId } from '../schemas/common.js';
14
14
  import { log } from '../logging/logger.js';
15
+ import { allocateTestCaseId, DEFAULT_TESTCASE_ID } from '../utils/testCaseId.js';
15
16
  import { validateTestCase } from './testCaseValidate.js';
16
17
  import { desc } from './descHelper.js';
18
+ import { UI_ACTION_API_IDS, UI_SCREEN_CONTAINER_API_IDS, UI_LOCATOR_BEARING_API_IDS } from './uiActionApiIds.js';
17
19
  // ── Shorthand → fully-qualified API ID map ────────────────────────────────────
18
20
  // Provar runtime requires fully-qualified IDs. Shorthand forms are accepted here
19
21
  // and expanded automatically before writing XML.
@@ -22,8 +24,11 @@ const SHORTHAND_TO_FQID = {
22
24
  UiDoAction: 'com.provar.plugins.forcedotcom.core.ui.UiDoAction',
23
25
  UiWithScreen: 'com.provar.plugins.forcedotcom.core.ui.UiWithScreen',
24
26
  UiAssert: 'com.provar.plugins.forcedotcom.core.ui.UiAssert',
27
+ UiRead: 'com.provar.plugins.forcedotcom.core.ui.UiRead',
28
+ UiFill: 'com.provar.plugins.forcedotcom.core.ui.UiFill',
25
29
  UiNavigate: 'com.provar.plugins.forcedotcom.core.ui.UiNavigate',
26
30
  UiWithRow: 'com.provar.plugins.forcedotcom.core.ui.UiWithRow',
31
+ UiHandleAlert: 'com.provar.plugins.forcedotcom.core.ui.UiHandleAlert',
27
32
  UiScrollToElement: 'com.provar.plugins.forcedotcom.core.ui.UiScrollToElement',
28
33
  ApexConnect: 'com.provar.plugins.forcedotcom.core.testapis.ApexConnect',
29
34
  ApexSoqlQuery: 'com.provar.plugins.forcedotcom.core.testapis.ApexSoqlQuery',
@@ -37,10 +42,58 @@ const SHORTHAND_TO_FQID = {
37
42
  Sleep: 'com.provar.plugins.bundled.apis.control.Sleep',
38
43
  ForEach: 'com.provar.plugins.bundled.apis.control.ForEach',
39
44
  CaseCall: 'com.provar.plugins.bundled.apis.control.CaseCall',
45
+ // NitroX MS variants (Microsoft Dynamics 365 + Power Platform — Provar 3.0.7+)
46
+ MSDynamics365Connect: 'com.provar.plugins.forcedotcom.core.ui.NitroXConnect:ms-dynamics365',
47
+ MSDataverseConnect: 'com.provar.plugins.forcedotcom.core.ui.NitroXConnect:ms-dataverse',
48
+ MSPowerAppConnect: 'com.provar.plugins.forcedotcom.core.ui.NitroXConnect:ms-powerapp',
49
+ MSPowerPageConnect: 'com.provar.plugins.forcedotcom.core.ui.NitroXConnect:ms-powerpage',
40
50
  };
51
+ const NITROX_MS_SHORTHANDS = new Set([
52
+ 'MSDynamics365Connect',
53
+ 'MSDataverseConnect',
54
+ 'MSPowerAppConnect',
55
+ 'MSPowerPageConnect',
56
+ ]);
41
57
  function resolveApiId(apiId) {
42
58
  return SHORTHAND_TO_FQID[apiId] ?? apiId;
43
59
  }
60
+ // ── PDX-495 + PDX-497: UI-action grouping under UiWithScreen substeps clause ─
61
+ // The set of fully-qualified API IDs that authors expect to be nested inside a
62
+ // preceding UiWithScreen's <clauses><clause name="substeps"><steps>…</steps></clause>
63
+ // block. When the generator receives a flat list with these IDs trailing a
64
+ // UiWithScreen, the auto-grouping pass moves them inside the substeps clause so
65
+ // the Provar IDE renders the test case correctly. SetValues, ApexConnect, and
66
+ // other non-UI apiCalls stay at the root.
67
+ //
68
+ // PDX-497: API set imported from the shared `uiActionApiIds.ts` module so the
69
+ // generator and validator can never drift. The single-namespace alignment
70
+ // (`com.provar.plugins.forcedotcom.core.ui.*`) matches what `resolveApiId`
71
+ // produces from every shorthand AND what the validator enforces — the older
72
+ // `com.provar.plugins.ui.*` defensive entries in this file's local set were
73
+ // dead code (no test coverage, no production path emits them).
74
+ const FORCEDOTCOM_UI_WITH_SCREEN = 'com.provar.plugins.forcedotcom.core.ui.UiWithScreen';
75
+ function isUiAction(apiId) {
76
+ return UI_ACTION_API_IDS.has(resolveApiId(apiId));
77
+ }
78
+ function isUiWithScreen(apiId) {
79
+ return resolveApiId(apiId) === FORCEDOTCOM_UI_WITH_SCREEN;
80
+ }
81
+ /**
82
+ * PDX-497: a UI action step whose own container clause satisfies the
83
+ * UI-NEST-STRUCT-001 rule for its descendants (UiWithScreen, UiWithRow).
84
+ * Mirrors the validator's `UI_SCREEN_CONTAINERS` set.
85
+ */
86
+ function isScreenContainer(apiId) {
87
+ return UI_SCREEN_CONTAINER_API_IDS.has(resolveApiId(apiId));
88
+ }
89
+ // PDX-497: UiWithRow plays a dual role. As a UI action (in UI_ACTION_API_IDS)
90
+ // it must be nested under a UiWithScreen ancestor via a substeps clause — same
91
+ // rule QH's UI-NEST-STRUCT-001 enforces. As a screen container (in
92
+ // UI_SCREEN_CONTAINER_API_IDS) it owns its OWN substeps clause that satisfies
93
+ // the rule for its descendants. The auto-grouping algorithm in `collectGroup`
94
+ // (below) handles both roles, and `buildTestCaseXml` synthesizes a root
95
+ // UiWithScreen when the payload contains screen containers but no UiWithScreen
96
+ // — without that wrapper, a root-level UiWithRow would itself fail the rule.
44
97
  // ── Per-step runtime warnings ─────────────────────────────────────────────────
45
98
  function buildStepWarnings(steps) {
46
99
  const warnings = [];
@@ -55,6 +108,13 @@ function buildStepWarnings(steps) {
55
108
  'To assert SOQL results use either: (a) a ForEach loop over the result list with AssertValues inside, ' +
56
109
  'or (b) a SetValues step to extract a specific field into a named variable, then assert that variable.');
57
110
  }
111
+ const nitroxMsFqids = new Set(Array.from(NITROX_MS_SHORTHANDS, (s) => SHORTHAND_TO_FQID[s]).filter((id) => Boolean(id)));
112
+ if (resolvedIds.some((id) => nitroxMsFqids.has(id))) {
113
+ warnings.push('NitroX MS connect (Dynamics 365 / Dataverse / Power Apps / Power Pages): ' +
114
+ 'variant-specific args (appName, powerAppName, environment, powerPageName) must either be supplied as ' +
115
+ 'literals/variables in attributes OR declared as <generatedParameters> for data-driven tests. ' +
116
+ 'Empty args with no parameter declaration cause runtime null binding.');
117
+ }
58
118
  // D7: Cleanup steps placed after a potential failure point are skipped when stopOnError=false.
59
119
  if (resolvedIds.includes(SHORTHAND_TO_FQID['ApexDeleteObject'] ?? '')) {
60
120
  warnings.push('ApexDeleteObject detected (likely cleanup): with stopOnError=false Provar skips all steps after ' +
@@ -70,9 +130,11 @@ const StepSchema = z.object({
70
130
  api_id: z
71
131
  .string()
72
132
  .describe('Provar step API ID. Shorthand forms are accepted and auto-expanded to fully-qualified IDs: ' +
73
- 'UiConnect, UiDoAction, UiWithScreen, UiAssert, UiNavigate, UiWithRow, ' +
133
+ 'UiConnect, UiDoAction, UiWithScreen, UiAssert, UiRead, UiFill, UiNavigate, UiWithRow, UiHandleAlert, ' +
74
134
  'ApexConnect, ApexSoqlQuery, ApexCreateObject, ApexReadObject, ApexUpdateObject, ApexDeleteObject, ' +
75
- 'SetValues, AssertValues, StepGroup, Sleep, ForEach, CaseCall. ' +
135
+ 'SetValues, AssertValues, StepGroup, Sleep, ForEach, CaseCall, ' +
136
+ 'MSDynamics365Connect, MSDataverseConnect, MSPowerAppConnect, MSPowerPageConnect ' +
137
+ '(NitroXConnect:ms-* family for Microsoft Dynamics 365 + Power Platform — Provar 3.0.7+). ' +
76
138
  'Or pass the fully-qualified ID directly (com.provar.plugins.*).'),
77
139
  name: z.string().describe('Human-readable step name'),
78
140
  attributes: z
@@ -89,7 +151,7 @@ const StepSchema = z.object({
89
151
  '(3) AssertValues: pass assertion arguments as flat key/value pairs; emitted as flat <argument> elements, NOT wrapped in valueList/namedValues. ' +
90
152
  '(4) target argument (UiWithScreen / UiWithRow): pass the sf:ui:target or ui:pageobject:target URI; ' +
91
153
  ' emitted as class="uiTarget" uri="...". ' +
92
- '(5) locator argument (UiDoAction / UiAssert): pass the locator URI; emitted as class="uiLocator" uri="...". ' +
154
+ '(5) locator argument (UiDoAction / UiAssert / UiRead / UiFill): pass the locator URI; emitted as class="uiLocator" uri="...". ' +
93
155
  'All other string values use class="value" valueClass="string".'),
94
156
  });
95
157
  const TOOL_DESCRIPTION = [
@@ -104,11 +166,14 @@ const TOOL_DESCRIPTION = [
104
166
  // ── Existing description (unchanged below) ───────────────────────────────────
105
167
  'Generate a Provar XML test case skeleton with proper UUID v4 guids, sequential testItemId values, and <steps> structure.',
106
168
  'Returns XML content. Writes to disk only when dry_run=false.',
107
- 'Generated structure: <?xml version="1.0" encoding="UTF-8" standalone="no"?> with <testCase guid="..." id="1" registryId="..."> (id is always the integer literal "1" as required by the Provar runtime), a <summary/> child, then <steps>.',
169
+ 'Generated structure: <?xml version="1.0" encoding="UTF-8" standalone="no"?> with <testCase guid="..." id="N" registryId="..."> (a numeric integer id), a <summary/> child, then <steps>. The unique identifier is the guid; id is a human-facing label. When writing into an existing project the id is auto-allocated as the highest in-use id + 1; otherwise it defaults to 1.',
108
170
  'URI-aware generation: use target_uri to control the XML nesting structure.',
109
171
  ' - sf:ui:target (or omit target_uri) → flat Salesforce XML structure (existing behaviour).',
110
172
  ' - ui:pageobject:target?pageId=pageobjects.PageClass → wraps all steps in a UiWithScreen element targeting that non-SF page object.',
111
173
  'API IDs: shorthand forms (e.g. UiConnect, ApexSoqlQuery) are automatically expanded to fully-qualified IDs required by the Provar runtime.',
174
+ 'Microsoft Dynamics / Power Platform: MSDynamics365Connect, MSDataverseConnect, MSPowerAppConnect, MSPowerPageConnect ' +
175
+ 'expand to NitroXConnect:ms-* variants. Variant-specific args (appName, powerAppName, environment, powerPageName) ' +
176
+ 'may be passed as literals OR declared via <generatedParameters> for data-driven tests.',
112
177
  'Step arguments: attributes are emitted as <arguments><argument id="..."><value .../></argument></arguments> — the only format the Provar runtime processes.',
113
178
  'Shorthand XML attributes on <apiCall> are silently ignored at runtime; always supply arguments via the attributes map.',
114
179
  'ApexSoqlQuery argument IDs: soqlQuery (the SOQL SELECT statement), resultListName (binds result list to a variable), apexConnectionName (named connection), resultScope (optional).',
@@ -124,11 +189,11 @@ const TOOL_DESCRIPTION = [
124
189
  'If AssertValues uses namedValues-shaped content, validation reports warning ASSERT-001.',
125
190
  'Variable references: pass values as "{VarName}" (braces); emitted as class="variable" <path element="VarName"/>.',
126
191
  'target argument (UiWithScreen/UiWithRow): pass the URI value; emitted as class="uiTarget" uri="...".',
127
- 'locator argument (UiDoAction/UiAssert): pass the URI value; emitted as class="uiLocator" uri="...".',
192
+ 'locator argument (UiDoAction/UiAssert/UiRead/UiFill): pass the URI value; emitted as class="uiLocator" uri="...".',
128
193
  'valueClass auto-detection: argument values are typed automatically before XML emission. ' +
129
- 'ISO-8601 date "YYYY-MM-DD" → valueClass="date"; ISO-8601 datetime "YYYY-MM-DDTHH:MM:SS" (optional fractional seconds + timezone) → "datetime"; ' +
194
+ 'ISO-8601 date "YYYY-MM-DD" → valueClass="date"; ISO-8601 datetime "YYYY-MM-DDTHH:MM:SS" (optional fractional seconds + timezone) → "dateTime"; ' +
130
195
  '"true"/"false" → "boolean"; numeric string (e.g. "42", "-5", "3.14") → "decimal"; otherwise "string". ' +
131
- 'Pass dates / booleans / numbers in those formats Provar runtime silently discards date fields emitted as valueClass="string". ' +
196
+ 'Pass dates in ISO-8601 the generator converts them to the epoch-millisecond value Provar requires (a date/dateTime field emitted as an ISO string, or as valueClass="string", fails to load in the IDE). A bare date is treated as UTC midnight; a datetime without a timezone is treated as UTC. ' +
132
197
  'Note: numbers always emit valueClass="decimal" per the canonical Provar reference (there is no separate "integer" valueClass).',
133
198
  'Edit page objects: action=Edit targets require a compiled page object for the SF object. ' +
134
199
  'If none exists in the project page-objects directory, the locator binding will fail at runtime. ' +
@@ -196,6 +261,20 @@ export function registerTestCaseGenerate(server, config) {
196
261
  .string()
197
262
  .optional()
198
263
  .describe(desc('Caller-provided key echoed back for deduplication tracking', 'string, optional; deduplication key echoed in response')),
264
+ grouping_mode: z
265
+ .enum(['auto', 'flat', 'single-screen'])
266
+ .default('auto')
267
+ .describe(desc('Controls how UI action steps (UiDoAction, UiAssert, UiRead, UiFill, UiNavigate, UiWithRow, UiHandleAlert) ' +
268
+ 'are nested under UiWithScreen wrappers. ' +
269
+ '"auto" (default): when the flat steps[] payload contains a UiWithScreen followed by UI action siblings, ' +
270
+ 'those siblings are auto-grouped inside the UiWithScreen\'s <clause name="substeps"><steps> block ' +
271
+ '(the structure Provar IDE expects). Non-UI steps (SetValues, ApexConnect, …) stay at the root. ' +
272
+ 'UiWithRow plays a dual role: when it follows a UiWithScreen it is pulled in as a child container; ' +
273
+ 'when screen containers such as UiWithRow appear without an explicit preceding UiWithScreen, generation may ' +
274
+ 'synthesize a root UiWithScreen wrapper so they are nested under that screen container rather than remaining at root. ' +
275
+ '"flat": legacy behaviour — emit every step as a root sibling, no nesting. ' +
276
+ '"single-screen": wrap all steps in a single synthetic UiWithScreen (matches target_uri=ui:pageobject:target semantics). ' +
277
+ 'If target_uri is "ui:pageobject:target?…" the single-screen wrap takes precedence regardless of this flag.', 'enum, optional; default "auto" (group UI actions under UiWithScreen substeps)')),
199
278
  },
200
279
  }, (input) => {
201
280
  const requestId = makeRequestId();
@@ -225,11 +304,18 @@ export function registerTestCaseGenerate(server, config) {
225
304
  return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
226
305
  }
227
306
  try {
228
- const xmlContent = buildTestCaseXml(input);
229
307
  const filePath = input.output_path ? path.resolve(input.output_path) : undefined;
230
- let written = false;
308
+ // Allocate the root testCase id from surrounding project context, but only
309
+ // when actually persisting (a write path that has cleared the path policy).
310
+ // Preview/dry-run runs have no project anchor, so they keep the default id.
311
+ let idAllocation = { id: DEFAULT_TESTCASE_ID, basis: 'default' };
231
312
  if (filePath && !input.dry_run) {
232
313
  assertPathAllowed(filePath, config.allowedPaths);
314
+ idAllocation = allocateTestCaseId(filePath, config.allowedPaths);
315
+ }
316
+ const xmlContent = buildTestCaseXml(input, idAllocation.id);
317
+ let written = false;
318
+ if (filePath && !input.dry_run) {
233
319
  if (fs.existsSync(filePath) && !input.overwrite) {
234
320
  const err = makeError('FILE_EXISTS', `File already exists: ${filePath}. Set overwrite=true to replace.`, requestId);
235
321
  return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
@@ -237,7 +323,12 @@ export function registerTestCaseGenerate(server, config) {
237
323
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
238
324
  fs.writeFileSync(filePath, xmlContent, 'utf-8');
239
325
  written = true;
240
- log('info', 'provar_testcase_generate: wrote file', { requestId, filePath });
326
+ log('info', 'provar_testcase_generate: wrote file', {
327
+ requestId,
328
+ filePath,
329
+ testCaseId: idAllocation.id,
330
+ idBasis: idAllocation.basis,
331
+ });
241
332
  }
242
333
  const warnings = buildStepWarnings(input.steps);
243
334
  const runValidation = input.validate_after_edit !== false;
@@ -248,6 +339,7 @@ export function registerTestCaseGenerate(server, config) {
248
339
  written,
249
340
  dry_run: input.dry_run,
250
341
  step_count: input.steps.length,
342
+ test_case_id: idAllocation.id,
251
343
  idempotency_key: input.idempotency_key,
252
344
  ...(warnings.length > 0 ? { warnings } : {}),
253
345
  };
@@ -363,20 +455,56 @@ function buildArgumentValue(key, val, indent, inNamedValues = false, apiId = '')
363
455
  }
364
456
  if (!inNamedValues) {
365
457
  // D2: 'target' argument → class="uiTarget" (only for UiWithScreen / UiWithRow).
366
- if (key === 'target' && (apiId.includes('UiWithScreen') || apiId.includes('UiWithRow'))) {
458
+ // PDX-497: dispatched via the shared canonical set rather than substring matching.
459
+ if (key === 'target' && UI_SCREEN_CONTAINER_API_IDS.has(apiId)) {
367
460
  return `${indent}<value class="uiTarget" uri="${escapeXmlAttr(val)}"/>`;
368
461
  }
369
- // D2: 'locator' argument → class="uiLocator" (only for UiDoAction / UiAssert).
370
- if (key === 'locator' && (apiId.includes('UiDoAction') || apiId.includes('UiAssert'))) {
462
+ // D2: 'locator' argument → class="uiLocator" (only for UI APIs that bear a locator).
463
+ // PDX-497: covers UiDoAction, UiAssert, UiRead, UiFill via the shared canonical set.
464
+ if (key === 'locator' && UI_LOCATOR_BEARING_API_IDS.has(apiId)) {
371
465
  return `${indent}<value class="uiLocator" uri="${escapeXmlAttr(val)}"/>`;
372
466
  }
467
+ // D2: 'interaction' argument → class="uiInteraction" (UiDoAction Action widget).
468
+ // PDX-506: the IDE step editor binds its Action only from a typed uiInteraction;
469
+ // a plain string runs green from the CLI but renders the Action field blank.
470
+ // Gated on the shared UI-action API set so generator + validator stay aligned.
471
+ if (key === 'interaction' && UI_ACTION_API_IDS.has(apiId)) {
472
+ return `${indent}<value class="uiInteraction" uri="${escapeXmlAttr(val)}"/>`;
473
+ }
373
474
  }
374
475
  // PDX-493 (H3): infer valueClass for date / datetime / boolean / decimal / string. The
375
476
  // `fieldTypeHint` parameter on `inferSalesforceValueClass` is intentionally not threaded
376
477
  // through here yet — it lands in PDX-492 (H2b) along with the `field_type_hints` tool input.
377
478
  const inferred = inferSalesforceValueClass(key, val);
479
+ if (inferred === 'date' || inferred === 'datetime') {
480
+ // PDX-509: Provar stores date/dateTime as epoch MILLISECONDS, never an ISO string —
481
+ // an ISO string fails to load in the IDE (RENDER-DATE-VALUECLASS-001), and the real
482
+ // corpus uses epoch ms exclusively with camelCase `dateTime`. Convert here.
483
+ const ms = isoToEpochMs(val, inferred);
484
+ if (ms !== null) {
485
+ const valueClass = inferred === 'datetime' ? 'dateTime' : 'date';
486
+ return `${indent}<value class="value" valueClass="${valueClass}">${ms}</value>`;
487
+ }
488
+ // A value that matches the ISO shape but is not a real date (e.g. "2026-99-99")
489
+ // cannot be converted — fall back to a plain string rather than emit a
490
+ // load-breaking date/dateTime with a non-epoch value.
491
+ return `${indent}<value class="value" valueClass="string">${escapeXmlContent(val)}</value>`;
492
+ }
378
493
  return `${indent}<value class="value" valueClass="${inferred}">${escapeXmlContent(val)}</value>`;
379
494
  }
495
+ /**
496
+ * Convert a validated ISO-8601 date / datetime string to epoch milliseconds.
497
+ * A date-only string ("YYYY-MM-DD") is parsed as UTC midnight per the ES spec. A
498
+ * datetime WITHOUT an explicit timezone would otherwise parse as machine-local
499
+ * time (non-deterministic), so we pin it to UTC by appending 'Z' — matching the
500
+ * Provar corpus, where date/dateTime values are stored as UTC epoch ms.
501
+ * Returns null if the value cannot be parsed (caller falls back to the raw text).
502
+ */
503
+ function isoToEpochMs(val, kind) {
504
+ const needsUtc = kind === 'datetime' && !/(Z|[+-]\d{2}:?\d{2})$/.test(val);
505
+ const ms = Date.parse(needsUtc ? `${val}Z` : val);
506
+ return Number.isNaN(ms) ? null : ms;
507
+ }
380
508
  function buildArgumentsXml(attributes, baseIndent = ' ', apiId = '') {
381
509
  const entries = Object.entries(attributes);
382
510
  if (entries.length === 0)
@@ -413,36 +541,262 @@ function buildSetValuesXml(attributes, baseIndent) {
413
541
  `${i(0)}</arguments>\n` +
414
542
  `${baseIndent.slice(0, -2)}`);
415
543
  }
544
+ // PDX-507: derive the field name for a uiFieldAssertion from a locator URI's
545
+ // `name=` parameter (e.g. ui:locator?name=LastName&binding=… → "LastName").
546
+ function extractFieldName(uri) {
547
+ const m = /[?&]name=([^&]*)/.exec(uri);
548
+ if (!m)
549
+ return 'Field';
550
+ try {
551
+ return decodeURIComponent(m[1]) || 'Field';
552
+ }
553
+ catch {
554
+ return m[1] || 'Field';
555
+ }
556
+ }
557
+ // PDX-507: UiAssert — assemble the nested fieldAssertions / uiFieldAssertion
558
+ // structure the Provar IDE Result Assertions tab binds from. The generator
559
+ // previously emitted the flat shape (top-level fieldLocator / attributeName /
560
+ // comparisonType / expectedValue arguments), which runs green from the CLI but
561
+ // renders the Result Assertions tab blank in the IDE. Shape confirmed against
562
+ // the real test corpus (AllPOCProjects): 3,743/3,778 UiAssert steps use this
563
+ // nested form, with a BARE <fieldLocator uri="…"/> element (never class="uiLocator"
564
+ // — see best-practice rule UI-ASSERT-FIELDLOCATOR-002) and no `assertionType`.
565
+ // Only a `fieldLocator` attribute triggers the nested form; a UiAssert that
566
+ // carries a plain `locator` argument keeps the documented locator→uiLocator
567
+ // contract via the flat fallback (buildArgumentValue dispatches it). When no
568
+ // fieldLocator is supplied the step is not a field assertion, so fall back to
569
+ // flat argument emission and leave other UiAssert shapes untouched.
570
+ function buildUiAssertXml(attributes, baseIndent, apiId) {
571
+ const a = attributes;
572
+ const fieldLocator = a['fieldLocator'] ?? '';
573
+ if (!fieldLocator)
574
+ return buildArgumentsXml(attributes, baseIndent, apiId);
575
+ const i = (n) => baseIndent + ' '.repeat(n);
576
+ const resultName = a['resultName'] ?? 'Values';
577
+ const resultScope = a['resultScope'] ?? 'Test';
578
+ const captureAfter = a['captureAfter'] ?? 'false';
579
+ const attributeName = a['attributeName'] ?? 'value';
580
+ const comparisonType = a['comparisonType'] ?? 'EqualTo';
581
+ const fieldName = extractFieldName(fieldLocator);
582
+ const expected = a['expectedValue'];
583
+ // resultName / resultScope / captureAfter are control-plane literals that the
584
+ // real corpus ALWAYS emits as valueClass="string" — including captureAfter
585
+ // ("true"/"false"), which is intentionally NOT valueClass="boolean" here.
586
+ // Routing these through inferSalesforceValueClass would diverge from the
587
+ // corpus (it would infer boolean), so they are emitted as string literals.
588
+ const strVal = (v) => `<value class="value" valueClass="string">${escapeXmlContent(v)}</value>`;
589
+ // uiAttributeAssertion — self-closing when there is no expected value. This is
590
+ // a real corpus form (e.g. ADP_POV "UI Assert - Date Empty" emits
591
+ // <uiAttributeAssertion attributeName="value" comparisonType="EqualTo"/>);
592
+ // otherwise it carries a typed <value> child built via buildArgumentValue so
593
+ // {Var} expands to class="variable".
594
+ const attrOpen = `${i(5)}<uiAttributeAssertion attributeName="${escapeXmlAttr(attributeName)}" comparisonType="${escapeXmlAttr(comparisonType)}"`;
595
+ const attrAssertion = expected != null && expected !== ''
596
+ ? `${attrOpen}>\n${buildArgumentValue('expectedValue', expected, i(6), true)}\n${i(5)}</uiAttributeAssertion>`
597
+ : `${attrOpen}/>`;
598
+ const fieldAssertion = `${i(4)}<uiFieldAssertion resultName="${escapeXmlAttr(fieldName)}">\n` +
599
+ `${i(5)}<fieldLocator uri="${escapeXmlAttr(fieldLocator)}"/>\n` +
600
+ `${i(5)}<attributeAssertions>\n` +
601
+ `${attrAssertion}\n` +
602
+ `${i(5)}</attributeAssertions>\n` +
603
+ `${i(4)}</uiFieldAssertion>`;
604
+ return (`\n${i(0)}<arguments>\n` +
605
+ `${i(0)}<argument id="resultName">\n${i(1)}${strVal(resultName)}\n${i(0)}</argument>\n` +
606
+ `${i(0)}<argument id="resultScope">\n${i(1)}${strVal(resultScope)}\n${i(0)}</argument>\n` +
607
+ `${i(0)}<argument id="fieldAssertions">\n` +
608
+ `${i(1)}<value class="valueList" mutable="Mutable">\n` +
609
+ `${fieldAssertion}\n` +
610
+ `${i(1)}</value>\n` +
611
+ `${i(0)}</argument>\n` +
612
+ `${i(0)}<argument id="captureAfter">\n${i(1)}${strVal(captureAfter)}\n${i(0)}</argument>\n` +
613
+ `${i(0)}<argument id="columnAssertions">\n${i(1)}<value class="valueList" mutable="Mutable"/>\n${i(0)}</argument>\n` +
614
+ `${i(0)}<argument id="pageAssertions">\n${i(1)}<value class="valueList" mutable="Mutable"/>\n${i(0)}</argument>\n` +
615
+ `${i(0)}<argument id="beforeWait"/>\n` +
616
+ `${i(0)}<argument id="autoRetry"/>\n` +
617
+ `${i(0)}</arguments>\n` +
618
+ `${baseIndent.slice(0, -2)}`);
619
+ }
416
620
  function buildFlatStepXml(step, testItemId, indent) {
621
+ return buildStepXmlWithChildren(step, testItemId, indent, '', undefined);
622
+ }
623
+ // PDX-495: build a single <apiCall> element, optionally with a <clauses>
624
+ // <clause name="substeps"><steps>…</steps></clause></clauses> block containing
625
+ // already-rendered child XML. This is what lets a UiWithScreen wrap its UI
626
+ // action siblings without breaking the existing flat emission path.
627
+ //
628
+ // `childrenXml` is the already-rendered, already-indented inner XML for the
629
+ // substeps clause (joined newline-separated, no leading/trailing whitespace).
630
+ // `substepsTestItemId` is the testItemId for the <clause name="substeps"> slot;
631
+ // omit (undefined) to skip the clauses block entirely (legacy flat behaviour).
632
+ function buildStepXmlWithChildren(step, testItemId, indent, childrenXml, substepsTestItemId) {
417
633
  const guid = randomUUID();
418
634
  const resolvedApiId = resolveApiId(step.api_id);
419
635
  const baseIndent = indent + ' ';
420
636
  // Use SetValues structure for any SetValues API (string-match mirrors the validator).
421
- const argumentsXml = resolvedApiId.includes('SetValues')
422
- ? buildSetValuesXml(step.attributes, baseIndent)
423
- : buildArgumentsXml(step.attributes, baseIndent, resolvedApiId);
424
- if (argumentsXml) {
637
+ // PDX-507: UiAssert uses the nested fieldAssertions/uiFieldAssertion structure.
638
+ let argumentsXml;
639
+ if (resolvedApiId.includes('SetValues')) {
640
+ argumentsXml = buildSetValuesXml(step.attributes, baseIndent);
641
+ }
642
+ else if (resolvedApiId.endsWith('.UiAssert') || resolvedApiId === 'UiAssert') {
643
+ argumentsXml = buildUiAssertXml(step.attributes, baseIndent, resolvedApiId);
644
+ }
645
+ else {
646
+ argumentsXml = buildArgumentsXml(step.attributes, baseIndent, resolvedApiId);
647
+ }
648
+ const hasClauses = substepsTestItemId !== undefined;
649
+ const open = `${indent}<apiCall guid="${guid}" apiId="${escapeXmlAttr(resolvedApiId)}" name="${escapeXmlAttr(step.name)}" testItemId="${testItemId}">`;
650
+ const close = `${indent}</apiCall>`;
651
+ if (!hasClauses && !argumentsXml) {
425
652
  return (`${indent}<apiCall guid="${guid}" apiId="${escapeXmlAttr(resolvedApiId)}"` +
426
- ` name="${escapeXmlAttr(step.name)}" testItemId="${testItemId}">${argumentsXml}</apiCall>`);
653
+ ` name="${escapeXmlAttr(step.name)}" testItemId="${testItemId}"/>`);
654
+ }
655
+ if (!hasClauses) {
656
+ // Legacy flat shape: keep the inline form so existing string assertions in
657
+ // call sites and tests (e.g. literal `</apiCall>` placement) still match.
658
+ return `${open}${argumentsXml}</apiCall>`;
427
659
  }
428
- return (`${indent}<apiCall guid="${guid}" apiId="${escapeXmlAttr(resolvedApiId)}"` +
429
- ` name="${escapeXmlAttr(step.name)}" testItemId="${testItemId}"/>`);
660
+ // PDX-495 grouped shape: arguments + clauses block with substeps.
661
+ const clauseIndent = baseIndent;
662
+ const innerStepsIndent = baseIndent + ' ';
663
+ const stepsBlock = childrenXml
664
+ ? `${innerStepsIndent}<steps>\n${childrenXml}\n${innerStepsIndent}</steps>`
665
+ : `${innerStepsIndent}<steps/>`;
666
+ const clausesBlock = `${baseIndent}<clauses>\n` +
667
+ `${clauseIndent}<clause name="substeps" testItemId="${substepsTestItemId}">\n` +
668
+ `${stepsBlock}\n` +
669
+ `${clauseIndent}</clause>\n` +
670
+ `${baseIndent}</clauses>`;
671
+ // argumentsXml when present already includes a leading "\n" and a trailing
672
+ // newline + parent-indent so the </apiCall> appears on its own line — match
673
+ // that shape for the grouped emission too.
674
+ if (argumentsXml) {
675
+ return `${open}${argumentsXml.replace(/\n[ \t]*$/, '')}\n${clausesBlock}\n${close}`;
676
+ }
677
+ return `${open}\n${clausesBlock}\n${close}`;
678
+ }
679
+ function groupStepsAuto(steps) {
680
+ const cursor = { i: 0 };
681
+ return collectGroup(steps, cursor, /* stopOnUiWithScreen */ false);
682
+ }
683
+ /**
684
+ * Walk a contiguous run of steps starting at `cursor.i`. Stops at end of list,
685
+ * or — when `stopOnUiWithScreen` is true — at the next UiWithScreen (which
686
+ * belongs to the caller).
687
+ *
688
+ * For each step:
689
+ * - UiWithScreen → consume one node, then recursively absorb a child run of
690
+ * UI-action siblings (with `stopOnUiWithScreen=true`).
691
+ * - UiWithRow → consume one node, then recursively absorb a child run of
692
+ * UI-action siblings (UiWithRow is itself a UI action that can also host
693
+ * substeps — same recursive behaviour as UiWithScreen for its children).
694
+ * - Any other UI action → consume one node, no children.
695
+ * - Non-UI step → consume one node, no children. When called from a child
696
+ * run this breaks the run (handled by the parent loop's `isUiAction` gate).
697
+ */
698
+ function collectGroup(steps, cursor, stopOnUiWithScreen) {
699
+ const result = [];
700
+ while (cursor.i < steps.length) {
701
+ const step = steps[cursor.i];
702
+ if (stopOnUiWithScreen && isUiWithScreen(step.api_id))
703
+ break;
704
+ // Inside a parent's child run, a non-UI step ends the run; the parent's
705
+ // caller will see it next.
706
+ if (stopOnUiWithScreen && !isUiAction(step.api_id))
707
+ break;
708
+ cursor.i++;
709
+ const node = { ...step, children: [] };
710
+ result.push(node);
711
+ if (isScreenContainer(step.api_id)) {
712
+ // UiWithScreen always absorbs; UiWithRow absorbs when it is a child of
713
+ // a UiWithScreen (root-level UiWithRow is rewritten into a synthetic
714
+ // UiWithScreen by `buildTestCaseXml` before this walker runs). The child
715
+ // run itself stops at the next UiWithScreen so a later UiWithScreen is
716
+ // not pulled in as a grandchild.
717
+ node.children = collectGroup(steps, cursor, /* stopOnUiWithScreen */ true);
718
+ }
719
+ }
720
+ return result;
430
721
  }
431
- function buildTestCaseXml(input) {
722
+ // PDX-495 + PDX-497: emit a list of grouped step nodes as XML. Assigns
723
+ // testItemIds depth-first: each node consumes one ID; if it has children it
724
+ // ALSO consumes one more ID for the <clause name="substeps"> slot, then its
725
+ // children consume their own IDs in order (recursing into grandchildren when
726
+ // a child node is itself a container, e.g. UiWithRow inside UiWithScreen).
727
+ // Mirrors the numbering convention used by Provar IDE (verified against the
728
+ // Contact_Lead1.testcase reference shape — see PDX-495).
729
+ function emitGroupedSteps(nodes, indent, startId) {
730
+ const lines = [];
731
+ let id = startId;
732
+ for (const node of nodes) {
733
+ const myId = id++;
734
+ if (node.children.length === 0) {
735
+ lines.push(buildFlatStepXml(node, myId, indent));
736
+ continue;
737
+ }
738
+ const substepsId = id++;
739
+ const childIndent = indent + ' '; // matches buildUiWithScreenXml inner step indent
740
+ // PDX-497: recurse — children may themselves be containers (e.g. UiWithRow
741
+ // inside UiWithScreen). The recursive call assigns child + grandchild IDs
742
+ // and returns the XML for the full subtree.
743
+ const { xml: childrenXml, nextId } = emitGroupedSteps(node.children, childIndent, id);
744
+ id = nextId;
745
+ lines.push(buildStepXmlWithChildren(node, myId, indent, childrenXml, substepsId));
746
+ }
747
+ return { xml: lines.join('\n'), nextId: id };
748
+ }
749
+ function buildTestCaseXml(input, testCaseId = DEFAULT_TESTCASE_ID) {
432
750
  const testCaseGuid = randomUUID();
433
751
  const registryId = randomUUID();
752
+ const groupingMode = input.grouping_mode ?? 'auto';
434
753
  let stepLines;
435
754
  const isNonSf = !!input.target_uri && input.target_uri.startsWith('ui:');
436
755
  if (isNonSf && input.target_uri) {
756
+ // target_uri=ui:pageobject:target?… always wraps all steps in a single
757
+ // synthetic UiWithScreen — this predates PDX-495 and takes precedence
758
+ // regardless of grouping_mode (matches "single-screen" semantics).
437
759
  stepLines = buildUiWithScreenXml(input.steps, input.target_uri);
438
760
  }
761
+ else if (groupingMode === 'single-screen' && input.steps.length > 0) {
762
+ // Explicit single-screen request without a non-SF target_uri: wrap with the
763
+ // caller-supplied target_uri when provided (e.g. `sf:ui:target?object=Lead&action=New`),
764
+ // falling back to the bare `sf:ui:target` default so the synthetic wrapper
765
+ // is well-formed even when target_uri is omitted.
766
+ stepLines = buildUiWithScreenXml(input.steps, input.target_uri ?? 'sf:ui:target');
767
+ }
768
+ else if (groupingMode === 'auto' && input.steps.some((s) => isScreenContainer(s.api_id))) {
769
+ // PDX-495 + PDX-497 auto-grouping: nest UI actions inside their preceding
770
+ // screen container (UiWithScreen). When the payload contains a screen
771
+ // container but no UiWithScreen at root (e.g. starts with UiWithRow), QH's
772
+ // UI-NEST-STRUCT-001 still requires UiWithRow itself to descend from a
773
+ // UiWithScreen via a substeps clause. Synthesize a root UiWithScreen so the
774
+ // round-trip generate -> validate stays clean. Mirrors the single-screen
775
+ // path's synthetic wrapper (target_uri ?? 'sf:ui:target').
776
+ const stepsForGrouping = input.steps.some((s) => isUiWithScreen(s.api_id))
777
+ ? input.steps
778
+ : [
779
+ {
780
+ api_id: 'UiWithScreen',
781
+ name: 'With page',
782
+ attributes: { target: input.target_uri ?? 'sf:ui:target' },
783
+ },
784
+ ...input.steps,
785
+ ];
786
+ const tree = groupStepsAuto(stepsForGrouping);
787
+ stepLines = emitGroupedSteps(tree, ' ', 1).xml;
788
+ }
439
789
  else {
790
+ // Legacy flat behaviour: every step is a root sibling. Preserved by
791
+ // grouping_mode="flat" and by payloads with no UiWithScreen present.
440
792
  const lines = input.steps.map((step, i) => buildFlatStepXml(step, i + 1, ' ')).join('\n');
441
793
  stepLines = lines || ' <!-- TODO: Add test steps here -->';
442
794
  }
443
- // Provar requires: standalone="no", id="1" (integer literal), no name attr, <summary/> before <steps>.
795
+ // Provar requires: standalone="no", a numeric integer id, no name attr, <summary/> before <steps>.
796
+ // The id is a human-facing label, not a uniqueness key (guid is) — see allocateTestCaseId:
797
+ // when writing into an existing project we pass highest-in-use + 1; otherwise it defaults to 1.
444
798
  return ('<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' +
445
- `<testCase guid="${testCaseGuid}" id="1" registryId="${registryId}">\n` +
799
+ `<testCase guid="${testCaseGuid}" id="${testCaseId}" registryId="${registryId}">\n` +
446
800
  ' <summary/>\n' +
447
801
  ' <steps>\n' +
448
802
  stepLines +