Skip to content

Commit 9704976

Browse files
authored
Merge pull request #121 from ProvarTesting/feature/test-creation-loop-feedback
feat(mcp): implement 7 test-loop improvements from real usage feedback
2 parents 57812b1 + b8f3c87 commit 9704976

7 files changed

Lines changed: 478 additions & 10 deletions

File tree

docs/mcp.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@ Validates an XML test case for schema correctness (validity score) and best prac
433433

434434
**Key schema rules:** TC_001 (missing XML declaration), TC_002 (malformed XML), TC_003 (wrong root element), TC_010/011/012 (missing/invalid id/guid), TC_031 (invalid apiCall guid), TC_034/035 (non-integer testItemId).
435435

436+
**Warning rules:**
437+
- **DATA-001**`testCase` declares a `<dataTable>` element. CLI standalone execution does not bind CSV column variables; steps using variable references will resolve to null. Use `SetValues` (Test scope) steps instead, or add the test to a test plan.
438+
- **ASSERT-001** — An `AssertValues` step uses the `argument id="values"` (namedValues) format, which is designed for UI element attribute assertions. For Apex/SOQL result or variable comparisons this silently passes as `null=null`. Use separate `expectedValue`, `actualValue`, and `comparisonType` arguments instead.
439+
436440
---
437441

438442
### `provar.testsuite.validate`
@@ -938,7 +942,9 @@ Triggers a Provar Automation test run using the currently loaded properties file
938942
| --------- | -------- | -------- | ------------------------------------------------------------------------ |
939943
| `flags` | string[] | no | Raw CLI flags to forward (e.g. `["--project-path", "/path/to/project"]`) |
940944

941-
**Output**`{ requestId, exitCode, stdout, stderr }`
945+
**Output**`{ requestId, exitCode, stdout, stderr[, output_lines_suppressed] }`
946+
947+
The `stdout` field is filtered before returning: Java schema-validator lines (`com.networknt.schema.*`) and stale logger-lock `SEVERE` warnings are stripped. If any lines were suppressed, `output_lines_suppressed` contains the count and a note is appended to `stdout`. Use `provar.testrun.rca` to inspect the full raw JUnit output.
942948

943949
**Error codes:** `AUTOMATION_TESTRUN_FAILED`, `SF_NOT_FOUND`
944950

@@ -1084,7 +1090,9 @@ Analyse a completed test run and return a structured Root Cause Analysis report.
10841090
| `screenshot_dir` | Path to `Artifacts/` directory if it exists, else `null` |
10851091
| `pre_existing` | `true` if the same test failed in a prior Increment run |
10861092

1087-
**Root cause categories:** `DRIVER_VERSION_MISMATCH`, `LOCATOR_STALE`, `TIMEOUT`, `ASSERTION_FAILED`, `CREDENTIAL_FAILURE`, `MISSING_CALLABLE`, `METADATA_CACHE`, `PAGE_OBJECT_COMPILE`, `CONNECTION_REFUSED`, `DATA_SETUP`, `LICENSE_INVALID`, `UNKNOWN`
1093+
**Root cause categories:** `DRIVER_VERSION_MISMATCH`, `LOCATOR_STALE`, `TIMEOUT`, `ASSERTION_FAILED`, `CREDENTIAL_FAILURE`, `MISSING_CALLABLE`, `METADATA_CACHE`, `PAGE_OBJECT_COMPILE`, `CONNECTION_REFUSED`, `DATA_SETUP`, `LICENSE_INVALID`, `SALESFORCE_VALIDATION`, `SALESFORCE_PICKLIST`, `SALESFORCE_REFERENCE`, `SALESFORCE_ACCESS`, `SALESFORCE_TRIGGER`, `UNKNOWN`
1094+
1095+
Salesforce DML error categories (`SALESFORCE_*`) represent test-data failures — they appear in `failures[].root_cause_category` but are **not** included in `infrastructure_issues`.
10881096

10891097
**Error codes:** `RESULTS_NOT_CONFIGURED`
10901098

src/mcp/tools/automationTools.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,48 @@ export function registerAutomationConfigLoad(server: McpServer, config: ServerCo
160160
);
161161
}
162162

