diff --git a/docs/mcp.md b/docs/mcp.md index 9489bf0..26def48 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1099,6 +1099,16 @@ Validates an XML test case for schema correctness (validity score) and best prac - **`CONN-DB-002`** (`dbConnectResultNameMismatch`) — a DB operation (`SqlQuery`/`DbRead`/`DbInsert`/`DbUpdate`/`DbDelete`) sets a `dbConnectionName` that does not match any `DbConnect` `resultName` in the test, so the open connection can't be found. Defers to `CONN-DB-001` when there is no `DbConnect` at all. - **`UI-LOCATOR-BUTTON-CASING-001`** (`uiLocatorButtonCasing`) — a `UiDoAction` locator uses a wrong-cased standard-button name: `name=Cancel` (use lowercase `name=cancel`) or `name=Continue`/`name=continue` on the record-type screen (use `name=save&path=selectRecordType`). - **`UI-LOCATOR-RECORDTYPE-001`** (`uiLocatorRecordTypeField`) — a `UiDoAction` Record Type picker locator uses `name=recordTypeId`/`name=recordType` instead of `name=RecordType` with `field=RecordTypeId` in the binding. +- **Structural correctness rules** — Checks on element shape / required structure that the Provar IDE depends on to render and run a step. Mostly critical; like the rules above they run offline / in `local_fallback` and keep score parity with the Quality Hub back-end. Currently enforced: + - **`SETVALUES-STRUCTURE-001`** (`setValuesStructure`, critical) — a `SetValues` step has no `` container. + - **`SETVALUES-NAME-001`** (`namedValueName`, critical) — a `` inside a `SetValues` step is missing its `name` attribute. + - **`SETVALUES-VALUE-001`** (`namedValueValue`, critical) — a `` inside a `SetValues` step is missing its child `` element. + - **`UI-ASSERT-STRUCT-002`** (`uiAssertHallucinatedGeneratedParameters`, critical) — a `UiAssert` step contains a `` element, which is never valid on `UiAssert` and blocks validation. + - **`UI-ASSERT-STRUCT-001`** (`uiAssertMissingArguments`, critical) — a `UiAssert` step is missing one or more required arguments (`fieldAssertions`, `columnAssertions`, `pageAssertions`, `resultScope`, `captureAfter`, `beforeWait`, `autoRetry` — present even if empty). + - **`UI-BINDING-ORDER-001`** (`bindingParameterOrder`, critical) — a `uiLocator` binding URI lists `action=`/`field=` before `object=`; the corpus-majority convention is `object=` first. + - **`UI-CONN-LITERAL-001`** (`uiConnectionNameLiteral`, critical) — a UI step's `uiConnectionName` uses a `class="variable"` value; it must be a literal connection name. + - **`FUNCCALL-VALID-001`** (`validFuncCallId`, major) — a `` uses an `id` that is not one of Provar's built-in functions (e.g. the hallucinated `Concatenate`/`Substring`); use the documented set (`Count`, `DateAdd`, `StringReplace`, …) or `class="compound"` for concatenation. + - **`RENDER-ROOT-001`** (`rootAttributes`, minor) — the root `` element carries an attribute outside the allowed set (`guid`, `id`, `name`, `visibility`, `registryId`, `failureBehaviour`). **Error codes** diff --git a/src/mcp/rules/provar_best_practices_rules.json b/src/mcp/rules/provar_best_practices_rules.json index 7d778df..8ba7b00 100644 --- a/src/mcp/rules/provar_best_practices_rules.json +++ b/src/mcp/rules/provar_best_practices_rules.json @@ -2670,10 +2670,10 @@ "id": "UI-BINDING-ORDER-001", "category": "LocatorPatterns", "name": "UI binding parameter order must have object= first", - "description": "In UI binding URIs, the object= parameter MUST come FIRST, followed by field= or action=. Wrong order causes 'Unknown control' errors in Provar at runtime.", + "description": "In UI binding URIs, the object= parameter MUST come FIRST, followed by field= or action=. Wrong order causes 'Unknown control' errors in Provar at runtime (the test case still loads, so this is a major runtime defect, not a load-blocking critical).", "appliesTo": ["Step"], - "severity": "critical", - "weight": 10, + "severity": "major", + "weight": 5, "recommendation": "Correct the binding parameter order. Use 'object?object=ObjectName&action=ActionName' or 'object?object=ObjectName&field=FieldName'. Never put action= or field= before object=.", "check": { "type": "bindingParameterOrder", diff --git a/src/mcp/tools/bestPracticesEngine.ts b/src/mcp/tools/bestPracticesEngine.ts index 43d4eae..e4316f5 100644 --- a/src/mcp/tools/bestPracticesEngine.ts +++ b/src/mcp/tools/bestPracticesEngine.ts @@ -1889,6 +1889,266 @@ function validateUiLocatorRecordTypeField(tc: XmlNode, rule: BPRule): BPViolatio ); } +// ── Structural / load-affecting check types (Tier 5) ───────────────────────── +// Faithful ports of nine Quality Hub structural validators. Six emit a single +// first-offender violation with no `count`; validFuncCallId, the two UiAssert +// structure checks, and bindingParameterOrder collect every offender and emit +// one violation carrying `count` (capped at 5 for funcCall), matching the +// back-end so the weighted-deduction score stays in parity with the Lambda. + +const UI_ASSERT_API_ID = 'com.provar.plugins.forcedotcom.core.ui.UiAssert'; + +// FUNCCALL-VALID-001 — Provar's built-in funcCall ids (exact back-end whitelist, 20 entries). +const VALID_FUNCCALL_IDS: ReadonlySet = new Set([ + 'TestCaseName', + 'TestCasePath', + 'TestCaseOutcome', + 'TestCaseSuccessful', + 'TestCaseErrors', + 'TestRunErrors', + 'StringReplace', + 'StringTrim', + 'StringNormalize', + 'DateAdd', + 'DateFormat', + 'DateParse', + 'Count', + 'Round', + 'NumberFormat', + 'Not', + 'IsSorted', + 'GetEnvironmentVariable', + 'GetSelectedEnvironment', + 'UniqueId', +]); + +/** FUNCCALL-VALID-001 — every `` `id` must be a real Provar built-in function. */ +function validateValidFuncCallId(tc: XmlNode, rule: BPRule): BPViolation | null { + const offenders: string[] = []; + for (const v of collectValueElements(tc)) { + if (v['@_class'] !== 'funcCall') continue; + const id = (v['@_id'] as string | undefined) ?? ''; + if (id && !VALID_FUNCCALL_IDS.has(id)) offenders.push(id); + } + if (!offenders.length) return null; + const MAX = 5; + let msg = `Unknown funcCall id(s) — these functions do not exist in Provar: ${offenders + .slice(0, 3) + .map((id) => `'${id}'`) + .join(', ')}`; + if (offenders.length > 3) msg += ` (+${offenders.length - 3} more)`; + msg += + '. Valid functions include Count, DateAdd, DateFormat, Round, StringReplace, UniqueId, etc. ' + + 'For string concatenation use .'; + return makeViolation(rule, msg, Math.min(offenders.length, MAX)); +} + +// RENDER-ROOT-001 — the only attributes allowed on the root element. +const VALID_ROOT_ATTRS: ReadonlySet = new Set([ + 'guid', + 'id', + 'name', + 'visibility', + 'registryId', + 'failureBehaviour', +]); + +/** RENDER-ROOT-001 — the root `` element must not carry unknown attributes. */ +function validateRootAttributes(tc: XmlNode, rule: BPRule): BPViolation | null { + const unknown = Object.keys(tc) + .filter((k) => k.startsWith('@_')) + .map((k) => k.slice(2)) + .filter((a) => !VALID_ROOT_ATTRS.has(a)); + if (!unknown.length) return null; + return makeViolation(rule, `Unknown root attributes: ${unknown.join(', ')}`); +} + +/** SETVALUES-STRUCTURE-001 — every SetValues step must contain a `` container. */ +function validateSetValuesStructure(tc: XmlNode, rule: BPRule): BPViolation | null { + for (const call of getAllApiCalls(tc)) { + if (call['@_apiId'] !== SETVALUES_API_ID) continue; + // Data-driven SetValues pull their values from an external source declared in + // (e.g. an Excel/CSV binding) and legitimately carry an + // empty with no inline . Not a defect. + if (call['parameterValueSources'] != null) continue; + if (collectElementsByTag(call, 'namedValues').length) continue; + return makeViolation(rule, `SetValues step missing container (testItemId=${stepContext(call).tid})`); + } + return null; +} + +/** SETVALUES-NAME-001 — every `` in a SetValues step must carry a `name` attribute. */ +function validateNamedValueName(tc: XmlNode, rule: BPRule): BPViolation | null { + for (const call of getAllApiCalls(tc)) { + if (call['@_apiId'] !== SETVALUES_API_ID) continue; + for (const nv of collectElementsByTag(call, 'namedValue')) { + if (!nv || typeof nv !== 'object' || (nv as XmlNode)['@_name']) continue; + return makeViolation( + rule, + `namedValue in SetValues step missing name attribute (testItemId=${stepContext(call).tid})` + ); + } + } + return null; +} + +// The QEditor SetValues form stores each assignment as a triple of namedValue slots: +// `valuePath` (target field), `value` (data), `valueScope`. Any of these may be left +// empty — a blank `value` sets the field to blank, and a wholly-blank row is an unused +// row Provar simply ignores. So an empty structural slot is NOT a "missing value" defect. +const SETVALUES_BLANKABLE_SLOTS: ReadonlySet = new Set(['valuePath', 'value', 'valueScope']); + +/** SETVALUES-VALUE-001 — every `` in a SetValues step must contain a child ``. */ +function validateNamedValueValue(tc: XmlNode, rule: BPRule): BPViolation | null { + for (const call of getAllApiCalls(tc)) { + if (call['@_apiId'] !== SETVALUES_API_ID) continue; + for (const nv of collectElementsByTag(call, 'namedValue')) { + if (!nv || typeof nv !== 'object') continue; + const node = nv as XmlNode; + if (node['value'] != null) continue; + // A blank structural slot (valuePath/value/valueScope) is valid (empty value / + // unused row). Only a non-standard namedValue missing its value is a real defect. + if (SETVALUES_BLANKABLE_SLOTS.has((node['@_name'] as string | undefined) ?? '')) continue; + return makeViolation( + rule, + `namedValue in SetValues step missing value element (testItemId=${stepContext(call).tid})` + ); + } + } + return null; +} + +/** UI-ASSERT-STRUCT-002 — UiAssert steps must not contain a (hallucinated) `` element. */ +function validateUiAssertHallucinatedGeneratedParameters(tc: XmlNode, rule: BPRule): BPViolation | null { + const offenders: Array> = []; + for (const call of getAllApiCalls(tc)) { + if (call['@_apiId'] !== UI_ASSERT_API_ID || call['generatedParameters'] == null) continue; + offenders.push(stepContext(call)); + } + if (!offenders.length) return null; + const f = offenders[0]; + return makeViolation( + rule, + `UiAssert step '${f.title}' contains a hallucinated element — UiAssert steps never ` + + `contain generatedParameters; remove the entire section (testItemId=${f.tid})`, + offenders.length + ); +} + +// UI-ASSERT-STRUCT-001 — arguments every UiAssert step must declare (even if empty). +const UI_ASSERT_REQUIRED_ARGS: readonly string[] = [ + 'fieldAssertions', + 'columnAssertions', + 'pageAssertions', + 'resultScope', + 'captureAfter', + 'beforeWait', + 'autoRetry', +]; + +/** UI-ASSERT-STRUCT-001 — a UiAssert step is missing one or more of its required arguments. */ +function validateUiAssertMissingArguments(tc: XmlNode, rule: BPRule): BPViolation | null { + const offenders: Array<{ ctx: ReturnType; missing: string[] }> = []; + for (const call of getAllApiCalls(tc)) { + if (call['@_apiId'] !== UI_ASSERT_API_ID) continue; + const argsNode = call['arguments']; + let missing: string[]; + if (!argsNode || typeof argsNode !== 'object') { + missing = [...UI_ASSERT_REQUIRED_ARGS]; + } else { + const existing = new Set(); + for (const a of getCallArguments(call)) { + const id = a['@_id'] as string | undefined; + if (id) existing.add(id); + } + missing = UI_ASSERT_REQUIRED_ARGS.filter((r) => !existing.has(r)); + } + if (missing.length) offenders.push({ ctx: stepContext(call), missing }); + } + if (!offenders.length) return null; + const f = offenders[0]; + return makeViolation( + rule, + `UiAssert step '${f.ctx.title}' is missing required arguments: ${f.missing.join(', ')}. All UiAssert steps ` + + 'must include fieldAssertions, columnAssertions, pageAssertions, resultScope, captureAfter, beforeWait, and ' + + `autoRetry (even if empty) (testItemId=${f.ctx.tid})`, + offenders.length + ); +} + +// UI-BINDING-ORDER-001 — binding URIs must list object= before action=/field= (percent-encoded). +const BINDING_WRONG_ACTION_FIRST = /object%3Faction%3D[^%]+%26object%3D/; +const BINDING_WRONG_FIELD_FIRST = /object%3Ffield%3D[^%]+%26object%3D/; +const BINDING_ACTION_EXTRACT = /action%3D([^%&]+)%26object%3D([^%&"]+)/; +const BINDING_FIELD_EXTRACT = /field%3D([^%&]+)%26object%3D([^%&"]+)/; + +/** Classify a binding URI's parameter order, returning the wrong/correct pair or null when fine. */ +function classifyBindingOrder(uri: string): { wrong: string; correct: string } | null { + if (BINDING_WRONG_ACTION_FIRST.test(uri)) { + const m = BINDING_ACTION_EXTRACT.exec(uri); + if (m) { + const o = m[2].replace(/&/g, '').replace(/&/g, ''); + return { wrong: `action=${m[1]}&object=${o}`, correct: `object=${o}&action=${m[1]}` }; + } + } else if (BINDING_WRONG_FIELD_FIRST.test(uri)) { + const m = BINDING_FIELD_EXTRACT.exec(uri); + if (m) { + const o = m[2].replace(/&/g, '').replace(/&/g, ''); + return { wrong: `field=${m[1]}&object=${o}`, correct: `object=${o}&field=${m[1]}` }; + } + } + return null; +} + +/** UI-BINDING-ORDER-001 — a `uiLocator` binding lists object= after action=/field= (non-standard order). */ +function validateBindingParameterOrder(tc: XmlNode, rule: BPRule): BPViolation | null { + const seen = new Set(); + const offenders: Array<{ ctx: ReturnType; wrong: string; correct: string }> = []; + for (const call of getAllApiCalls(tc)) { + const ctx = stepContext(call); + for (const v of collectValueElements(call)) { + if (v['@_class'] !== 'uiLocator' || seen.has(v)) continue; + seen.add(v); + const uri = (v['@_uri'] as string | undefined) ?? ''; + if (!uri || !uri.includes('binding=')) continue; + const verdict = classifyBindingOrder(uri); + if (verdict) offenders.push({ ctx, ...verdict }); + } + } + if (!offenders.length) return null; + const f = offenders[0]; + return makeViolation( + rule, + `UI binding in step '${f.ctx.title}' lists parameters in a non-standard order: found '${f.wrong}', the ` + + `corpus-majority convention lists object= first ('${f.correct}'). (testItemId=${f.ctx.tid})`, + offenders.length + ); +} + +// UI-CONN-LITERAL-001 — UI step types whose uiConnectionName must be a literal, not a variable. +const UI_CONN_LITERAL_APIS: ReadonlySet = new Set([ + 'com.provar.plugins.forcedotcom.core.ui.UiWithScreen', + 'com.provar.plugins.forcedotcom.core.ui.UiDoAction', + 'com.provar.plugins.forcedotcom.core.ui.UiAssert', + 'com.provar.plugins.forcedotcom.core.ui.UiWithRow', +]); + +/** UI-CONN-LITERAL-001 — a UI step's `uiConnectionName` uses a `class="variable"` value instead of a literal. */ +function validateUiConnectionNameLiteral(tc: XmlNode, rule: BPRule): BPViolation | null { + for (const call of getAllApiCalls(tc)) { + if (!UI_CONN_LITERAL_APIS.has(call['@_apiId'] as string)) continue; + const v = directArgValueElement(call, 'uiConnectionName'); + if (!v || v['@_class'] !== 'variable') continue; + const ctx = stepContext(call); + return makeViolation( + rule, + `UI step '${ctx.title}' uses a variable reference for uiConnectionName; it must be a literal string ` + + `(testItemId=${ctx.tid})` + ); + } + return null; +} + // ── Validator dispatch map ──────────────────────────────────────────────────── type ValidatorFn = (tc: XmlNode, rule: BPRule) => BPViolation | null; @@ -1922,6 +2182,16 @@ const VALIDATOR_REGISTRY: Record = { assertValuesStringExpr: validateAssertValuesStringExpr, uiLocatorButtonCasing: validateUiLocatorButtonCasing, uiLocatorRecordTypeField: validateUiLocatorRecordTypeField, + // Tier 5 — structural / load-affecting check types + validFuncCallId: validateValidFuncCallId, + rootAttributes: validateRootAttributes, + setValuesStructure: validateSetValuesStructure, + namedValueName: validateNamedValueName, + namedValueValue: validateNamedValueValue, + uiAssertHallucinatedGeneratedParameters: validateUiAssertHallucinatedGeneratedParameters, + uiAssertMissingArguments: validateUiAssertMissingArguments, + bindingParameterOrder: validateBindingParameterOrder, + uiConnectionNameLiteral: validateUiConnectionNameLiteral, // 'regex' is dispatched separately (needs metadata) // 'uiActionNestingStructure' is dispatched separately (emits one violation per offending step) }; diff --git a/test/unit/mcp/bestPracticesEngine.test.ts b/test/unit/mcp/bestPracticesEngine.test.ts index 5f5b335..350a5fd 100644 --- a/test/unit/mcp/bestPracticesEngine.test.ts +++ b/test/unit/mcp/bestPracticesEngine.test.ts @@ -1361,3 +1361,269 @@ ${stepsXml} }); }); }); + +describe('structural / load-affecting validators (Tier 5)', () => { + const GUID_T5_TC = '550e8400-e29b-41d4-a716-4466554406d0'; + const GUID_T5_S1 = '550e8400-e29b-41d4-a716-4466554406d1'; + const SET_VALUES = 'com.provar.plugins.bundled.apis.control.SetValues'; + const UI_ASSERT = 'com.provar.plugins.forcedotcom.core.ui.UiAssert'; + const UI_DO_ACTION = 'com.provar.plugins.forcedotcom.core.ui.UiDoAction'; + const GENERIC = 'com.provar.plugins.forcedotcom.core.testapis.ApexExecute'; + + function buildTc(stepsXml: string): string { + return ` + + +${stepsXml} + +`; + } + + function find(violations: BPViolation[], ruleId: string): BPViolation | undefined { + return violations.find((v) => v.rule_id === ruleId); + } + + // ── validFuncCallId (FUNCCALL-VALID-001) ─────────────────────────────────── + describe('validFuncCallId — FUNCCALL-VALID-001', () => { + function funcStep(valueXml: string): string { + return buildTc(` + + ${valueXml} + + `); + } + + it('fires for a hallucinated funcCall id', () => { + const v = find( + runBestPractices(funcStep('')).violations, + 'FUNCCALL-VALID-001' + ); + assert.ok(v, 'Expected FUNCCALL-VALID-001 to fire for id="Concatenate"'); + assert.equal(v?.severity, 'major'); + }); + + it('passes for a real Provar built-in (Count)', () => { + const v = find( + runBestPractices(funcStep('')).violations, + 'FUNCCALL-VALID-001' + ); + assert.ok(!v, `id="Count" is valid and should pass, got: ${v?.message}`); + }); + }); + + // ── rootAttributes (RENDER-ROOT-001) ─────────────────────────────────────── + describe('rootAttributes — RENDER-ROOT-001', () => { + it('fires when the root testCase carries an unknown attribute', () => { + const xml = ` +`; + const v = find(runBestPractices(xml).violations, 'RENDER-ROOT-001'); + assert.ok(v, 'Expected RENDER-ROOT-001 to fire for an unknown root attribute'); + assert.ok(v?.message.includes('bogusAttr'), `Message should name the bad attr: ${v?.message}`); + }); + + it('passes when the root has only known attributes', () => { + const xml = ` +`; + const v = find(runBestPractices(xml).violations, 'RENDER-ROOT-001'); + assert.ok(!v, `Only known root attrs should pass, got: ${v?.message}`); + }); + }); + + // ── setValuesStructure (SETVALUES-STRUCTURE-001) ─────────────────────────── + describe('setValuesStructure — SETVALUES-STRUCTURE-001', () => { + it('fires for a SetValues step with no container', () => { + const xml = buildTc(` + + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-STRUCTURE-001'); + assert.ok(v, 'Expected SETVALUES-STRUCTURE-001 to fire when is missing'); + assert.equal(v?.severity, 'critical'); + }); + + it('passes when the SetValues step contains a container', () => { + const xml = buildTc(` + 1 + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-STRUCTURE-001'); + assert.ok(!v, `A SetValues with should pass, got: ${v?.message}`); + }); + + it('does NOT fire for a data-driven SetValues (values from an external source)', () => { + // Excel/CSV-driven SetValues declares and carries an + // empty with no inline — corpus-confirmed valid. + const xml = buildTc(` + + + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-STRUCTURE-001'); + assert.ok(!v, `A data-driven SetValues must pass, got: ${v?.message}`); + }); + }); + + // ── namedValueName (SETVALUES-NAME-001) ──────────────────────────────────── + describe('namedValueName — SETVALUES-NAME-001', () => { + it('fires when a namedValue lacks a name attribute', () => { + const xml = buildTc(` + 1 + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-NAME-001'); + assert.ok(v, 'Expected SETVALUES-NAME-001 to fire for a nameless namedValue'); + assert.equal(v?.severity, 'critical'); + }); + + it('passes when every namedValue has a name attribute', () => { + const xml = buildTc(` + 1 + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-NAME-001'); + assert.ok(!v, `A named namedValue should pass, got: ${v?.message}`); + }); + }); + + // ── namedValueValue (SETVALUES-VALUE-001) ────────────────────────────────── + describe('namedValueValue — SETVALUES-VALUE-001', () => { + it('fires when a non-structural namedValue has no child ', () => { + const xml = buildTc(` + + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-VALUE-001'); + assert.ok(v, 'Expected SETVALUES-VALUE-001 to fire for a non-slot namedValue with no child'); + assert.equal(v?.severity, 'critical'); + }); + + it('does NOT fire for an empty value slot (blank field) — corpus-confirmed valid', () => { + const xml = buildTc(` + Field__c + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-VALUE-001'); + assert.ok(!v, `An empty name="value" slot blanks the field and must pass, got: ${v?.message}`); + }); + + it('does NOT fire for a wholly-blank row (empty valuePath + value) — an unused row', () => { + const xml = buildTc(` + + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-VALUE-001'); + assert.ok(!v, `A blank/unused SetValues row must pass, got: ${v?.message}`); + }); + + it('passes when every namedValue has a child ', () => { + const xml = buildTc(` + 1 + `); + const v = find(runBestPractices(xml).violations, 'SETVALUES-VALUE-001'); + assert.ok(!v, `A namedValue with a child should pass, got: ${v?.message}`); + }); + }); + + // ── uiAssertHallucinatedGeneratedParameters (UI-ASSERT-STRUCT-002) ───────── + describe('uiAssertHallucinatedGeneratedParameters — UI-ASSERT-STRUCT-002', () => { + it('fires for a UiAssert step containing ', () => { + const xml = buildTc(` + + `); + const v = find(runBestPractices(xml).violations, 'UI-ASSERT-STRUCT-002'); + assert.ok(v, 'Expected UI-ASSERT-STRUCT-002 to fire for hallucinated generatedParameters'); + assert.equal(v?.severity, 'critical'); + }); + + it('passes for a UiAssert step with no generatedParameters', () => { + const xml = buildTc(` `); + const v = find(runBestPractices(xml).violations, 'UI-ASSERT-STRUCT-002'); + assert.ok(!v, `A UiAssert with no generatedParameters should pass, got: ${v?.message}`); + }); + }); + + // ── uiAssertMissingArguments (UI-ASSERT-STRUCT-001) ──────────────────────── + describe('uiAssertMissingArguments — UI-ASSERT-STRUCT-001', () => { + it('fires for a UiAssert step missing required arguments', () => { + const xml = buildTc(` + + `); + const v = find(runBestPractices(xml).violations, 'UI-ASSERT-STRUCT-001'); + assert.ok(v, 'Expected UI-ASSERT-STRUCT-001 to fire when required args are missing'); + assert.equal(v?.severity, 'critical'); + assert.ok(v?.message.includes('fieldAssertions'), `Message should list a missing arg: ${v?.message}`); + }); + + it('passes when all required UiAssert arguments are present', () => { + const args = [ + 'fieldAssertions', + 'columnAssertions', + 'pageAssertions', + 'resultScope', + 'captureAfter', + 'beforeWait', + 'autoRetry', + ] + .map((a) => ``) + .join(''); + const xml = buildTc(` + ${args} + `); + const v = find(runBestPractices(xml).violations, 'UI-ASSERT-STRUCT-001'); + assert.ok(!v, `All required args present should pass, got: ${v?.message}`); + }); + }); + + // ── bindingParameterOrder (UI-BINDING-ORDER-001) ─────────────────────────── + describe('bindingParameterOrder — UI-BINDING-ORDER-001', () => { + function locStep(uri: string): string { + return buildTc(` + + `); + } + + it('fires for an action-before-object binding order', () => { + const v = find( + runBestPractices(locStep('ui:locator?binding=object%3Faction%3DNEW%26object%3DAccount')).violations, + 'UI-BINDING-ORDER-001' + ); + assert.ok(v, 'Expected UI-BINDING-ORDER-001 to fire for action-first order'); + assert.equal( + v?.severity, + 'major', + 'wrong binding order errors at runtime (the test loads) — major, not critical' + ); + }); + + it('passes for the object-first binding order', () => { + const v = find( + runBestPractices(locStep('ui:locator?binding=object%3Fobject%3DAccount%26action%3DNEW')).violations, + 'UI-BINDING-ORDER-001' + ); + assert.ok(!v, `object-first order should pass, got: ${v?.message}`); + }); + + it('does not fire for a locator with no binding= parameter', () => { + const v = find(runBestPractices(locStep('ui:locator?name=save')).violations, 'UI-BINDING-ORDER-001'); + assert.ok(!v, 'a locator without a binding is out of scope for this rule'); + }); + }); + + // ── uiConnectionNameLiteral (UI-CONN-LITERAL-001) ────────────────────────── + describe('uiConnectionNameLiteral — UI-CONN-LITERAL-001', () => { + function uiConnStep(valueXml: string): string { + return buildTc(` + ${valueXml} + `); + } + + it('fires when uiConnectionName is a variable reference', () => { + const v = find( + runBestPractices(uiConnStep('')).violations, + 'UI-CONN-LITERAL-001' + ); + assert.ok(v, 'Expected UI-CONN-LITERAL-001 to fire for a variable uiConnectionName'); + assert.equal(v?.severity, 'critical'); + }); + + it('passes when uiConnectionName is a literal string', () => { + const v = find( + runBestPractices(uiConnStep('MyConnection')).violations, + 'UI-CONN-LITERAL-001' + ); + assert.ok(!v, `A literal uiConnectionName should pass, got: ${v?.message}`); + }); + }); +});