@nivustec/proteus 1.0.1
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/LICENSE +21 -0
- package/README.md +394 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +44 -0
- package/dist/injector.d.ts +10 -0
- package/dist/injector.js +157 -0
- package/dist/parser.d.ts +8 -0
- package/dist/parser.js +402 -0
- package/dist/tool-executor.d.ts +15 -0
- package/dist/tool-executor.js +52 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +2 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +42 -0
- package/dist/watcher.d.ts +2 -0
- package/dist/watcher.js +59 -0
- package/package.json +69 -0
package/dist/injector.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import { parseAndInject } from "./parser.js";
|
|
5
|
+
export async function injectTestIds(config, specificFiles) {
|
|
6
|
+
const stats = {
|
|
7
|
+
filesProcessed: 0,
|
|
8
|
+
totalInjected: 0,
|
|
9
|
+
errors: 0,
|
|
10
|
+
errorMessages: [],
|
|
11
|
+
};
|
|
12
|
+
let filesToProcess = [];
|
|
13
|
+
// If we have specific files, process ONLY them (ignore include/exclude)
|
|
14
|
+
if (specificFiles && specificFiles.length > 0) {
|
|
15
|
+
filesToProcess = specificFiles.filter((file) => {
|
|
16
|
+
const exists = existsSync(file);
|
|
17
|
+
return exists;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
// If there are no specific files, use include/exclude
|
|
22
|
+
for (const pattern of config.include || []) {
|
|
23
|
+
const matches = await glob(pattern, {
|
|
24
|
+
ignore: config.exclude,
|
|
25
|
+
nodir: true,
|
|
26
|
+
});
|
|
27
|
+
filesToProcess.push(...matches);
|
|
28
|
+
}
|
|
29
|
+
filesToProcess = [...new Set(filesToProcess)];
|
|
30
|
+
}
|
|
31
|
+
for (const filePath of filesToProcess) {
|
|
32
|
+
const code = readFileSync(filePath, "utf-8");
|
|
33
|
+
if (config.verbose) {
|
|
34
|
+
console.log("🌊 Proteus is transforming your components...");
|
|
35
|
+
}
|
|
36
|
+
const result = parseAndInject(filePath, code, config);
|
|
37
|
+
if (result.error) {
|
|
38
|
+
stats.errors++;
|
|
39
|
+
stats.errorMessages.push(`Error processing ${filePath}: ${result.error.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (result.injectedCount > 0) {
|
|
42
|
+
writeFileSync(filePath, result.code);
|
|
43
|
+
stats.totalInjected += result.injectedCount;
|
|
44
|
+
}
|
|
45
|
+
stats.filesProcessed++;
|
|
46
|
+
}
|
|
47
|
+
return stats;
|
|
48
|
+
}
|
|
49
|
+
export async function injectStagedFiles() {
|
|
50
|
+
const stats = {
|
|
51
|
+
filesProcessed: 0,
|
|
52
|
+
totalInjected: 0,
|
|
53
|
+
errors: 0,
|
|
54
|
+
errorMessages: [],
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
const stagedBackup = execSync("git diff --cached --name-only", {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
})
|
|
60
|
+
.split("\n")
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
execSync("git reset --mixed", { stdio: "pipe" });
|
|
63
|
+
const filesToProcess = stagedBackup.filter((file) => file.match(/\.(jsx|tsx)$/) &&
|
|
64
|
+
!file.includes("node_modules") &&
|
|
65
|
+
existsSync(file));
|
|
66
|
+
const cfg = loadConfig(undefined, true);
|
|
67
|
+
cfg.verbose = false;
|
|
68
|
+
if (cfg.verbose) {
|
|
69
|
+
console.log(`🔧 Files to process: ${filesToProcess.length} files`);
|
|
70
|
+
}
|
|
71
|
+
if (filesToProcess.length === 0) {
|
|
72
|
+
if (stagedBackup.length > 0) {
|
|
73
|
+
execSync(`git add ${stagedBackup.join(" ")}`, { stdio: "pipe" });
|
|
74
|
+
}
|
|
75
|
+
if (cfg.verbose) {
|
|
76
|
+
console.log("💡 No React files in commit");
|
|
77
|
+
}
|
|
78
|
+
return stats;
|
|
79
|
+
}
|
|
80
|
+
const config = cfg;
|
|
81
|
+
for (const filePath of filesToProcess) {
|
|
82
|
+
const code = readFileSync(filePath, "utf-8");
|
|
83
|
+
const result = parseAndInject(filePath, code, config);
|
|
84
|
+
if (result.error) {
|
|
85
|
+
stats.errors++;
|
|
86
|
+
stats.errorMessages.push(`Error processing ${filePath}: ${result.error.message}`);
|
|
87
|
+
}
|
|
88
|
+
if (result.injectedCount > 0) {
|
|
89
|
+
writeFileSync(filePath, result.code);
|
|
90
|
+
if (config.verbose) {
|
|
91
|
+
console.log("🌊 Proteus is transforming your components...");
|
|
92
|
+
console.log(`✅ Injected ${result.injectedCount} test IDs in ${filePath}`);
|
|
93
|
+
}
|
|
94
|
+
stats.totalInjected += result.injectedCount;
|
|
95
|
+
}
|
|
96
|
+
stats.filesProcessed++;
|
|
97
|
+
}
|
|
98
|
+
// 4. Re-stage all files (including modifications)
|
|
99
|
+
if (stagedBackup.length > 0) {
|
|
100
|
+
execSync(`git add ${stagedBackup.join(" ")}`, { stdio: "pipe" });
|
|
101
|
+
}
|
|
102
|
+
if (config.verbose) {
|
|
103
|
+
if (stats.totalInjected > 0) {
|
|
104
|
+
console.log(`🎉 Added ${stats.totalInjected} test IDs to the commit`);
|
|
105
|
+
console.log("💡 Review the changes with 'git diff --cached' before committing");
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log("✅ All files already have data-testid");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return stats;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
stats.errors++;
|
|
115
|
+
stats.errorMessages.push(error instanceof Error ? error.message : String(error));
|
|
116
|
+
return stats;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function setupGitHooks() {
|
|
120
|
+
const packageJsonPath = "./package.json";
|
|
121
|
+
const huskyDir = "./.husky";
|
|
122
|
+
if (existsSync(huskyDir)) {
|
|
123
|
+
try {
|
|
124
|
+
const preCommitPath = `${huskyDir}/pre-commit`;
|
|
125
|
+
const script = `#!/usr/bin/env sh\n. "$(dirname "$0")/_/husky.sh"\nproteus pre-commit\n`;
|
|
126
|
+
writeFileSync(preCommitPath, script, { encoding: "utf8" });
|
|
127
|
+
console.log("✓ Husky pre-commit configured at .husky/pre-commit");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
console.error("Error configuring Husky pre-commit:", error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!existsSync(packageJsonPath)) {
|
|
135
|
+
console.warn("Warning: package.json not found, cannot setup Git hooks");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
140
|
+
if (!packageJson.scripts) {
|
|
141
|
+
packageJson.scripts = {};
|
|
142
|
+
}
|
|
143
|
+
packageJson.scripts["proteus:pre-commit"] = "proteus pre-commit";
|
|
144
|
+
if (!packageJson.husky)
|
|
145
|
+
packageJson.husky = {};
|
|
146
|
+
if (!packageJson.husky.hooks)
|
|
147
|
+
packageJson.husky.hooks = {};
|
|
148
|
+
packageJson.husky.hooks["pre-commit"] = "npm run proteus:pre-commit";
|
|
149
|
+
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
|
150
|
+
console.log("✓ Legacy Git hooks configured in package.json");
|
|
151
|
+
console.log("💡 Added pre-commit hook for automatic data-testid injection");
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
console.error("Error setting up Git hooks:", error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
import { loadConfig } from "./config.js";
|
package/dist/parser.d.ts
ADDED
package/dist/parser.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
import _traverse from "@babel/traverse";
|
|
3
|
+
import * as t from "@babel/types";
|
|
4
|
+
import { generateTestId, shortStableHash } from "./utils.js";
|
|
5
|
+
// Normalize ESM/CJS interop from @babel/traverse
|
|
6
|
+
const traverse = _traverse.default;
|
|
7
|
+
// Check if we're inside a forwardRef function
|
|
8
|
+
function isInsideForwardRef(path) {
|
|
9
|
+
let current = path;
|
|
10
|
+
while (current) {
|
|
11
|
+
// Check if parent is a CallExpression with forwardRef
|
|
12
|
+
if (current.isCallExpression() &&
|
|
13
|
+
current.node.callee &&
|
|
14
|
+
((current.node.callee.type === "Identifier" && current.node.callee.name === "forwardRef") ||
|
|
15
|
+
(current.node.callee.type === "MemberExpression" &&
|
|
16
|
+
current.node.callee.property &&
|
|
17
|
+
current.node.callee.property.name === "forwardRef"))) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
current = current.parentPath;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
// Check if element has ref={ref} attribute (forwarded ref)
|
|
25
|
+
function hasForwardedRef(node) {
|
|
26
|
+
return node.attributes.some((attr) => t.isJSXAttribute(attr) &&
|
|
27
|
+
t.isJSXIdentifier(attr.name) &&
|
|
28
|
+
attr.name.name === "ref" &&
|
|
29
|
+
attr.value &&
|
|
30
|
+
t.isJSXExpressionContainer(attr.value) &&
|
|
31
|
+
t.isIdentifier(attr.value.expression) &&
|
|
32
|
+
attr.value.expression.name === "ref");
|
|
33
|
+
}
|
|
34
|
+
// Check if element has {...props} spread
|
|
35
|
+
function hasPropsSpread(node) {
|
|
36
|
+
return node.attributes.some((attr) => t.isJSXSpreadAttribute(attr) &&
|
|
37
|
+
t.isIdentifier(attr.argument) &&
|
|
38
|
+
attr.argument.name === "props");
|
|
39
|
+
}
|
|
40
|
+
// Parse the source, traverse JSX, and inject data-testid attributes
|
|
41
|
+
export function parseAndInject(filePath, code, config) {
|
|
42
|
+
let injectedCount = 0;
|
|
43
|
+
try {
|
|
44
|
+
const ast = parse(code, {
|
|
45
|
+
sourceType: "module",
|
|
46
|
+
plugins: ["jsx", "typescript"],
|
|
47
|
+
tokens: true,
|
|
48
|
+
});
|
|
49
|
+
const lines = code.split("\n");
|
|
50
|
+
let hasModifications = false;
|
|
51
|
+
let elementsFound = 0;
|
|
52
|
+
traverse(ast, {
|
|
53
|
+
JSXOpeningElement(path) {
|
|
54
|
+
elementsFound++;
|
|
55
|
+
const node = path.node;
|
|
56
|
+
if (!node.loc)
|
|
57
|
+
return;
|
|
58
|
+
const lineNumber = node.loc.start.line;
|
|
59
|
+
const elementName = getElementName(node);
|
|
60
|
+
if (!elementName || isExcludedElement(elementName))
|
|
61
|
+
return;
|
|
62
|
+
const hasTestId = node.attributes.some((attr) => t.isJSXAttribute(attr) &&
|
|
63
|
+
t.isJSXIdentifier(attr.name) &&
|
|
64
|
+
attr.name.name === "data-testid");
|
|
65
|
+
if (hasTestId)
|
|
66
|
+
return;
|
|
67
|
+
// SKIP if this element is a reusable component wrapper:
|
|
68
|
+
// - Inside forwardRef
|
|
69
|
+
// - Has ref={ref}
|
|
70
|
+
// - Has {...props}
|
|
71
|
+
if (config.detectReusableComponents) {
|
|
72
|
+
const insideForwardRef = isInsideForwardRef(path);
|
|
73
|
+
const hasRef = hasForwardedRef(node);
|
|
74
|
+
const hasSpread = hasPropsSpread(node);
|
|
75
|
+
if (insideForwardRef && hasRef && hasSpread) {
|
|
76
|
+
if (config.verbose) {
|
|
77
|
+
console.log(`⏭️ Skipping reusable component wrapper at ${filePath}:${lineNumber}`);
|
|
78
|
+
}
|
|
79
|
+
return; // Skip this element, but continue processing others
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const mapInfo = getMapInfo(path);
|
|
83
|
+
const isDynamic = isInsideMapFunction(path);
|
|
84
|
+
const descriptor = getFunctionalDescriptor(node, path);
|
|
85
|
+
const componentPath = getComponentPath(path);
|
|
86
|
+
const classNameHint = getStaticClassName(node);
|
|
87
|
+
const siblingPos = getSiblingPosition(path, elementName);
|
|
88
|
+
const elementInfo = {
|
|
89
|
+
elementName,
|
|
90
|
+
fileName: filePath,
|
|
91
|
+
lineNumber,
|
|
92
|
+
key: mapInfo.key,
|
|
93
|
+
index: mapInfo.index,
|
|
94
|
+
isDynamic,
|
|
95
|
+
descriptor,
|
|
96
|
+
componentPath,
|
|
97
|
+
};
|
|
98
|
+
const built = buildAttributeForElement(config, elementInfo, mapInfo, classNameHint, siblingPos);
|
|
99
|
+
// Insert just before the closing token of the opening tag (supports multi-line and self-closing)
|
|
100
|
+
const endLineIndex = node.loc.end.line - 1;
|
|
101
|
+
const endCol = node.loc.end.column;
|
|
102
|
+
if (endLineIndex >= lines.length)
|
|
103
|
+
return;
|
|
104
|
+
const endLine = lines[endLineIndex];
|
|
105
|
+
const isSelf = node.selfClosing === true;
|
|
106
|
+
const insertCol = isSelf ? Math.max(0, endCol - 2) : Math.max(0, endCol - 1);
|
|
107
|
+
lines[endLineIndex] =
|
|
108
|
+
endLine.slice(0, insertCol) +
|
|
109
|
+
` ${built.attributeText}` +
|
|
110
|
+
endLine.slice(insertCol);
|
|
111
|
+
hasModifications = true;
|
|
112
|
+
injectedCount++;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
// Quiet by default; injector controls logging verbosity
|
|
116
|
+
return {
|
|
117
|
+
code: hasModifications ? lines.join("\n") : code,
|
|
118
|
+
injectedCount,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return { code, injectedCount: 0, error: error };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Helpers
|
|
126
|
+
function getElementName(node) {
|
|
127
|
+
if (t.isJSXIdentifier(node.name)) {
|
|
128
|
+
return node.name.name;
|
|
129
|
+
}
|
|
130
|
+
else if (t.isJSXMemberExpression(node.name)) {
|
|
131
|
+
let name = "";
|
|
132
|
+
let current = node.name;
|
|
133
|
+
while (t.isJSXMemberExpression(current)) {
|
|
134
|
+
name =
|
|
135
|
+
"." +
|
|
136
|
+
(t.isJSXIdentifier(current.property) ? current.property.name : "") +
|
|
137
|
+
name;
|
|
138
|
+
current = current.object;
|
|
139
|
+
}
|
|
140
|
+
if (t.isJSXIdentifier(current)) {
|
|
141
|
+
name = current.name + name;
|
|
142
|
+
}
|
|
143
|
+
return name;
|
|
144
|
+
}
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
function isExcludedElement(elementName) {
|
|
148
|
+
// Self-closing, non-semantic tags we never instrument
|
|
149
|
+
const excluded = ["br", "hr", "meta", "link", "script", "style"];
|
|
150
|
+
return excluded.includes(elementName.toLowerCase());
|
|
151
|
+
}
|
|
152
|
+
function isInsideMapFunction(path) {
|
|
153
|
+
let current = path.parentPath;
|
|
154
|
+
while (current) {
|
|
155
|
+
if (current.isCallExpression() &&
|
|
156
|
+
current.node.callee.type === "MemberExpression" &&
|
|
157
|
+
current.node.callee.property.type === "Identifier" &&
|
|
158
|
+
current.node.callee.property.name === "map") {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
current = current.parentPath;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// Returns map key/index info for elements within Array.prototype.map
|
|
166
|
+
function getMapInfo(path) {
|
|
167
|
+
let current = path.parentPath;
|
|
168
|
+
while (current) {
|
|
169
|
+
if (current.isCallExpression() &&
|
|
170
|
+
current.node.callee.type === "MemberExpression" &&
|
|
171
|
+
current.node.callee.property.type === "Identifier" &&
|
|
172
|
+
current.node.callee.property.name === "map") {
|
|
173
|
+
// Try current opening element
|
|
174
|
+
if (path.isJSXOpeningElement()) {
|
|
175
|
+
const keyAttr = path.node.attributes.find((attr) => t.isJSXAttribute(attr) &&
|
|
176
|
+
t.isJSXIdentifier(attr.name) &&
|
|
177
|
+
attr.name.name === "key");
|
|
178
|
+
if (keyAttr && t.isJSXAttribute(keyAttr) && keyAttr.value) {
|
|
179
|
+
if (t.isStringLiteral(keyAttr.value)) {
|
|
180
|
+
return { key: keyAttr.value.value };
|
|
181
|
+
}
|
|
182
|
+
else if (t.isJSXExpressionContainer(keyAttr.value)) {
|
|
183
|
+
const expr = keyAttr.value.expression;
|
|
184
|
+
const keyExpr = expressionToText(expr);
|
|
185
|
+
if (keyExpr)
|
|
186
|
+
return { key: keyExpr };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Try nearest ancestor JSXElement's openingElement (e.g., wrapper with key)
|
|
191
|
+
let ancestor = path.parentPath;
|
|
192
|
+
while (ancestor) {
|
|
193
|
+
if (ancestor.node && ancestor.node.type === "JSXElement") {
|
|
194
|
+
const opening = ancestor.node.openingElement;
|
|
195
|
+
const keyAttr = opening.attributes.find((attr) => t.isJSXAttribute(attr) &&
|
|
196
|
+
t.isJSXIdentifier(attr.name) &&
|
|
197
|
+
attr.name.name === "key");
|
|
198
|
+
if (keyAttr && keyAttr.value) {
|
|
199
|
+
if (t.isStringLiteral(keyAttr.value)) {
|
|
200
|
+
return { key: keyAttr.value.value };
|
|
201
|
+
}
|
|
202
|
+
if (t.isJSXExpressionContainer(keyAttr.value)) {
|
|
203
|
+
const keyExpr = expressionToText(keyAttr.value.expression);
|
|
204
|
+
if (keyExpr)
|
|
205
|
+
return { key: keyExpr };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
ancestor = ancestor.parentPath;
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
current = current.parentPath;
|
|
214
|
+
}
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
// Stringify simple identifiers/member-expressions (e.g., item.id)
|
|
218
|
+
function expressionToText(expr) {
|
|
219
|
+
if (!expr)
|
|
220
|
+
return undefined;
|
|
221
|
+
if (expr.type === "Identifier")
|
|
222
|
+
return expr.name;
|
|
223
|
+
if (expr.type === "MemberExpression") {
|
|
224
|
+
const obj = expressionToText(expr.object);
|
|
225
|
+
const prop = expr.property && expr.property.name ? expr.property.name : undefined;
|
|
226
|
+
if (obj && prop)
|
|
227
|
+
return `${obj}.${prop}`;
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
// Derive a semantic descriptor from attributes or children (e.g., placeholder, alt, or text)
|
|
232
|
+
function getFunctionalDescriptor(node, path) {
|
|
233
|
+
// Prefer explicit semantics on common elements
|
|
234
|
+
const attrValue = (name) => {
|
|
235
|
+
const attr = node.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === name);
|
|
236
|
+
if (!attr || !attr.value)
|
|
237
|
+
return undefined;
|
|
238
|
+
if (t.isStringLiteral(attr.value))
|
|
239
|
+
return attr.value.value.toLowerCase();
|
|
240
|
+
return undefined;
|
|
241
|
+
};
|
|
242
|
+
const name = t.isJSXIdentifier(node.name) ? node.name.name.toLowerCase() : "";
|
|
243
|
+
if (name === "input") {
|
|
244
|
+
const placeholder = attrValue("placeholder");
|
|
245
|
+
if (placeholder)
|
|
246
|
+
return `input-${placeholder.replace(/\s+/g, "-")}`;
|
|
247
|
+
const type = attrValue("type");
|
|
248
|
+
if (type)
|
|
249
|
+
return `input-${type}`;
|
|
250
|
+
}
|
|
251
|
+
if (name === "img") {
|
|
252
|
+
const alt = attrValue("alt");
|
|
253
|
+
if (alt)
|
|
254
|
+
return `img-${alt.replace(/\s+/g, "-")}`;
|
|
255
|
+
}
|
|
256
|
+
// Infer from children (text or simple expressions)
|
|
257
|
+
const parentEl = path.parentPath && path.parentPath.parent;
|
|
258
|
+
if (parentEl && parentEl.type === "JSXElement") {
|
|
259
|
+
const children = parentEl.children || [];
|
|
260
|
+
for (const c of children) {
|
|
261
|
+
if (c.type === "JSXText") {
|
|
262
|
+
const text = c.value.trim();
|
|
263
|
+
if (text)
|
|
264
|
+
return text.toLowerCase().replace(/\s+/g, "-");
|
|
265
|
+
}
|
|
266
|
+
if (c.type === "JSXExpressionContainer") {
|
|
267
|
+
const d = inferDescriptorFromExpression(c.expression);
|
|
268
|
+
if (d)
|
|
269
|
+
return d;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
// Resolve nearest component/function name to build an informative base id
|
|
276
|
+
function getComponentPath(path) {
|
|
277
|
+
const names = [];
|
|
278
|
+
let current = path;
|
|
279
|
+
while (current) {
|
|
280
|
+
if (current.isFunctionDeclaration() || current.isFunctionExpression() || current.isArrowFunctionExpression()) {
|
|
281
|
+
const id = current.node.id;
|
|
282
|
+
if (id && id.name) {
|
|
283
|
+
names.unshift(id.name);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (current.isVariableDeclarator() && current.node.id.type === "Identifier") {
|
|
288
|
+
names.unshift(current.node.id.name);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
current = current.parentPath;
|
|
292
|
+
}
|
|
293
|
+
return names.length ? names : undefined;
|
|
294
|
+
}
|
|
295
|
+
// Read static className literals to refine role inference
|
|
296
|
+
function getStaticClassName(node) {
|
|
297
|
+
const cls = node.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === "className");
|
|
298
|
+
if (!cls || !cls.value)
|
|
299
|
+
return undefined;
|
|
300
|
+
if (t.isStringLiteral(cls.value))
|
|
301
|
+
return cls.value.value;
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
// If multiple identical sibling tags exist, return this element's position (1-based)
|
|
305
|
+
function getSiblingPosition(path, elementName) {
|
|
306
|
+
const parent = path.parentPath && path.parentPath.parent;
|
|
307
|
+
if (!parent || parent.type !== 'JSXElement')
|
|
308
|
+
return undefined;
|
|
309
|
+
const children = parent.children || [];
|
|
310
|
+
let indices = [];
|
|
311
|
+
for (let i = 0; i < children.length; i++) {
|
|
312
|
+
const c = children[i];
|
|
313
|
+
if (c.type === 'JSXElement') {
|
|
314
|
+
const open = c.openingElement;
|
|
315
|
+
const name = getElementName(open);
|
|
316
|
+
if (name === elementName)
|
|
317
|
+
indices.push(i);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (indices.length <= 1)
|
|
321
|
+
return undefined;
|
|
322
|
+
const myIdx = children.findIndex((c) => c === path.parentPath.node);
|
|
323
|
+
const pos = indices.indexOf(myIdx);
|
|
324
|
+
if (pos === -1)
|
|
325
|
+
return undefined;
|
|
326
|
+
return { index: pos, total: indices.length };
|
|
327
|
+
}
|
|
328
|
+
// Extract a concise name from simple expressions inside JSX
|
|
329
|
+
function inferDescriptorFromExpression(expr) {
|
|
330
|
+
if (!expr)
|
|
331
|
+
return undefined;
|
|
332
|
+
if (expr.type === "MemberExpression") {
|
|
333
|
+
const prop = expr.property;
|
|
334
|
+
if (prop && prop.type === "Identifier")
|
|
335
|
+
return prop.name.toLowerCase();
|
|
336
|
+
}
|
|
337
|
+
if (expr.type === "CallExpression") {
|
|
338
|
+
for (const arg of expr.arguments || []) {
|
|
339
|
+
const d = inferDescriptorFromExpression(arg);
|
|
340
|
+
if (d)
|
|
341
|
+
return d;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
// Map raw tag name to a coarse role
|
|
347
|
+
function mapRole(tag) {
|
|
348
|
+
const name = tag.toLowerCase();
|
|
349
|
+
if (name === "div" || name === "section" || name === "article" || name === "span")
|
|
350
|
+
return "container";
|
|
351
|
+
if (name === "img")
|
|
352
|
+
return "image";
|
|
353
|
+
return name;
|
|
354
|
+
}
|
|
355
|
+
// Build the data-testid attribute text based on strategy, map context, sibling index, and descriptors
|
|
356
|
+
function buildAttributeForElement(config, info, mapInfo, classNameHint, siblingPos) {
|
|
357
|
+
if (config.strategy === "functional") {
|
|
358
|
+
const component = (info.componentPath && info.componentPath[0])
|
|
359
|
+
? info.componentPath[0].toLowerCase()
|
|
360
|
+
: info.fileName.replace(/\.(tsx|jsx|ts|js)$/, "").toLowerCase();
|
|
361
|
+
let role = mapRole(info.elementName);
|
|
362
|
+
if (classNameHint) {
|
|
363
|
+
if (/\bitem\b/.test(classNameHint))
|
|
364
|
+
role = "item";
|
|
365
|
+
else if (/\bproduct-list\b/.test(classNameHint))
|
|
366
|
+
role = "list";
|
|
367
|
+
else if (/\bheader\b/.test(classNameHint))
|
|
368
|
+
role = "header";
|
|
369
|
+
else if (/\badd-to-cart\b/.test(classNameHint))
|
|
370
|
+
role = "button";
|
|
371
|
+
}
|
|
372
|
+
const descriptor = info.descriptor ? info.descriptor.replace(/\s+/g, "-") : undefined;
|
|
373
|
+
// Add sibling index when multiple identical tags under same parent
|
|
374
|
+
const siblingIndexPart = siblingPos && siblingPos.total > 1 ? String(siblingPos.index + 1) : undefined;
|
|
375
|
+
const base = ["qa_" + component, role, siblingIndexPart, descriptor]
|
|
376
|
+
.filter(Boolean)
|
|
377
|
+
.join("_");
|
|
378
|
+
// In functional strategy, always append map key for elements inside map
|
|
379
|
+
if (info.isDynamic && mapInfo && mapInfo.key) {
|
|
380
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/.test(mapInfo.key)) {
|
|
381
|
+
const exprAttr = `data-testid={\`${base}_\${${mapInfo.key}}\`}`;
|
|
382
|
+
return { attributeText: exprAttr, idForRecord: `${base}_${mapInfo.key}` };
|
|
383
|
+
}
|
|
384
|
+
return { attributeText: `data-testid="${base}_${mapInfo.key}"`, idForRecord: `${base}_${mapInfo.key}` };
|
|
385
|
+
}
|
|
386
|
+
if (info.isDynamic && typeof mapInfo.index === "number") {
|
|
387
|
+
return { attributeText: `data-testid="${base}_${mapInfo.index}"`, idForRecord: `${base}_${mapInfo.index}` };
|
|
388
|
+
}
|
|
389
|
+
// Ensure uniqueness for repeated tags without descriptors
|
|
390
|
+
const unique = shortStableHash(`${info.fileName}:${info.lineNumber}:${role}:${descriptor || ''}`);
|
|
391
|
+
return { attributeText: `data-testid="${base}_${unique}"`, idForRecord: `${base}_${unique}` };
|
|
392
|
+
}
|
|
393
|
+
// safe-hash fallback
|
|
394
|
+
const id = generateTestId(config, info);
|
|
395
|
+
if (info.isDynamic && mapInfo && mapInfo.key) {
|
|
396
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/.test(mapInfo.key)) {
|
|
397
|
+
return { attributeText: `data-testid={\`${id}_\${${mapInfo.key}}\`}`, idForRecord: `${id}_${mapInfo.key}` };
|
|
398
|
+
}
|
|
399
|
+
return { attributeText: `data-testid="${id}_${mapInfo.key}"`, idForRecord: `${id}_${mapInfo.key}` };
|
|
400
|
+
}
|
|
401
|
+
return { attributeText: `data-testid="${id}"`, idForRecord: id };
|
|
402
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface ProteusCLIArgs {
|
|
2
|
+
files?: string[];
|
|
3
|
+
include?: string[];
|
|
4
|
+
exclude?: string[];
|
|
5
|
+
strategy?: "safe-hash" | "functional";
|
|
6
|
+
json_output: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Executes the Proteus CLI as a child process and returns the parsed JSON output.
|
|
10
|
+
* This function is designed to be universally compatible with any agent.
|
|
11
|
+
* @param args - The arguments to pass to the 'proteus inject' command.
|
|
12
|
+
* @returns A promise that resolves with the JSON output from the CLI.
|
|
13
|
+
*/
|
|
14
|
+
export declare function executeProteusCLI(args: ProteusCLIArgs): Promise<any>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Executes the Proteus CLI as a child process and returns the parsed JSON output.
|
|
4
|
+
* This function is designed to be universally compatible with any agent.
|
|
5
|
+
* @param args - The arguments to pass to the 'proteus inject' command.
|
|
6
|
+
* @returns A promise that resolves with the JSON output from the CLI.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeProteusCLI(args) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const cliArgs = ["inject"];
|
|
11
|
+
// Ensure json_output is always true for tool calling
|
|
12
|
+
if (args.json_output) {
|
|
13
|
+
cliArgs.push("--json");
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
reject(new Error("The 'json_output' argument must be true for tool calling."));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (args.files && args.files.length > 0)
|
|
20
|
+
cliArgs.push(...args.files);
|
|
21
|
+
if (args.include && args.include.length > 0)
|
|
22
|
+
cliArgs.push("--include", ...args.include);
|
|
23
|
+
if (args.exclude && args.exclude.length > 0)
|
|
24
|
+
cliArgs.push("--exclude", ...args.exclude);
|
|
25
|
+
if (args.strategy)
|
|
26
|
+
cliArgs.push("--strategy", args.strategy);
|
|
27
|
+
const proteusProcess = spawn("proteus", cliArgs, { shell: true });
|
|
28
|
+
let stdoutData = "";
|
|
29
|
+
let stderrData = "";
|
|
30
|
+
proteusProcess.stdout.on("data", (data) => {
|
|
31
|
+
stdoutData += data.toString();
|
|
32
|
+
});
|
|
33
|
+
proteusProcess.stderr.on("data", (data) => {
|
|
34
|
+
stderrData += data.toString();
|
|
35
|
+
});
|
|
36
|
+
proteusProcess.on("close", (code) => {
|
|
37
|
+
if (code !== 0) {
|
|
38
|
+
return reject(new Error(`Proteus CLI exited with code ${code}: ${stderrData}`));
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const jsonOutput = JSON.parse(stdoutData);
|
|
42
|
+
resolve(jsonOutput);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
reject(new Error(`Failed to parse Proteus CLI JSON output: ${error}`));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
proteusProcess.on("error", (err) => {
|
|
49
|
+
reject(new Error(`Failed to start Proteus CLI process: ${err.message}`));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ProteuConfig {
|
|
2
|
+
injectOnCommit?: boolean;
|
|
3
|
+
injectOnSave?: boolean;
|
|
4
|
+
include?: string[];
|
|
5
|
+
exclude?: string[];
|
|
6
|
+
strategy?: "safe-hash" | "functional";
|
|
7
|
+
verbose?: boolean;
|
|
8
|
+
json?: boolean;
|
|
9
|
+
detectReusableComponents?: boolean;
|
|
10
|
+
autoExcludePatterns?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ElementInfo {
|
|
13
|
+
elementName: string;
|
|
14
|
+
fileName: string;
|
|
15
|
+
lineNumber: number;
|
|
16
|
+
key?: string;
|
|
17
|
+
index?: number;
|
|
18
|
+
isDynamic?: boolean;
|
|
19
|
+
componentPath?: string[];
|
|
20
|
+
descriptor?: string;
|
|
21
|
+
}
|
package/dist/types.js
ADDED
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { ElementInfo, ProteuConfig } from "./types.js";
|
|
2
|
+
export declare function generateTestId(config: ProteuConfig, info: ElementInfo): string;
|
|
3
|
+
export declare function shortStableHash(input: string): string;
|
|
4
|
+
export declare function normalizePath(path: string): string;
|