163+
// ── Testrun output filter ─────────────────────────────────────────────────────
164+
165+
const NOISE_PATTERNS: RegExp[] = [
166+
/com\.networknt\.schema/,
167+
/SEVERE.*Failed to configure logger.*\.lck/,
168+
];
169+
170+
/**
171+
* Strip Java schema-validator debug lines and stale logger-lock SEVERE warnings
172+
* from Provar testrun output. These two patterns account for the bulk of output
173+
* volume and cause MCP responses to be truncated before the pass/fail lines.
174+
*
175+
* Everything else (including real SEVERE failures) passes through unchanged.
176+
* Collapses runs of blank lines to a single blank to keep the output readable.
177+
* Returns the filtered text and the count of suppressed lines.
178+
*/
179+
export function filterTestRunOutput(raw: string): { filtered: string; suppressed: number } {
180+
const lines = raw.split(/\r?\n/);
181+
const kept: string[] = [];
182+
let suppressed = 0;
183+
let lastKeptWasBlank = false;
184+
185+
for (const rawLine of lines) {
186+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
187+
if (NOISE_PATTERNS.some((p) => p.test(line))) {
188+
suppressed++;
189+
continue;
190+
}
191+
const isBlank = line.trim() === '';
192+
if (isBlank && lastKeptWasBlank) continue; // collapse blank runs
193+
kept.push(line);
194+
lastKeptWasBlank = isBlank;
195+
}
196+
197+
let filtered = kept.join('\n');
198+
if (suppressed > 0) {
199+
filtered +=
200+
`\n[testrun: ${suppressed} lines suppressed (schema validator / logger noise) — use provar.testrun.rca for full results]`;
201+
}
202+
return { filtered, suppressed };
203+
}
204+
163205
// ── Tool: provar.automation.testrun ───────────────────────────────────────────
164206

