@provartesting/provardx-cli 1.4.6 → 1.5.0-dev
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.
- package/README.md +292 -8
- package/lib/commands/provar/automation/config/validate.js.map +1 -1
- package/lib/commands/provar/automation/project/validate.d.ts +14 -0
- package/lib/commands/provar/automation/project/validate.js +69 -0
- package/lib/commands/provar/automation/project/validate.js.map +1 -0
- package/lib/commands/provar/mcp/start.d.ts +16 -0
- package/lib/commands/provar/mcp/start.js +62 -0
- package/lib/commands/provar/mcp/start.js.map +1 -0
- package/lib/commands/provar/quality-hub/connect.d.ts +5 -0
- package/lib/commands/provar/quality-hub/connect.js +12 -0
- package/lib/commands/provar/quality-hub/connect.js.map +1 -0
- package/lib/commands/provar/quality-hub/display.d.ts +5 -0
- package/lib/commands/provar/quality-hub/display.js +12 -0
- package/lib/commands/provar/quality-hub/display.js.map +1 -0
- package/lib/commands/provar/quality-hub/open.d.ts +5 -0
- package/lib/commands/provar/quality-hub/open.js +12 -0
- package/lib/commands/provar/quality-hub/open.js.map +1 -0
- package/lib/commands/provar/quality-hub/test/run/abort.d.ts +5 -0
- package/lib/commands/provar/quality-hub/test/run/abort.js +12 -0
- package/lib/commands/provar/quality-hub/test/run/abort.js.map +1 -0
- package/lib/commands/provar/quality-hub/test/run/report.d.ts +5 -0
- package/lib/commands/provar/quality-hub/test/run/report.js +12 -0
- package/lib/commands/provar/quality-hub/test/run/report.js.map +1 -0
- package/lib/commands/provar/quality-hub/test/run.d.ts +5 -0
- package/lib/commands/provar/quality-hub/test/run.js +12 -0
- package/lib/commands/provar/quality-hub/test/run.js.map +1 -0
- package/lib/commands/provar/quality-hub/testcase/retrieve.d.ts +5 -0
- package/lib/commands/provar/quality-hub/testcase/retrieve.js +12 -0
- package/lib/commands/provar/quality-hub/testcase/retrieve.js.map +1 -0
- package/lib/mcp/licensing/algasClient.d.ts +19 -0
- package/lib/mcp/licensing/algasClient.js +144 -0
- package/lib/mcp/licensing/algasClient.js.map +1 -0
- package/lib/mcp/licensing/ideDetection.d.ts +34 -0
- package/lib/mcp/licensing/ideDetection.js +179 -0
- package/lib/mcp/licensing/ideDetection.js.map +1 -0
- package/lib/mcp/licensing/index.d.ts +5 -0
- package/lib/mcp/licensing/index.js +10 -0
- package/lib/mcp/licensing/index.js.map +1 -0
- package/lib/mcp/licensing/licenseCache.d.ts +20 -0
- package/lib/mcp/licensing/licenseCache.js +79 -0
- package/lib/mcp/licensing/licenseCache.js.map +1 -0
- package/lib/mcp/licensing/licenseError.d.ts +4 -0
- package/lib/mcp/licensing/licenseError.js +15 -0
- package/lib/mcp/licensing/licenseError.js.map +1 -0
- package/lib/mcp/licensing/licenseValidator.d.ts +33 -0
- package/lib/mcp/licensing/licenseValidator.js +103 -0
- package/lib/mcp/licensing/licenseValidator.js.map +1 -0
- package/lib/mcp/logging/logger.d.ts +7 -0
- package/lib/mcp/logging/logger.js +22 -0
- package/lib/mcp/logging/logger.js.map +1 -0
- package/lib/mcp/rules/page_object_validation_rules.json +344 -0
- package/lib/mcp/rules/provar_best_practices_rules.json +3192 -0
- package/lib/mcp/schemas/common.d.ts +20 -0
- package/lib/mcp/schemas/common.js +16 -0
- package/lib/mcp/schemas/common.js.map +1 -0
- package/lib/mcp/security/pathPolicy.d.ts +14 -0
- package/lib/mcp/security/pathPolicy.js +38 -0
- package/lib/mcp/security/pathPolicy.js.map +1 -0
- package/lib/mcp/server.d.ts +5 -0
- package/lib/mcp/server.js +59 -0
- package/lib/mcp/server.js.map +1 -0
- package/lib/mcp/tools/antTools.d.ts +21 -0
- package/lib/mcp/tools/antTools.js +602 -0
- package/lib/mcp/tools/antTools.js.map +1 -0
- package/lib/mcp/tools/automationTools.d.ts +14 -0
- package/lib/mcp/tools/automationTools.js +386 -0
- package/lib/mcp/tools/automationTools.js.map +1 -0
- package/lib/mcp/tools/bestPracticesEngine.d.ts +30 -0
- package/lib/mcp/tools/bestPracticesEngine.js +632 -0
- package/lib/mcp/tools/bestPracticesEngine.js.map +1 -0
- package/lib/mcp/tools/defectTools.d.ts +15 -0
- package/lib/mcp/tools/defectTools.js +199 -0
- package/lib/mcp/tools/defectTools.js.map +1 -0
- package/lib/mcp/tools/hierarchyValidate.d.ts +139 -0
- package/lib/mcp/tools/hierarchyValidate.js +540 -0
- package/lib/mcp/tools/hierarchyValidate.js.map +1 -0
- package/lib/mcp/tools/pageObjectGenerate.d.ts +3 -0
- package/lib/mcp/tools/pageObjectGenerate.js +153 -0
- package/lib/mcp/tools/pageObjectGenerate.js.map +1 -0
- package/lib/mcp/tools/pageObjectValidate.d.ts +18 -0
- package/lib/mcp/tools/pageObjectValidate.js +420 -0
- package/lib/mcp/tools/pageObjectValidate.js.map +1 -0
- package/lib/mcp/tools/projectInspect.d.ts +3 -0
- package/lib/mcp/tools/projectInspect.js +694 -0
- package/lib/mcp/tools/projectInspect.js.map +1 -0
- package/lib/mcp/tools/projectValidateFromPath.d.ts +3 -0
- package/lib/mcp/tools/projectValidateFromPath.js +153 -0
- package/lib/mcp/tools/projectValidateFromPath.js.map +1 -0
- package/lib/mcp/tools/propertiesTools.d.ts +7 -0
- package/lib/mcp/tools/propertiesTools.js +314 -0
- package/lib/mcp/tools/propertiesTools.js.map +1 -0
- package/lib/mcp/tools/qualityHubTools.d.ts +8 -0
- package/lib/mcp/tools/qualityHubTools.js +178 -0
- package/lib/mcp/tools/qualityHubTools.js.map +1 -0
- package/lib/mcp/tools/rcaTools.d.ts +4 -0
- package/lib/mcp/tools/rcaTools.js +620 -0
- package/lib/mcp/tools/rcaTools.js.map +1 -0
- package/lib/mcp/tools/sfSpawn.d.ts +28 -0
- package/lib/mcp/tools/sfSpawn.js +50 -0
- package/lib/mcp/tools/sfSpawn.js.map +1 -0
- package/lib/mcp/tools/testCaseGenerate.d.ts +3 -0
- package/lib/mcp/tools/testCaseGenerate.js +221 -0
- package/lib/mcp/tools/testCaseGenerate.js.map +1 -0
- package/lib/mcp/tools/testCaseValidate.d.ts +20 -0
- package/lib/mcp/tools/testCaseValidate.js +227 -0
- package/lib/mcp/tools/testCaseValidate.js.map +1 -0
- package/lib/mcp/tools/testPlanTools.d.ts +6 -0
- package/lib/mcp/tools/testPlanTools.js +311 -0
- package/lib/mcp/tools/testPlanTools.js.map +1 -0
- package/lib/mcp/tools/testPlanValidate.d.ts +2 -0
- package/lib/mcp/tools/testPlanValidate.js +75 -0
- package/lib/mcp/tools/testPlanValidate.js.map +1 -0
- package/lib/mcp/tools/testSuiteValidate.d.ts +2 -0
- package/lib/mcp/tools/testSuiteValidate.js +63 -0
- package/lib/mcp/tools/testSuiteValidate.js.map +1 -0
- package/lib/services/projectValidation.d.ts +119 -0
- package/lib/services/projectValidation.js +678 -0
- package/lib/services/projectValidation.js.map +1 -0
- package/messages/sf.provar.automation.project.validate.md +52 -0
- package/messages/sf.provar.mcp.start.md +74 -0
- package/oclif.manifest.json +1298 -1
- package/package.json +29 -15
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2024 Provar Limited.
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* Licensed under the BSD 3-Clause license.
|
|
5
|
+
* For full license text, see LICENSE.md file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
+
*/
|
|
7
|
+
/* eslint-disable camelcase */
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { validateProject, buildHierarchySummary, } from '../mcp/tools/hierarchyValidate.js';
|
|
11
|
+
// ── Public error type ─────────────────────────────────────────────────────────
|
|
12
|
+
export class ProjectValidationError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = 'ProjectValidationError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ── Quality tier / grade helpers ──────────────────────────────────────────────
|
|
21
|
+
export function toQualityTier(score) {
|
|
22
|
+
if (score >= 95)
|
|
23
|
+
return 'S';
|
|
24
|
+
if (score >= 85)
|
|
25
|
+
return 'A';
|
|
26
|
+
if (score >= 75)
|
|
27
|
+
return 'B';
|
|
28
|
+
if (score >= 65)
|
|
29
|
+
return 'C';
|
|
30
|
+
return 'D';
|
|
31
|
+
}
|
|
32
|
+
export function toQualityGrade(score) {
|
|
33
|
+
if (score >= 95)
|
|
34
|
+
return 'Excellent';
|
|
35
|
+
if (score >= 90)
|
|
36
|
+
return 'Great';
|
|
37
|
+
if (score >= 80)
|
|
38
|
+
return 'Good';
|
|
39
|
+
if (score >= 70)
|
|
40
|
+
return 'Fair';
|
|
41
|
+
return 'Poor';
|
|
42
|
+
}
|
|
43
|
+
export function toTitleCase(s) {
|
|
44
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
45
|
+
}
|
|
46
|
+
// ── Project root auto-detection ───────────────────────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Given a path that may be a Provar workspace root (containing one or more project
|
|
49
|
+
* sub-directories) or the project root itself (contains .testproject), return the
|
|
50
|
+
* resolved project root.
|
|
51
|
+
*
|
|
52
|
+
* Detection order:
|
|
53
|
+
* 1. `.testproject` file present at given path → it is the project root
|
|
54
|
+
* 2. Exactly one sub-directory contains a `.testproject` → use that
|
|
55
|
+
* 3. Multiple sub-directories contain `.testproject` → return all candidates so the
|
|
56
|
+
* caller can surface a clear error rather than guessing
|
|
57
|
+
*/
|
|
58
|
+
export function resolveProjectRoot(givenPath) {
|
|
59
|
+
if (fs.existsSync(path.join(givenPath, '.testproject'))) {
|
|
60
|
+
return { root: givenPath, candidates: [] };
|
|
61
|
+
}
|
|
62
|
+
// Scan one level deep
|
|
63
|
+
const candidates = [];
|
|
64
|
+
try {
|
|
65
|
+
for (const entry of fs.readdirSync(givenPath, { withFileTypes: true })) {
|
|
66
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
67
|
+
continue;
|
|
68
|
+
const sub = path.join(givenPath, entry.name);
|
|
69
|
+
if (fs.existsSync(path.join(sub, '.testproject')))
|
|
70
|
+
candidates.push(sub);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch { /* skip */ }
|
|
74
|
+
if (candidates.length === 1)
|
|
75
|
+
return { root: candidates[0], candidates: [] };
|
|
76
|
+
return { root: givenPath, candidates }; // caller handles 0 or multiple
|
|
77
|
+
}
|
|
78
|
+
// ── Project context reader (from .testproject) ────────────────────────────────
|
|
79
|
+
export function readProjectContext(projectPath) {
|
|
80
|
+
const testProjectPath = path.join(projectPath, '.testproject');
|
|
81
|
+
const projectName = path.basename(projectPath);
|
|
82
|
+
if (!fs.existsSync(testProjectPath)) {
|
|
83
|
+
return { projectName, context: {} };
|
|
84
|
+
}
|
|
85
|
+
let content;
|
|
86
|
+
try {
|
|
87
|
+
content = fs.readFileSync(testProjectPath, 'utf-8');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { projectName, context: {} };
|
|
91
|
+
}
|
|
92
|
+
// Extract environment names
|
|
93
|
+
const envPattern = /<environment\s[^>]*\bname="([^"]+)"/g;
|
|
94
|
+
const environments = [];
|
|
95
|
+
let m;
|
|
96
|
+
while ((m = envPattern.exec(content)) !== null)
|
|
97
|
+
environments.push(m[1]);
|
|
98
|
+
// Extract connection names
|
|
99
|
+
const connPattern = /<connection\s[^>]*\bname="([^"]+)"/g;
|
|
100
|
+
const connectionNames = [];
|
|
101
|
+
while ((m = connPattern.exec(content)) !== null)
|
|
102
|
+
connectionNames.push(m[1]);
|
|
103
|
+
// Check secrets encryption status
|
|
104
|
+
let secretsPasswordSet = false;
|
|
105
|
+
let unencryptedSecretCount = 0;
|
|
106
|
+
const secretsPathMatch = content.match(/<secureStoragePath>([^<]+)<\/secureStoragePath>/);
|
|
107
|
+
const secretsRelPath = secretsPathMatch?.[1]?.trim() ?? '.secrets';
|
|
108
|
+
const secretsFullPath = path.resolve(path.join(projectPath, secretsRelPath));
|
|
109
|
+
const projectPathResolved = path.resolve(projectPath);
|
|
110
|
+
// Bounds check: only read secrets file if it's within the project directory
|
|
111
|
+
const secretsInBounds = secretsFullPath === projectPathResolved || secretsFullPath.startsWith(projectPathResolved + path.sep);
|
|
112
|
+
if (secretsInBounds && fs.existsSync(secretsFullPath)) {
|
|
113
|
+
try {
|
|
114
|
+
const secretsContent = fs.readFileSync(secretsFullPath, 'utf-8');
|
|
115
|
+
secretsPasswordSet = secretsContent.includes('Encryptor.check=');
|
|
116
|
+
for (const line of secretsContent.split('\n')) {
|
|
117
|
+
const trimmed = line.trim();
|
|
118
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!'))
|
|
119
|
+
continue;
|
|
120
|
+
if (trimmed.startsWith('Encryptor.check='))
|
|
121
|
+
continue;
|
|
122
|
+
const eqIdx = trimmed.indexOf('=');
|
|
123
|
+
if (eqIdx === -1)
|
|
124
|
+
continue;
|
|
125
|
+
const value = trimmed.slice(eqIdx + 1).trim();
|
|
126
|
+
if (value && !value.startsWith('ENC1('))
|
|
127
|
+
unencryptedSecretCount++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch { /* skip */ }
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
projectName,
|
|
134
|
+
context: {
|
|
135
|
+
environments: environments.length ? environments : undefined,
|
|
136
|
+
connection_names: connectionNames.length ? connectionNames : undefined,
|
|
137
|
+
secretsPasswordSet,
|
|
138
|
+
unencrypted_secret_count: unencryptedSecretCount,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Internal: reads a .testinstance file exactly once and returns everything
|
|
144
|
+
* needed both for building TestCaseInput and for accumulating covered paths.
|
|
145
|
+
* Callers that only need TestCaseInput should use resolveTestInstance().
|
|
146
|
+
*/
|
|
147
|
+
function resolveTestInstanceFull(instancePath, projectPath) {
|
|
148
|
+
try {
|
|
149
|
+
const content = fs.readFileSync(instancePath, 'utf-8');
|
|
150
|
+
const pathMatch = content.match(/testCasePath=["']([^"']+)["']/);
|
|
151
|
+
const testCaseId = content.match(/testCaseId=["']([^"']+)["']/)?.[1] ?? null;
|
|
152
|
+
if (!pathMatch?.[1])
|
|
153
|
+
return { testCase: null, testCasePath: null, testCaseId };
|
|
154
|
+
const testCasePath = pathMatch[1].replace(/\\/g, '/');
|
|
155
|
+
const tcFullPath = path.resolve(path.join(projectPath, testCasePath));
|
|
156
|
+
const projResolved = path.resolve(projectPath);
|
|
157
|
+
let xml_content;
|
|
158
|
+
// Bounds check: only read test case files within the project directory
|
|
159
|
+
const tcInBounds = tcFullPath === projResolved || tcFullPath.startsWith(projResolved + path.sep);
|
|
160
|
+
// Derive name from the bounds-checked resolved path to prevent injection via crafted testCasePath
|
|
161
|
+
const tcName = tcInBounds
|
|
162
|
+
? path.basename(tcFullPath, '.testcase')
|
|
163
|
+
: path.basename(testCasePath, '.testcase');
|
|
164
|
+
if (tcInBounds && fs.existsSync(tcFullPath)) {
|
|
165
|
+
try {
|
|
166
|
+
xml_content = fs.readFileSync(tcFullPath, 'utf-8');
|
|
167
|
+
}
|
|
168
|
+
catch { /* xml_content stays undefined */ }
|
|
169
|
+
}
|
|
170
|
+
return { testCase: { name: tcName, xml_content }, testCasePath, testCaseId };
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return { testCase: null, testCasePath: null, testCaseId: null };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function resolveTestInstance(instancePath, projectPath) {
|
|
177
|
+
return resolveTestInstanceFull(instancePath, projectPath).testCase;
|
|
178
|
+
}
|
|
179
|
+
/** Max suite nesting depth — mirrors the guard in projectInspect.ts. */
|
|
180
|
+
const MAX_SUITE_DEPTH = 10;
|
|
181
|
+
/** Accumulates a covered path (and its UUID fallback) into the provided Set. */
|
|
182
|
+
function accumulateCoveredPath(testCasePath, testCaseId, coveredPaths, idMap) {
|
|
183
|
+
if (testCasePath)
|
|
184
|
+
coveredPaths.add(testCasePath);
|
|
185
|
+
if (testCaseId) {
|
|
186
|
+
const resolved = idMap.get(testCaseId);
|
|
187
|
+
if (resolved)
|
|
188
|
+
coveredPaths.add(resolved);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export function readSuiteDirectory(dirPath, name, projectPath, depth = 0, coveredPaths, idMap) {
|
|
192
|
+
const testCases = [];
|
|
193
|
+
const testSuites = [];
|
|
194
|
+
if (depth > MAX_SUITE_DEPTH)
|
|
195
|
+
return { name, test_cases: testCases, test_suites: testSuites };
|
|
196
|
+
try {
|
|
197
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
if (entry.name === 'node_modules')
|
|
200
|
+
continue;
|
|
201
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
202
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
203
|
+
testSuites.push(readSuiteDirectory(fullPath, entry.name, projectPath, depth + 1, coveredPaths, idMap));
|
|
204
|
+
}
|
|
205
|
+
else if (entry.name.endsWith('.testinstance')) {
|
|
206
|
+
const { testCase, testCasePath, testCaseId } = resolveTestInstanceFull(fullPath, projectPath);
|
|
207
|
+
if (testCase)
|
|
208
|
+
testCases.push(testCase);
|
|
209
|
+
if (coveredPaths && idMap)
|
|
210
|
+
accumulateCoveredPath(testCasePath, testCaseId, coveredPaths, idMap);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch { /* skip */ }
|
|
215
|
+
return { name, test_cases: testCases, test_suites: testSuites };
|
|
216
|
+
}
|
|
217
|
+
export function readPlanDirectory(planPath, name, projectPath, coveredPaths, idMap) {
|
|
218
|
+
const testCases = [];
|
|
219
|
+
const testSuites = [];
|
|
220
|
+
try {
|
|
221
|
+
const entries = fs.readdirSync(planPath, { withFileTypes: true });
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (entry.name === 'node_modules')
|
|
224
|
+
continue;
|
|
225
|
+
const fullPath = path.join(planPath, entry.name);
|
|
226
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
227
|
+
testSuites.push(readSuiteDirectory(fullPath, entry.name, projectPath, 0, coveredPaths, idMap));
|
|
228
|
+
}
|
|
229
|
+
else if (entry.name.endsWith('.testinstance')) {
|
|
230
|
+
const { testCase, testCasePath, testCaseId } = resolveTestInstanceFull(fullPath, projectPath);
|
|
231
|
+
if (testCase)
|
|
232
|
+
testCases.push(testCase);
|
|
233
|
+
if (coveredPaths && idMap)
|
|
234
|
+
accumulateCoveredPath(testCasePath, testCaseId, coveredPaths, idMap);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { /* skip */ }
|
|
239
|
+
return { name, test_cases: testCases, test_suites: testSuites };
|
|
240
|
+
}
|
|
241
|
+
export function readPlansDir(projectPath) {
|
|
242
|
+
const plansDir = path.join(projectPath, 'plans');
|
|
243
|
+
const coveredPaths = new Set();
|
|
244
|
+
if (!fs.existsSync(plansDir))
|
|
245
|
+
return { plans: [], coveredPaths };
|
|
246
|
+
// Build UUID→path map once so the plan walk can resolve testCaseId fallbacks
|
|
247
|
+
// without a separate pass over the tests/ directory later.
|
|
248
|
+
const idMap = buildTestCaseIdMap(projectPath);
|
|
249
|
+
const plans = [];
|
|
250
|
+
try {
|
|
251
|
+
const entries = fs.readdirSync(plansDir, { withFileTypes: true });
|
|
252
|
+
for (const entry of entries) {
|
|
253
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
254
|
+
continue;
|
|
255
|
+
const planPath = path.join(plansDir, entry.name);
|
|
256
|
+
plans.push(readPlanDirectory(planPath, entry.name, projectPath, coveredPaths, idMap));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch { /* skip */ }
|
|
260
|
+
return { plans, coveredPaths };
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Builds a map of testcase UUID (registryId / id / guid) → project-relative path.
|
|
264
|
+
* Used as a fallback when testCasePath in a .testinstance file doesn't match
|
|
265
|
+
* the on-disk relative path exactly (e.g. different path separators, moved files).
|
|
266
|
+
*/
|
|
267
|
+
function buildTestCaseIdMap(projectPath) {
|
|
268
|
+
const testsDir = path.join(projectPath, 'tests');
|
|
269
|
+
const idMap = new Map();
|
|
270
|
+
if (!fs.existsSync(testsDir))
|
|
271
|
+
return idMap;
|
|
272
|
+
function walk(dir) {
|
|
273
|
+
try {
|
|
274
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
275
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
276
|
+
continue;
|
|
277
|
+
const fullPath = path.join(dir, entry.name);
|
|
278
|
+
if (entry.isDirectory()) {
|
|
279
|
+
walk(fullPath);
|
|
280
|
+
}
|
|
281
|
+
else if (entry.name.endsWith('.testcase')) {
|
|
282
|
+
try {
|
|
283
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
284
|
+
const rel = path.relative(projectPath, fullPath).replace(/\\/g, '/');
|
|
285
|
+
for (const attr of ['registryId', 'id', 'guid']) {
|
|
286
|
+
const m = content.match(new RegExp(`${attr}=["']([^"']+)["']`));
|
|
287
|
+
if (m?.[1] && !idMap.has(m[1]))
|
|
288
|
+
idMap.set(m[1], rel);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch { /* skip */ }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch { /* skip */ }
|
|
296
|
+
}
|
|
297
|
+
walk(testsDir);
|
|
298
|
+
return idMap;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Collects all .testcase file basenames (without extension) found under tests/.
|
|
302
|
+
* Includes callable tests (visibility="Internal") that have no plan instances,
|
|
303
|
+
* so that checkCaseCalls can distinguish genuine missing-callee errors from
|
|
304
|
+
* valid callable references.
|
|
305
|
+
*/
|
|
306
|
+
export function collectAllTestCaseNames(projectPath) {
|
|
307
|
+
const testsDir = path.join(projectPath, 'tests');
|
|
308
|
+
if (!fs.existsSync(testsDir))
|
|
309
|
+
return [];
|
|
310
|
+
const names = [];
|
|
311
|
+
function walk(dir) {
|
|
312
|
+
try {
|
|
313
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
314
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
315
|
+
continue;
|
|
316
|
+
if (entry.isDirectory()) {
|
|
317
|
+
walk(path.join(dir, entry.name));
|
|
318
|
+
}
|
|
319
|
+
else if (entry.name.endsWith('.testcase'))
|
|
320
|
+
names.push(path.basename(entry.name, '.testcase'));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch { /* skip */ }
|
|
324
|
+
}
|
|
325
|
+
walk(testsDir);
|
|
326
|
+
return names;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* @deprecated Covered paths are now computed as a byproduct of readPlansDir().
|
|
330
|
+
* Use the coveredPaths returned by readPlansDir() instead.
|
|
331
|
+
*/
|
|
332
|
+
export function collectCoveredPathsFromDisk(projectPath) {
|
|
333
|
+
const plansDir = path.join(projectPath, 'plans');
|
|
334
|
+
const covered = new Set();
|
|
335
|
+
if (!fs.existsSync(plansDir))
|
|
336
|
+
return covered;
|
|
337
|
+
// UUID fallback: testCaseId in .testinstance → registryId/id/guid in .testcase
|
|
338
|
+
const idMap = buildTestCaseIdMap(projectPath);
|
|
339
|
+
function walk(dir) {
|
|
340
|
+
try {
|
|
341
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
342
|
+
if (entry.name === 'node_modules')
|
|
343
|
+
continue;
|
|
344
|
+
const fullPath = path.join(dir, entry.name);
|
|
345
|
+
if (entry.isDirectory()) {
|
|
346
|
+
walk(fullPath);
|
|
347
|
+
}
|
|
348
|
+
else if (entry.name.endsWith('.testinstance')) {
|
|
349
|
+
try {
|
|
350
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
351
|
+
// Primary: path-based match
|
|
352
|
+
const pathM = content.match(/testCasePath=["']([^"']+)["']/);
|
|
353
|
+
if (pathM?.[1])
|
|
354
|
+
covered.add(pathM[1].replace(/\\/g, '/'));
|
|
355
|
+
// Fallback: UUID match via testCaseId → testcase registryId/id/guid
|
|
356
|
+
const idM = content.match(/testCaseId=["']([^"']+)["']/);
|
|
357
|
+
if (idM?.[1]) {
|
|
358
|
+
const resolvedPath = idMap.get(idM[1]);
|
|
359
|
+
if (resolvedPath)
|
|
360
|
+
covered.add(resolvedPath);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch { /* skip */ }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch { /* skip */ }
|
|
368
|
+
}
|
|
369
|
+
walk(plansDir);
|
|
370
|
+
return covered;
|
|
371
|
+
}
|
|
372
|
+
export function findUncoveredTestCases(projectPath, coveredPaths) {
|
|
373
|
+
const testsDir = path.join(projectPath, 'tests');
|
|
374
|
+
if (!fs.existsSync(testsDir))
|
|
375
|
+
return [];
|
|
376
|
+
const uncovered = [];
|
|
377
|
+
function walk(dir) {
|
|
378
|
+
try {
|
|
379
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
380
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
381
|
+
continue;
|
|
382
|
+
const fullPath = path.join(dir, entry.name);
|
|
383
|
+
if (entry.isDirectory()) {
|
|
384
|
+
walk(fullPath);
|
|
385
|
+
}
|
|
386
|
+
else if (entry.name.endsWith('.testcase')) {
|
|
387
|
+
const rel = path.relative(projectPath, fullPath).replace(/\\/g, '/');
|
|
388
|
+
if (!coveredPaths.has(rel))
|
|
389
|
+
uncovered.push(rel);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch { /* skip */ }
|
|
394
|
+
}
|
|
395
|
+
walk(testsDir);
|
|
396
|
+
return uncovered.sort();
|
|
397
|
+
}
|
|
398
|
+
function hierarchyViolationToQh(v, num) {
|
|
399
|
+
return {
|
|
400
|
+
number: num,
|
|
401
|
+
ruleId: v.rule_id,
|
|
402
|
+
ruleName: v.name,
|
|
403
|
+
ruleDescription: v.description,
|
|
404
|
+
category: v.category,
|
|
405
|
+
severity: toTitleCase(v.severity),
|
|
406
|
+
weight: v.weight,
|
|
407
|
+
message: v.message,
|
|
408
|
+
recommendation: v.recommendation,
|
|
409
|
+
appliesTo: v.applies_to.join(';'),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function tcIssuesToQhViolations(tc) {
|
|
413
|
+
const violations = [];
|
|
414
|
+
let num = 1;
|
|
415
|
+
for (const issue of tc.issues) {
|
|
416
|
+
violations.push({
|
|
417
|
+
number: num++,
|
|
418
|
+
ruleId: issue.rule_id,
|
|
419
|
+
ruleName: issue.rule_id,
|
|
420
|
+
ruleDescription: '',
|
|
421
|
+
category: 'Validation',
|
|
422
|
+
severity: issue.severity === 'ERROR' ? 'Major' : issue.severity === 'WARNING' ? 'Minor' : 'Info',
|
|
423
|
+
weight: issue.severity === 'ERROR' ? 5 : issue.severity === 'WARNING' ? 2 : 1,
|
|
424
|
+
message: issue.message,
|
|
425
|
+
recommendation: issue.suggestion ?? '',
|
|
426
|
+
appliesTo: issue.applies_to,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
for (const bp of (tc.best_practices_violations ?? [])) {
|
|
430
|
+
violations.push({
|
|
431
|
+
number: num++,
|
|
432
|
+
ruleId: bp.rule_id,
|
|
433
|
+
ruleName: bp.name,
|
|
434
|
+
ruleDescription: bp.description,
|
|
435
|
+
category: bp.category,
|
|
436
|
+
severity: toTitleCase(bp.severity),
|
|
437
|
+
weight: bp.weight,
|
|
438
|
+
message: bp.message,
|
|
439
|
+
recommendation: bp.recommendation,
|
|
440
|
+
appliesTo: bp.applies_to.join(';'),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return violations;
|
|
444
|
+
}
|
|
445
|
+
function tcTotalViolations(tc) {
|
|
446
|
+
return tc.issues.length + (tc.best_practices_violations?.length ?? 0);
|
|
447
|
+
}
|
|
448
|
+
function addSuiteSection(sections, suite) {
|
|
449
|
+
sections.push({
|
|
450
|
+
level: 'Test Suite',
|
|
451
|
+
contextName: suite.name,
|
|
452
|
+
qualityScore: suite.quality_score,
|
|
453
|
+
qualityTier: toQualityTier(suite.quality_score),
|
|
454
|
+
totalViolations: suite.violations.length,
|
|
455
|
+
violations: suite.violations.map((v, i) => hierarchyViolationToQh(v, i + 1)),
|
|
456
|
+
});
|
|
457
|
+
for (const child of suite.test_suites)
|
|
458
|
+
addSuiteSection(sections, child);
|
|
459
|
+
for (const tc of suite.test_cases) {
|
|
460
|
+
const total = tcTotalViolations(tc);
|
|
461
|
+
if (total > 0) {
|
|
462
|
+
sections.push({
|
|
463
|
+
level: 'Test Case',
|
|
464
|
+
contextName: tc.name,
|
|
465
|
+
qualityScore: tc.quality_score,
|
|
466
|
+
qualityTier: toQualityTier(tc.quality_score),
|
|
467
|
+
totalViolations: total,
|
|
468
|
+
violations: tcIssuesToQhViolations(tc),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function addPlanSections(sections, plan) {
|
|
474
|
+
sections.push({
|
|
475
|
+
level: 'Test Plan',
|
|
476
|
+
contextName: plan.name,
|
|
477
|
+
qualityScore: plan.quality_score,
|
|
478
|
+
qualityTier: toQualityTier(plan.quality_score),
|
|
479
|
+
totalViolations: plan.violations.length,
|
|
480
|
+
violations: plan.violations.map((v, i) => hierarchyViolationToQh(v, i + 1)),
|
|
481
|
+
});
|
|
482
|
+
for (const suite of plan.test_suites)
|
|
483
|
+
addSuiteSection(sections, suite);
|
|
484
|
+
for (const tc of plan.test_cases) {
|
|
485
|
+
const total = tcTotalViolations(tc);
|
|
486
|
+
if (total > 0) {
|
|
487
|
+
sections.push({
|
|
488
|
+
level: 'Test Case',
|
|
489
|
+
contextName: tc.name,
|
|
490
|
+
qualityScore: tc.quality_score,
|
|
491
|
+
qualityTier: toQualityTier(tc.quality_score),
|
|
492
|
+
totalViolations: total,
|
|
493
|
+
violations: tcIssuesToQhViolations(tc),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function flattenToSections(result) {
|
|
499
|
+
const sections = [];
|
|
500
|
+
sections.push({
|
|
501
|
+
level: 'Project',
|
|
502
|
+
contextName: result.name,
|
|
503
|
+
qualityScore: result.quality_score,
|
|
504
|
+
qualityTier: toQualityTier(result.quality_score),
|
|
505
|
+
totalViolations: result.violations.length,
|
|
506
|
+
violations: result.violations.map((v, i) => hierarchyViolationToQh(v, i + 1)),
|
|
507
|
+
});
|
|
508
|
+
for (const suite of result.test_suites)
|
|
509
|
+
addSuiteSection(sections, suite);
|
|
510
|
+
for (const tc of result.test_cases) {
|
|
511
|
+
const total = tcTotalViolations(tc);
|
|
512
|
+
if (total > 0) {
|
|
513
|
+
sections.push({
|
|
514
|
+
level: 'Test Case',
|
|
515
|
+
contextName: tc.name,
|
|
516
|
+
qualityScore: tc.quality_score,
|
|
517
|
+
qualityTier: toQualityTier(tc.quality_score),
|
|
518
|
+
totalViolations: total,
|
|
519
|
+
violations: tcIssuesToQhViolations(tc),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const plan of result.test_plans)
|
|
524
|
+
addPlanSections(sections, plan);
|
|
525
|
+
return sections;
|
|
526
|
+
}
|
|
527
|
+
export function buildQhReport(result, projectName) {
|
|
528
|
+
const now = new Date();
|
|
529
|
+
const summary = buildHierarchySummary(result);
|
|
530
|
+
return {
|
|
531
|
+
reportInfo: {
|
|
532
|
+
name: `VR-LOCAL-${now.toISOString().replace(/[:.]/g, '-').slice(0, 19)}`,
|
|
533
|
+
generatedAt: now.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
|
534
|
+
exportedAt: now.toISOString(),
|
|
535
|
+
source: 'provar-mcp-local',
|
|
536
|
+
},
|
|
537
|
+
summary: {
|
|
538
|
+
qualityScore: result.quality_score,
|
|
539
|
+
qualityGrade: toQualityGrade(result.quality_score),
|
|
540
|
+
totalViolations: summary.total_violations,
|
|
541
|
+
criticalViolations: summary.violations_by_severity.critical,
|
|
542
|
+
majorViolations: summary.violations_by_severity.major,
|
|
543
|
+
minorViolations: summary.violations_by_severity.minor,
|
|
544
|
+
infoViolations: summary.violations_by_severity.info,
|
|
545
|
+
},
|
|
546
|
+
context: {
|
|
547
|
+
validationLevel: 'Project',
|
|
548
|
+
testProjectName: projectName,
|
|
549
|
+
},
|
|
550
|
+
sections: flattenToSections(result),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
export function saveResults(projectPath, resultsDir, report, projectName) {
|
|
554
|
+
const targetDir = resultsDir
|
|
555
|
+
? path.resolve(resultsDir)
|
|
556
|
+
: path.join(projectPath, 'provardx', 'validation');
|
|
557
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
558
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
559
|
+
const safeName = projectName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
|
560
|
+
const fileName = `${timestamp}-${safeName}.json`;
|
|
561
|
+
const filePath = path.join(targetDir, fileName);
|
|
562
|
+
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8');
|
|
563
|
+
// Return absolute path when results_dir is provided (avoids ugly ../../../../ relative traversal),
|
|
564
|
+
// relative path otherwise (matches project-relative convention).
|
|
565
|
+
if (resultsDir)
|
|
566
|
+
return filePath.replace(/\\/g, '/');
|
|
567
|
+
return path.relative(projectPath, filePath).replace(/\\/g, '/');
|
|
568
|
+
}
|
|
569
|
+
// ── Main exported function ────────────────────────────────────────────────────
|
|
570
|
+
/**
|
|
571
|
+
* Validate a Provar project from a path on disk.
|
|
572
|
+
*
|
|
573
|
+
* Throws ProjectValidationError for user-facing problems (path not found,
|
|
574
|
+
* ambiguous project, not a Provar project directory). Lets unexpected I/O
|
|
575
|
+
* errors propagate as-is.
|
|
576
|
+
*/
|
|
577
|
+
export function validateProjectFromPath(options) {
|
|
578
|
+
const { project_path, quality_threshold, save_results, results_dir } = options;
|
|
579
|
+
const resolved = path.resolve(project_path);
|
|
580
|
+
if (!fs.existsSync(resolved)) {
|
|
581
|
+
throw new ProjectValidationError('PATH_NOT_FOUND', `Project path does not exist: ${resolved}`);
|
|
582
|
+
}
|
|
583
|
+
const { root: projectRoot, candidates } = resolveProjectRoot(resolved);
|
|
584
|
+
if (candidates.length > 1) {
|
|
585
|
+
throw new ProjectValidationError('AMBIGUOUS_PROJECT', `Multiple Provar projects found under "${resolved}". Specify the exact project directory: ${candidates.map((c) => path.basename(c)).join(', ')}`);
|
|
586
|
+
}
|
|
587
|
+
if (!fs.existsSync(path.join(projectRoot, '.testproject'))) {
|
|
588
|
+
throw new ProjectValidationError('NOT_A_PROJECT', `No Provar project found at "${projectRoot}". Ensure the path points to a directory containing a .testproject file.`);
|
|
589
|
+
}
|
|
590
|
+
const threshold = quality_threshold ?? 80;
|
|
591
|
+
// 1. Read project context from .testproject
|
|
592
|
+
const { projectName, context } = readProjectContext(projectRoot);
|
|
593
|
+
// 2. Read plan hierarchy from plans/ directory; covered paths are computed
|
|
594
|
+
// as a byproduct of the walk — no second traversal needed.
|
|
595
|
+
const { plans: testPlans, coveredPaths } = readPlansDir(projectRoot);
|
|
596
|
+
// 3. Validate
|
|
597
|
+
const input = {
|
|
598
|
+
name: projectName,
|
|
599
|
+
test_plans: testPlans,
|
|
600
|
+
test_suites: [],
|
|
601
|
+
test_cases: [],
|
|
602
|
+
project_context: context,
|
|
603
|
+
all_disk_test_case_names: collectAllTestCaseNames(projectRoot),
|
|
604
|
+
};
|
|
605
|
+
const result = validateProject(input, threshold);
|
|
606
|
+
const summary = buildHierarchySummary(result);
|
|
607
|
+
// 4. Find uncovered test cases and compute accurate disk counts
|
|
608
|
+
// coveredPaths was already built during step 2 — no second directory walk.
|
|
609
|
+
const uncoveredTestCases = findUncoveredTestCases(projectRoot, coveredPaths);
|
|
610
|
+
// Count only covered references where the .testcase file actually exists on disk
|
|
611
|
+
const coveredOnDisk = [...coveredPaths].filter((rel) => fs.existsSync(path.join(projectRoot, rel))).length;
|
|
612
|
+
// 5. Build detailed plan results
|
|
613
|
+
const plans = result.test_plans.map((p) => ({
|
|
614
|
+
name: p.name,
|
|
615
|
+
quality_score: p.quality_score,
|
|
616
|
+
violations: p.violations,
|
|
617
|
+
suites: p.test_suites.map((s) => ({
|
|
618
|
+
name: s.name,
|
|
619
|
+
quality_score: s.quality_score,
|
|
620
|
+
violations: s.violations,
|
|
621
|
+
test_cases: s.test_cases.map((tc) => ({
|
|
622
|
+
name: tc.name,
|
|
623
|
+
quality_score: tc.quality_score,
|
|
624
|
+
quality_tier: toQualityTier(tc.quality_score),
|
|
625
|
+
status: tc.status,
|
|
626
|
+
is_valid: tc.is_valid,
|
|
627
|
+
step_count: tc.step_count,
|
|
628
|
+
error_count: tc.error_count,
|
|
629
|
+
warning_count: tc.warning_count,
|
|
630
|
+
issues: tc.issues,
|
|
631
|
+
})),
|
|
632
|
+
child_suites: s.test_suites.map((cs) => ({
|
|
633
|
+
name: cs.name,
|
|
634
|
+
quality_score: cs.quality_score,
|
|
635
|
+
violations: cs.violations,
|
|
636
|
+
test_case_count: cs.test_cases.length,
|
|
637
|
+
})),
|
|
638
|
+
})),
|
|
639
|
+
unplanned_test_cases: p.test_cases.map((tc) => ({
|
|
640
|
+
name: tc.name,
|
|
641
|
+
quality_score: tc.quality_score,
|
|
642
|
+
status: tc.status,
|
|
643
|
+
error_count: tc.error_count,
|
|
644
|
+
issues: tc.issues,
|
|
645
|
+
})),
|
|
646
|
+
}));
|
|
647
|
+
// 6. Optionally save QH report
|
|
648
|
+
let savedTo = null;
|
|
649
|
+
let saveError;
|
|
650
|
+
if (save_results !== false) {
|
|
651
|
+
const report = buildQhReport(result, projectName);
|
|
652
|
+
try {
|
|
653
|
+
savedTo = saveResults(projectRoot, results_dir, report, projectName);
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
saveError = err.message;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
project_path: projectRoot,
|
|
661
|
+
project_name: projectName,
|
|
662
|
+
quality_score: result.quality_score,
|
|
663
|
+
quality_tier: toQualityTier(result.quality_score),
|
|
664
|
+
quality_grade: toQualityGrade(result.quality_score),
|
|
665
|
+
summary,
|
|
666
|
+
project_violations: result.violations,
|
|
667
|
+
plans,
|
|
668
|
+
coverage: {
|
|
669
|
+
total_test_cases_on_disk: coveredOnDisk + uncoveredTestCases.length,
|
|
670
|
+
covered_by_plans: coveredOnDisk,
|
|
671
|
+
uncovered_count: uncoveredTestCases.length,
|
|
672
|
+
uncovered_test_cases: uncoveredTestCases,
|
|
673
|
+
},
|
|
674
|
+
saved_to: savedTo,
|
|
675
|
+
save_error: saveError,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
//# sourceMappingURL=projectValidation.js.map
|