gen-testid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +14 -0
- package/.idea/gen-testid.iml +12 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/.idea/workspace.xml +96 -0
- package/README.md +524 -0
- package/bin/cli.js +9 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +87 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +95 -0
- package/dist/core/GenTestId.d.ts +12 -0
- package/dist/core/GenTestId.js +127 -0
- package/dist/core/semantic-generator.d.ts +18 -0
- package/dist/core/semantic-generator.js +142 -0
- package/dist/core/strategies.d.ts +24 -0
- package/dist/core/strategies.js +73 -0
- package/dist/core/types.d.ts +28 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/shared/element-counter.d.ts +4 -0
- package/dist/shared/element-counter.js +11 -0
- package/dist/shared/parser.d.ts +11 -0
- package/dist/shared/parser.js +132 -0
- package/package.json +24 -0
- package/src/cli/index.ts +102 -0
- package/src/cli/init.ts +114 -0
- package/src/core/GenTestId.ts +169 -0
- package/src/core/semantic-generator.ts +164 -0
- package/src/core/strategies.ts +91 -0
- package/src/core/types.ts +33 -0
- package/src/index.ts +2 -0
- package/src/shared/element-counter.ts +10 -0
- package/src/shared/parser.ts +174 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
//src/core/semantic-generator.ts
|
|
2
|
+
import { ElementContext } from './types.js';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
|
|
5
|
+
export interface SemanticGeneratorConfig {
|
|
6
|
+
model?: string;
|
|
7
|
+
ollamaUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SemanticGenerator {
|
|
11
|
+
private model: string;
|
|
12
|
+
private ollamaUrl: string;
|
|
13
|
+
private cache: Map<string, string> = new Map();
|
|
14
|
+
private idCounter: Map<string, number> = new Map();
|
|
15
|
+
private isAvailableCache: boolean | null = null;
|
|
16
|
+
|
|
17
|
+
constructor(config: SemanticGeneratorConfig = {}) {
|
|
18
|
+
this.model = config.model || 'llama3.2:3b';
|
|
19
|
+
this.ollamaUrl = config.ollamaUrl || 'http://localhost:11434';
|
|
20
|
+
this.checkAvailability();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private checkAvailability(): void {
|
|
24
|
+
try {
|
|
25
|
+
const result = execSync(`curl -s -o /dev/null -w "%{http_code}" ${this.ollamaUrl}/api/tags`, {
|
|
26
|
+
encoding: 'utf-8',
|
|
27
|
+
timeout: 2000
|
|
28
|
+
});
|
|
29
|
+
this.isAvailableCache = result === '200';
|
|
30
|
+
if (this.isAvailableCache) {
|
|
31
|
+
console.log('✅ Ollama доступен');
|
|
32
|
+
} else {
|
|
33
|
+
console.log('❌ Ollama не отвечает');
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
this.isAvailableCache = false;
|
|
37
|
+
console.log('❌ Ollama не доступен');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
generateSemanticIdSync(context: ElementContext): string | null {
|
|
42
|
+
if (!this.isAvailableCache) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cacheKey = `${context.filePath}:${context.componentName}:${context.lineNumber}`;
|
|
47
|
+
|
|
48
|
+
if (this.cache.has(cacheKey)) {
|
|
49
|
+
return this.cache.get(cacheKey)!;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const prompt = this.buildPrompt(context);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const semanticId = this.generateWithOllamaSync(prompt);
|
|
56
|
+
|
|
57
|
+
if (semanticId && semanticId.length > 0 && semanticId !== 'id') {
|
|
58
|
+
let cleanedId = this.cleanId(semanticId);
|
|
59
|
+
|
|
60
|
+
//Проверяем дубликаты внутри файла одного
|
|
61
|
+
const fileKey = context.filePath;
|
|
62
|
+
const duplicateKey = `${fileKey}:${cleanedId}`;
|
|
63
|
+
const count = this.idCounter.get(duplicateKey) || 0;
|
|
64
|
+
|
|
65
|
+
if (count > 0) {
|
|
66
|
+
cleanedId = `${cleanedId}-${count + 1}`;
|
|
67
|
+
}
|
|
68
|
+
this.idCounter.set(duplicateKey, count + 1);
|
|
69
|
+
|
|
70
|
+
this.cache.set(cacheKey, cleanedId);
|
|
71
|
+
return cleanedId;
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
//Ошибка - игнорируем
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private buildPrompt(context: ElementContext): string {
|
|
81
|
+
const tagName = context.metadata?.elementType?.split(':')[1] || context.componentName;
|
|
82
|
+
const textContent = context.metadata?.textContent || '';
|
|
83
|
+
const placeholder = context.metadata?.attributes?.placeholder || '';
|
|
84
|
+
const inputType = context.metadata?.attributes?.type || '';
|
|
85
|
+
const labelText = context.metadata?.attributes?.label || '';
|
|
86
|
+
const isCustomComponent = context.metadata?.isCustomComponent || false;
|
|
87
|
+
|
|
88
|
+
let elementDescription = '';
|
|
89
|
+
|
|
90
|
+
if (isCustomComponent) {
|
|
91
|
+
// Для кастомных компонентов используем label или имя компонента
|
|
92
|
+
const componentName = tagName.replace(/Field$/, '').toLowerCase();
|
|
93
|
+
elementDescription = labelText ? `"${labelText}" ${componentName}` : `${componentName} component`;
|
|
94
|
+
} else if (tagName === 'button') {
|
|
95
|
+
const buttonText = textContent || 'button';
|
|
96
|
+
elementDescription = `"${buttonText}" button`;
|
|
97
|
+
} else if (tagName === 'input') {
|
|
98
|
+
if (inputType === 'email') elementDescription = 'email input';
|
|
99
|
+
else if (inputType === 'password') elementDescription = 'password input';
|
|
100
|
+
else if (inputType === 'checkbox') elementDescription = 'checkbox';
|
|
101
|
+
else if (inputType === 'submit') elementDescription = 'submit button';
|
|
102
|
+
else if (placeholder) elementDescription = `"${placeholder}" input`;
|
|
103
|
+
else elementDescription = 'input field';
|
|
104
|
+
} else {
|
|
105
|
+
elementDescription = tagName;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return `Generate ONE short semantic test ID for: ${elementDescription}
|
|
109
|
+
${textContent ? `Text: "${textContent}"` : ''}
|
|
110
|
+
${placeholder ? `Placeholder: "${placeholder}"` : ''}
|
|
111
|
+
${labelText ? `Label: "${labelText}"` : ''}
|
|
112
|
+
|
|
113
|
+
Return ONLY the ID (lowercase, hyphens, max 25 chars).
|
|
114
|
+
Examples: login-button, email-input, password-field, user-name-input, submit-form
|
|
115
|
+
|
|
116
|
+
ID:`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private generateWithOllamaSync(prompt: string): string | null {
|
|
120
|
+
try {
|
|
121
|
+
const escapedPrompt = JSON.stringify(prompt);
|
|
122
|
+
|
|
123
|
+
const command = `curl -s -X POST ${this.ollamaUrl}/api/generate \
|
|
124
|
+
-H "Content-Type: application/json" \
|
|
125
|
+
-d '{"model":"${this.model}","prompt":${escapedPrompt},"stream":false,"options":{"temperature":0.2,"max_tokens":25}}'`;
|
|
126
|
+
|
|
127
|
+
const result = execSync(command, {
|
|
128
|
+
encoding: 'utf-8',
|
|
129
|
+
timeout: 5000,
|
|
130
|
+
maxBuffer: 1024 * 1024
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const data = JSON.parse(result);
|
|
134
|
+
let response = data.response?.trim() || null;
|
|
135
|
+
|
|
136
|
+
if (response) {
|
|
137
|
+
response = response.toLowerCase()
|
|
138
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
139
|
+
.replace(/-+/g, '-')
|
|
140
|
+
.replace(/^-|-$/g, '');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return response;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private cleanId(id: string): string {
|
|
150
|
+
if (!id) return '';
|
|
151
|
+
|
|
152
|
+
let cleaned = id
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
155
|
+
.replace(/-+/g, '-')
|
|
156
|
+
.replace(/^-|-$/g, '');
|
|
157
|
+
|
|
158
|
+
if (cleaned.length > 35) {
|
|
159
|
+
cleaned = cleaned.substring(0, 35);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return cleaned;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/core/strategies.ts
|
|
2
|
+
import { NamingStrategy, ElementContext } from './types.js';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { SemanticGenerator } from './semantic-generator.js';
|
|
5
|
+
|
|
6
|
+
export class HierarchicalStrategy implements NamingStrategy {
|
|
7
|
+
constructor(private prefix: string = 'test') {}
|
|
8
|
+
|
|
9
|
+
generateId(context: ElementContext): string {
|
|
10
|
+
const { componentName, occurrence } = context;
|
|
11
|
+
|
|
12
|
+
const fileName = context.filePath
|
|
13
|
+
.split('/')
|
|
14
|
+
.pop()
|
|
15
|
+
?.replace(/\.[^/.]+$/, '') || '';
|
|
16
|
+
|
|
17
|
+
const lineNumber = context.lineNumber || 0;
|
|
18
|
+
const tagType = context.metadata?.elementType?.split(':')[1] || componentName;
|
|
19
|
+
|
|
20
|
+
const parts = [
|
|
21
|
+
this.prefix,
|
|
22
|
+
fileName,
|
|
23
|
+
tagType,
|
|
24
|
+
`line-${lineNumber}`,
|
|
25
|
+
`elem-${occurrence}`
|
|
26
|
+
].filter(Boolean);
|
|
27
|
+
|
|
28
|
+
return parts.join('__').toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class StableUuidStrategy implements NamingStrategy {
|
|
33
|
+
private cache = new Map<string, string>();
|
|
34
|
+
|
|
35
|
+
constructor(private prefix: string = 'test') {}
|
|
36
|
+
|
|
37
|
+
generateId(context: ElementContext): string {
|
|
38
|
+
const key = `${context.filePath}:${context.componentName}:${context.lineNumber}:${context.occurrence}`;
|
|
39
|
+
|
|
40
|
+
if (!this.cache.has(key)) {
|
|
41
|
+
const hash = createHash('md5').update(key).digest('hex');
|
|
42
|
+
const uuid = [
|
|
43
|
+
hash.substring(0, 8),
|
|
44
|
+
hash.substring(8, 12),
|
|
45
|
+
hash.substring(12, 16),
|
|
46
|
+
hash.substring(16, 20),
|
|
47
|
+
hash.substring(20, 32)
|
|
48
|
+
].join('-');
|
|
49
|
+
|
|
50
|
+
this.cache.set(key, `${this.prefix}-${uuid}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return this.cache.get(key)!;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class MinimalStrategy implements NamingStrategy {
|
|
58
|
+
private globalCounter = 0;
|
|
59
|
+
|
|
60
|
+
constructor(private prefix: string = 'test') {}
|
|
61
|
+
|
|
62
|
+
generateId(): string {
|
|
63
|
+
return `${this.prefix}-${++this.globalCounter}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class SemanticStrategy implements NamingStrategy {
|
|
68
|
+
private generator: SemanticGenerator;
|
|
69
|
+
private fallbackStrategy: NamingStrategy;
|
|
70
|
+
|
|
71
|
+
constructor(prefix: string = 'test', model?: string, ollamaUrl?: string) {
|
|
72
|
+
console.log('🤖 Инициализация семантической стратегии...');
|
|
73
|
+
this.generator = new SemanticGenerator({
|
|
74
|
+
model: model || 'llama3.2:3b',
|
|
75
|
+
ollamaUrl: ollamaUrl || 'http://localhost:11434'
|
|
76
|
+
});
|
|
77
|
+
this.fallbackStrategy = new HierarchicalStrategy(prefix);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
generateId(context: ElementContext): string {
|
|
81
|
+
// Пробуем получить семантический ID
|
|
82
|
+
const semanticId = this.generator.generateSemanticIdSync(context);
|
|
83
|
+
|
|
84
|
+
if (semanticId && semanticId.length > 0) {
|
|
85
|
+
return semanticId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fallback
|
|
89
|
+
return this.fallbackStrategy.generateId(context);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//src/core/types.ts
|
|
2
|
+
export type Strategy = 'hierarchical' | 'stable-uuid' | 'minimal' | 'semantic';
|
|
3
|
+
|
|
4
|
+
export interface GenTestIdConfig {
|
|
5
|
+
prefix?: string;
|
|
6
|
+
strategy?: Strategy;
|
|
7
|
+
semanticModel?: string;
|
|
8
|
+
ollamaUrl?: string;
|
|
9
|
+
excludeFiles?: string[];
|
|
10
|
+
includeFiles?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ElementContext {
|
|
14
|
+
componentName: string;
|
|
15
|
+
filePath: string;
|
|
16
|
+
lineNumber?: number;
|
|
17
|
+
occurrence: number;
|
|
18
|
+
metadata?: Record<string, any>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NamingStrategy {
|
|
22
|
+
generateId(context: ElementContext): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InjectionResult {
|
|
26
|
+
filePath: string;
|
|
27
|
+
injectedCount: number;
|
|
28
|
+
elements: Array<{
|
|
29
|
+
testId: string;
|
|
30
|
+
lineNumber: number;
|
|
31
|
+
elementType: string;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// src/shared/parser.ts
|
|
2
|
+
import { parse, compileTemplate } from '@vue/compiler-sfc';
|
|
3
|
+
import { GenTestId } from '../core/GenTestId.js';
|
|
4
|
+
|
|
5
|
+
export interface ParseResult {
|
|
6
|
+
modifiedContent: string;
|
|
7
|
+
injectedCount: number;
|
|
8
|
+
elements: Array<{
|
|
9
|
+
testId: string;
|
|
10
|
+
lineNumber: number;
|
|
11
|
+
elementType: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NODE_TYPES = {
|
|
16
|
+
ELEMENT: 1,
|
|
17
|
+
ATTRIBUTE: 6,
|
|
18
|
+
DIRECTIVE: 7
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
const IGNORED_TAGS = new Set([
|
|
22
|
+
'template', 'slot', 'component', 'keep-alive',
|
|
23
|
+
'transition', 'transition-group'
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Надёжно находит позицию для вставки атрибута в тег
|
|
28
|
+
*/
|
|
29
|
+
function findInsertPosition(tagSource: string, isSelfClosing: boolean): { offset: number; prefix: string } {
|
|
30
|
+
if (isSelfClosing) {
|
|
31
|
+
// Для <Comp ... /> — вставляем перед />
|
|
32
|
+
const closingIndex = tagSource.lastIndexOf('/>');
|
|
33
|
+
return { offset: closingIndex, prefix: '' };
|
|
34
|
+
} else {
|
|
35
|
+
// Для <Comp ...> — вставляем сразу после имени тега
|
|
36
|
+
// Находим конец имени тега: <TagName[пробел|атрибут|>|\n]
|
|
37
|
+
const tagNameMatch = tagSource.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
|
|
38
|
+
if (!tagNameMatch) return { offset: 1, prefix: '' }; // fallback после <
|
|
39
|
+
|
|
40
|
+
const afterTagName = tagSource.slice(tagNameMatch[0].length);
|
|
41
|
+
// Если сразу после имени тега идёт > — вставляем перед ним
|
|
42
|
+
if (afterTagName.trimStart().startsWith('>')) {
|
|
43
|
+
const gtIndex = tagSource.indexOf('>', tagNameMatch[0].length);
|
|
44
|
+
return { offset: gtIndex, prefix: '' };
|
|
45
|
+
}
|
|
46
|
+
// Иначе вставляем после имени тега (до первого пробела/атрибута)
|
|
47
|
+
return { offset: tagNameMatch[0].length, prefix: ' ' };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function parseVueFile(
|
|
52
|
+
content: string,
|
|
53
|
+
filePath: string,
|
|
54
|
+
genTestId: GenTestId
|
|
55
|
+
): Promise<ParseResult> {
|
|
56
|
+
const result: ParseResult = {
|
|
57
|
+
modifiedContent: content,
|
|
58
|
+
injectedCount: 0,
|
|
59
|
+
elements: []
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const { descriptor, errors } = parse(content, { filename: filePath });
|
|
64
|
+
|
|
65
|
+
if (errors?.length > 0) {
|
|
66
|
+
console.warn(`⚠️ Ошибки парсинга SFC ${filePath}:`, errors.map(e => e.message || String(e)));
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!descriptor.template) {
|
|
71
|
+
console.log(` ⚪ Нет секции <template> в ${filePath}`);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const templateSource = descriptor.template.content;
|
|
76
|
+
const templateStartOffset = descriptor.template.loc.start.offset;
|
|
77
|
+
|
|
78
|
+
const { ast } = compileTemplate({
|
|
79
|
+
source: templateSource,
|
|
80
|
+
filename: filePath,
|
|
81
|
+
id: 'gen-testid-injector'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!ast?.children) return result;
|
|
85
|
+
|
|
86
|
+
const injections: Array<{
|
|
87
|
+
offset: number;
|
|
88
|
+
insertText: string;
|
|
89
|
+
testId: string;
|
|
90
|
+
tag: string;
|
|
91
|
+
lineNumber: number;
|
|
92
|
+
}> = [];
|
|
93
|
+
|
|
94
|
+
function walk(node: any) {
|
|
95
|
+
if (node.type !== NODE_TYPES.ELEMENT) return;
|
|
96
|
+
|
|
97
|
+
const tag = node.tag;
|
|
98
|
+
if (!tag || IGNORED_TAGS.has(tag.toLowerCase())) {
|
|
99
|
+
processChildren(node);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hasDataTestId = node.props?.some((prop: any) => {
|
|
104
|
+
if (prop.type === NODE_TYPES.ATTRIBUTE) {
|
|
105
|
+
return prop.name === 'data-testid';
|
|
106
|
+
}
|
|
107
|
+
if (prop.type === NODE_TYPES.DIRECTIVE && prop.name === 'bind' && prop.arg) {
|
|
108
|
+
return (prop.arg as any)?.content === 'data-testid';
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (!hasDataTestId) {
|
|
114
|
+
const startOffset = templateStartOffset + node.loc.start.offset;
|
|
115
|
+
const endOffset = templateStartOffset + node.loc.end.offset;
|
|
116
|
+
const lineNumber = content.slice(0, startOffset).split('\n').length;
|
|
117
|
+
const fullTagSource = content.slice(startOffset, endOffset);
|
|
118
|
+
const isSelfClosing = fullTagSource.trimEnd().endsWith('/>');
|
|
119
|
+
|
|
120
|
+
const testId = genTestId.generateTestId({
|
|
121
|
+
componentName: tag,
|
|
122
|
+
filePath,
|
|
123
|
+
lineNumber,
|
|
124
|
+
metadata: {
|
|
125
|
+
elementType: `vue:${tag}`,
|
|
126
|
+
textContent: tag,
|
|
127
|
+
attributes: {}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// 🔥 Надёжное определение позиции вставки
|
|
132
|
+
const { offset: relativeOffset, prefix } = findInsertPosition(fullTagSource, isSelfClosing);
|
|
133
|
+
const insertOffset = startOffset + relativeOffset;
|
|
134
|
+
const insertText = ` ${prefix}data-testid="${testId}"`;
|
|
135
|
+
|
|
136
|
+
injections.push({ offset: insertOffset, insertText, testId, tag, lineNumber });
|
|
137
|
+
result.elements.push({ testId, lineNumber, elementType: `vue:${tag}` });
|
|
138
|
+
result.injectedCount++;
|
|
139
|
+
console.log(` ✅ ${tag} (строка ${lineNumber}): ${testId}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
processChildren(node);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function processChildren(node: any) {
|
|
146
|
+
if (node.children && Array.isArray(node.children)) {
|
|
147
|
+
for (const child of node.children) {
|
|
148
|
+
walk(child);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const child of ast.children) {
|
|
154
|
+
walk(child);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Применяем инъекции от конца к началу
|
|
158
|
+
if (injections.length > 0) {
|
|
159
|
+
injections.sort((a, b) => b.offset - a.offset);
|
|
160
|
+
let modified = content;
|
|
161
|
+
for (const inj of injections) {
|
|
162
|
+
modified = modified.slice(0, inj.offset) + inj.insertText + modified.slice(inj.offset);
|
|
163
|
+
}
|
|
164
|
+
result.modifiedContent = modified;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(` 📊 Всего добавлено testid: ${result.injectedCount}`);
|
|
168
|
+
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.warn(`⚠️ Ошибка при парсинге Vue файла ${filePath}:`, error);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result;
|
|
174
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2020", "DOM"],
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"allowSyntheticDefaultImports": true,
|
|
16
|
+
"jsx": "react-jsx",
|
|
17
|
+
"types": ["node"],
|
|
18
|
+
"allowJs": true,
|
|
19
|
+
"checkJs": false
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*"],
|
|
22
|
+
"exclude": ["node_modules", "dist"],
|
|
23
|
+
"files": ["src/cli/index.ts"]
|
|
24
|
+
}
|