165207
export function registerAutomationTestRun(server: McpServer): void {
@@ -183,12 +225,19 @@ export function registerAutomationTestRun(server: McpServer): void {
183225

184226
try {
185227
const result = runSfCommand(['provar', 'automation', 'test', 'run', ...flags], sf_path);
186-
const response = { requestId, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
228+
const { filtered, suppressed } = filterTestRunOutput(result.stdout);
187229

188230
if (result.exitCode !== 0) {
189-
return { isError: true as const, content: [{ type: 'text' as const, text: JSON.stringify(makeError('AUTOMATION_TESTRUN_FAILED', result.stderr || result.stdout, requestId)) }] };
231+
const { filtered: filteredErr, suppressed: suppressedErr } = filterTestRunOutput(result.stderr || result.stdout);
232+
const errBody = {
233+
...makeError('AUTOMATION_TESTRUN_FAILED', filteredErr, requestId),
234+
...(suppressedErr > 0 ? { output_lines_suppressed: suppressedErr } : {}),
235+
};
236+
return { isError: true as const, content: [{ type: 'text' as const, text: JSON.stringify(errBody) }] };
190237
}
191238

239+
const response: Record<string, unknown> = { requestId, exitCode: result.exitCode, stdout: filtered, stderr: result.stderr };
240+
if (suppressed > 0) response['output_lines_suppressed'] = suppressed;
192241
return { content: [{ type: 'text' as const, text: JSON.stringify(response) }], structuredContent: response };
193242
} catch (err) {
194243
return handleSpawnError(err, requestId, 'provar.automation.testrun');

src/mcp/tools/rcaTools.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ const RCA_RULES: RcaRule[] = [
7777
summary: 'Test assertion failed',
7878
recommendation: 'Verify expected value is correct for current org state',
7979
},
80+
{
81+
category: 'SALESFORCE_VALIDATION',
82+
pattern: /Required fields are missing:\s*\[/i,
83+
summary: 'Salesforce required-field validation failed',
84+
recommendation: 'Ensure all required fields have values; check field-level security for the running user',
85+
},
86+
{
87+
category: 'SALESFORCE_PICKLIST',
88+
pattern: /bad value for restricted picklist field/i,
89+
summary: 'Invalid picklist value used',
90+
recommendation: 'Query valid picklist values (run metadata download or Apex describe); check for trailing spaces or case differences',
91+
},
92+
{
93+
category: 'SALESFORCE_REFERENCE',
94+
pattern: /INVALID_CROSS_REFERENCE_KEY/i,
95+
summary: 'Referenced record ID does not exist or is inaccessible',
96+
recommendation: 'Verify the referenced record exists and the running user has access to it',
97+
},
98+
{
99+
category: 'SALESFORCE_ACCESS',
100+
pattern: /INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY/i,
101+
summary: 'Running user lacks permission on a referenced record',
102+
recommendation: 'Grant the running user appropriate object and record-level permissions',
103+
},
104+
{
105+
category: 'SALESFORCE_TRIGGER',
106+
pattern: /FIELD_CUSTOM_VALIDATION_EXCEPTION/i,
107+
summary: 'A Salesforce validation rule or trigger blocked the DML operation',
108+
recommendation: 'Review validation rules and triggers on the target object; ensure test data satisfies all business rules',
109+
},
80110
{
81111
category: 'CREDENTIAL_FAILURE',
82112
pattern: /InvalidPasswordException|AuthenticationException|INVALID_LOGIN/i,
@@ -184,6 +214,29 @@ function numericChildDirs(dir: string): number[] {
184214
}
185215
}
186216

217+
/**
218+
* Find Provar Increment-mode sibling directories next to resultsBase.
219+
* Provar creates Results, Results(1), Results(2), … as siblings in the same
220+
* parent directory — NOT as numeric children of Results. Returns the numeric
221+
* suffixes of all matching siblings (e.g. [1, 2, 18]).
222+
*/
223+
function incrementSiblingDirs(resultsBase: string): number[] {
224+
const parent = path.dirname(resultsBase);
225+
const base = path.basename(resultsBase);
226+
if (!fs.existsSync(parent)) return [];
227+
try {
228+
const safeName = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
229+
const pattern = new RegExp(`^${safeName}\\((\\d+)\\)$`);
230+
return fs
231+
.readdirSync(parent, { withFileTypes: true })
232+
.filter((e) => e.isDirectory() && pattern.test(e.name))
233+
.map((e) => parseInt((pattern.exec(e.name) as RegExpExecArray)[1], 10))
234+
.filter((n) => n > 0);
235+
} catch {
236+
return [];
237+
}
238+
}
239+
187240
/**
188241
* Expand ${env.VAR} placeholders in a string using process.env.
189242
*/
@@ -293,8 +346,20 @@ function resolveResultsLocation(
293346
}
294347

295348
// Increment resolution
349+
// Provar's primary Increment pattern: Results, Results(1), Results(2)… as siblings.
350+
// Legacy fallback: purely-numeric children (Results/1/, Results/2/…).
351+
const siblings = incrementSiblingDirs(resultsBase);
296352
const numericDirs = numericChildDirs(resultsBase);
297-
if (disposition === 'Increment' || numericDirs.length > 0) {
353+
if (disposition === 'Increment' || siblings.length > 0 || numericDirs.length > 0) {
354+
if (siblings.length > 0) {
355+
const targetIndex = run_index ?? Math.max(...siblings);
356+
return {
357+
results_dir: path.join(path.dirname(resultsBase), `${path.basename(resultsBase)}(${targetIndex})`),
358+
run_index: targetIndex,
359+
disposition,
360+
resolution_source,
361+
};
362+
}
298363
if (numericDirs.length > 0) {
299364
const targetIndex = run_index ?? Math.max(...numericDirs);
300365
return {
@@ -304,7 +369,7 @@ function resolveResultsLocation(
304369
resolution_source,
305370
};
306371
}
307-
// Disposition is Increment but no numeric subdirs yet — fall through to base
372+
// Disposition is Increment but no numbered dirs yet — fall through to base
308373
}
309374

310375
return {
@@ -336,8 +401,10 @@ export function registerTestRunLocate(server: McpServer): void {
336401
.describe('Explicit override for the results base directory; if provided, skip auto-detection'),
337402
run_index: z
338403
.number()
404+
.int()
405+
.positive()
339406
.optional()
340-
.describe('Which Increment run to target (default: latest)'),
407+
.describe('Which Increment run to target (default: latest); must be a positive integer'),
341408
},
342409
(input) => {
343410
const requestId = makeRequestId();
@@ -603,8 +670,10 @@ export function registerTestRunRca(server: McpServer): void {
603670
.describe('Explicit override for the results base directory'),
604671
run_index: z
605672
.number()
673+
.int()
674+
.positive()
606675
.optional()
607-
.describe('Which Increment run to target (default: latest)'),
676+
.describe('Which Increment run to target (default: latest); must be a positive integer'),
608677
locate_only: z
609678
.boolean()
610679
.optional()

src/mcp/tools/testCaseValidate.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export function validateTestCase(xmlContent: string, testName?: string): TestCas
250250
severity: 'ERROR',
251251
message: `testCase guid "${tcGuid}" is not a valid UUID v4.`,
252252
applies_to: 'testCase',
253-
suggestion: 'Generate a proper UUID v4 for the guid attribute.',
253+
suggestion: 'Replace with a valid UUID v4 — e.g. crypto.randomUUID(). The 4th segment must begin with 8, 9, a, or b.',
254254
});
255255
}
256256
// TC_013 (registryId) is intentionally not checked here — registryId is a
@@ -270,6 +270,17 @@ export function validateTestCase(xmlContent: string, testName?: string): TestCas
270270
return finalize(issues, tcId, tcName, 0, xmlContent, testName);
271271
}
272272

273+
// DATA-001: <dataTable> binding is silently ignored in standalone CLI execution
274+
if ('dataTable' in tc && tc['dataTable'] != null) {
275+
issues.push({
276+
rule_id: 'DATA-001',
277+
severity: 'WARNING',
278+
message: 'testCase declares a <dataTable> but CLI standalone execution does not bind CSV column variables — steps using <value class="variable"> references will resolve to null.',
279+
applies_to: 'testCase',
280+
suggestion: 'Use SetValues (Test scope) steps to bind data for standalone CLI execution, or add this test case to a test plan.',
281+
});
282+
}
283+
273284
// Same self-closing guard for <steps/> → fast-xml-parser yields ''
274285
const rawSteps = tc['steps'];
275286
const steps: Record<string, unknown> =
@@ -307,7 +318,7 @@ function validateApiCall(call: Record<string, unknown>, issues: ValidationIssue[
307318
severity: 'ERROR',
308319
message: `apiCall${label} guid "${callGuid}" is not a valid UUID v4.`,
309320
applies_to: 'apiCall',
310-
suggestion: 'Use proper UUID v4 format.',
321+
suggestion: 'Replace with a valid UUID v4 — e.g. crypto.randomUUID(). The 4th segment must begin with 8, 9, a, or b.',
311322
});
312323
}
313324
if (!apiId) {
@@ -345,6 +356,29 @@ function validateApiCall(call: Record<string, unknown>, issues: ValidationIssue[
345356
suggestion: 'Use sequential integers for testItemId.',
346357
});
347358
}
359+
360+
// ASSERT-001: AssertValues using UI namedValues format instead of variable format
361+
if (apiId?.includes('AssertValues')) {
362+
const rawArgs = call['arguments'] as Record<string, unknown> | undefined;
363+
if (rawArgs) {
364+
const argRaw = rawArgs['argument'];
365+
const argList: Array<Record<string, unknown>> = !argRaw
366+
? []
367+
: Array.isArray(argRaw)
368+
? (argRaw as Array<Record<string, unknown>>)
369+
: [argRaw as Record<string, unknown>];
370+
const hasValuesArg = argList.some((a) => (a['@_id'] as string | undefined) === 'values');
371+
if (hasValuesArg) {
372+
issues.push({
373+
rule_id: 'ASSERT-001',
374+
severity: 'WARNING',
375+
message: `AssertValues step "${name ?? '(unnamed)'}" uses namedValues format (argument id="values") — designed for UI element attribute assertions. For Apex/SOQL result or variable comparisons this silently passes as null=null.`,
376+
applies_to: 'apiCall',
377+
suggestion: 'Use separate expectedValue, actualValue, and comparisonType arguments for variable or Apex result comparisons.',
378+
});
379+
}
380+
}
381+
}
348382
}
349383

350384
function finalize(

0 commit comments

Comments
 (0)