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.
@@ -0,0 +1,132 @@
1
+ // src/shared/parser.ts
2
+ import { parse, compileTemplate } from '@vue/compiler-sfc';
3
+ const NODE_TYPES = {
4
+ ELEMENT: 1,
5
+ ATTRIBUTE: 6,
6
+ DIRECTIVE: 7
7
+ };
8
+ const IGNORED_TAGS = new Set([
9
+ 'template', 'slot', 'component', 'keep-alive',
10
+ 'transition', 'transition-group'
11
+ ]);
12
+ /**
13
+ * Надёжно находит позицию для вставки атрибута в тег
14
+ */
15
+ function findInsertPosition(tagSource, isSelfClosing) {
16
+ if (isSelfClosing) {
17
+ // Для <Comp ... /> — вставляем перед />
18
+ const closingIndex = tagSource.lastIndexOf('/>');
19
+ return { offset: closingIndex, prefix: '' };
20
+ }
21
+ else {
22
+ // Для <Comp ...> — вставляем сразу после имени тега
23
+ // Находим конец имени тега: <TagName[пробел|атрибут|>|\n]
24
+ const tagNameMatch = tagSource.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
25
+ if (!tagNameMatch)
26
+ return { offset: 1, prefix: '' }; // fallback после <
27
+ const afterTagName = tagSource.slice(tagNameMatch[0].length);
28
+ // Если сразу после имени тега идёт > — вставляем перед ним
29
+ if (afterTagName.trimStart().startsWith('>')) {
30
+ const gtIndex = tagSource.indexOf('>', tagNameMatch[0].length);
31
+ return { offset: gtIndex, prefix: '' };
32
+ }
33
+ // Иначе вставляем после имени тега (до первого пробела/атрибута)
34
+ return { offset: tagNameMatch[0].length, prefix: ' ' };
35
+ }
36
+ }
37
+ export async function parseVueFile(content, filePath, genTestId) {
38
+ const result = {
39
+ modifiedContent: content,
40
+ injectedCount: 0,
41
+ elements: []
42
+ };
43
+ try {
44
+ const { descriptor, errors } = parse(content, { filename: filePath });
45
+ if (errors?.length > 0) {
46
+ console.warn(`⚠️ Ошибки парсинга SFC ${filePath}:`, errors.map(e => e.message || String(e)));
47
+ return result;
48
+ }
49
+ if (!descriptor.template) {
50
+ console.log(` ⚪ Нет секции <template> в ${filePath}`);
51
+ return result;
52
+ }
53
+ const templateSource = descriptor.template.content;
54
+ const templateStartOffset = descriptor.template.loc.start.offset;
55
+ const { ast } = compileTemplate({
56
+ source: templateSource,
57
+ filename: filePath,
58
+ id: 'gen-testid-injector'
59
+ });
60
+ if (!ast?.children)
61
+ return result;
62
+ const injections = [];
63
+ function walk(node) {
64
+ if (node.type !== NODE_TYPES.ELEMENT)
65
+ return;
66
+ const tag = node.tag;
67
+ if (!tag || IGNORED_TAGS.has(tag.toLowerCase())) {
68
+ processChildren(node);
69
+ return;
70
+ }
71
+ const hasDataTestId = node.props?.some((prop) => {
72
+ if (prop.type === NODE_TYPES.ATTRIBUTE) {
73
+ return prop.name === 'data-testid';
74
+ }
75
+ if (prop.type === NODE_TYPES.DIRECTIVE && prop.name === 'bind' && prop.arg) {
76
+ return prop.arg?.content === 'data-testid';
77
+ }
78
+ return false;
79
+ });
80
+ if (!hasDataTestId) {
81
+ const startOffset = templateStartOffset + node.loc.start.offset;
82
+ const endOffset = templateStartOffset + node.loc.end.offset;
83
+ const lineNumber = content.slice(0, startOffset).split('\n').length;
84
+ const fullTagSource = content.slice(startOffset, endOffset);
85
+ const isSelfClosing = fullTagSource.trimEnd().endsWith('/>');
86
+ const testId = genTestId.generateTestId({
87
+ componentName: tag,
88
+ filePath,
89
+ lineNumber,
90
+ metadata: {
91
+ elementType: `vue:${tag}`,
92
+ textContent: tag,
93
+ attributes: {}
94
+ }
95
+ });
96
+ // 🔥 Надёжное определение позиции вставки
97
+ const { offset: relativeOffset, prefix } = findInsertPosition(fullTagSource, isSelfClosing);
98
+ const insertOffset = startOffset + relativeOffset;
99
+ const insertText = ` ${prefix}data-testid="${testId}"`;
100
+ injections.push({ offset: insertOffset, insertText, testId, tag, lineNumber });
101
+ result.elements.push({ testId, lineNumber, elementType: `vue:${tag}` });
102
+ result.injectedCount++;
103
+ console.log(` ✅ ${tag} (строка ${lineNumber}): ${testId}`);
104
+ }
105
+ processChildren(node);
106
+ }
107
+ function processChildren(node) {
108
+ if (node.children && Array.isArray(node.children)) {
109
+ for (const child of node.children) {
110
+ walk(child);
111
+ }
112
+ }
113
+ }
114
+ for (const child of ast.children) {
115
+ walk(child);
116
+ }
117
+ // Применяем инъекции от конца к началу
118
+ if (injections.length > 0) {
119
+ injections.sort((a, b) => b.offset - a.offset);
120
+ let modified = content;
121
+ for (const inj of injections) {
122
+ modified = modified.slice(0, inj.offset) + inj.insertText + modified.slice(inj.offset);
123
+ }
124
+ result.modifiedContent = modified;
125
+ }
126
+ console.log(` 📊 Всего добавлено testid: ${result.injectedCount}`);
127
+ }
128
+ catch (error) {
129
+ console.warn(`⚠️ Ошибка при парсинге Vue файла ${filePath}:`, error);
130
+ }
131
+ return result;
132
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "gen-testid",
3
+ "version": "1.0.0",
4
+ "description": "Автоматическая простановка data-testid в Vue проектах",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "gen-testid": "./bin/cli.js"
10
+ },
11
+ "dependencies": {
12
+ "@vue/compiler-sfc": "^3.5.32",
13
+ "commander": "^11.1.0",
14
+ "fast-glob": "^3.3.2"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^20.8.0",
18
+ "typescript": "^5.2.0"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsc --watch"
23
+ }
24
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { GenTestId } from '../core/GenTestId.js';
5
+ import { initCommand } from './init.js';
6
+ import * as path from 'path';
7
+ import * as fs from 'fs';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('gen-testid')
13
+ .description('Автоматическая простановка data-testid в Vue проектах')
14
+ .version('1.0.1');
15
+
16
+ program
17
+ .command('init')
18
+ .description('Инициализация конфигурации')
19
+ .action(initCommand);
20
+
21
+ program
22
+ .command('inject [files...]')
23
+ .description('Проставить testid в указанные файлы')
24
+ .option('-c, --config <path>', 'путь к конфигурации')
25
+ .option('-s, --strategy <name>', 'стратегия именования')
26
+ .option('-p, --prefix <prefix>', 'префикс для testid')
27
+ .option('-d, --dry-run', 'пробный запуск без изменений')
28
+ .action(async (files, options) => {
29
+ console.log('🔍 Инжекция testid...\n');
30
+
31
+ try {
32
+ const config = loadConfig(options.config);
33
+
34
+ const genTestId = new GenTestId({
35
+ ...config,
36
+ strategy: options.strategy || config.strategy,
37
+ prefix: options.prefix || config.prefix
38
+ });
39
+
40
+ if (files.length === 0) {
41
+ console.log('📁 Сканирование всех Vue файлов...');
42
+ const results = await genTestId.processAllFiles();
43
+ printResults(results, options.dryRun);
44
+ } else {
45
+ console.log(`📁 Обработка ${files.length} файлов...`);
46
+ const results = [];
47
+ for (const file of files) {
48
+ const fullPath = path.resolve(process.cwd(), file);
49
+ const result = await genTestId.processFile(fullPath);
50
+ results.push(result);
51
+ }
52
+ printResults(results, options.dryRun);
53
+ }
54
+
55
+ } catch (error) {
56
+ console.error('\n❌ Ошибка:', error);
57
+ process.exit(1);
58
+ }
59
+ });
60
+
61
+ program.parse(process.argv);
62
+
63
+ function loadConfig(configPath?: string): any {
64
+ if (configPath) {
65
+ const fullPath = path.resolve(process.cwd(), configPath);
66
+ if (fs.existsSync(fullPath)) {
67
+ return JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
68
+ }
69
+ }
70
+
71
+ const rcPath = path.resolve(process.cwd(), '.gentestidrc');
72
+ if (fs.existsSync(rcPath)) {
73
+ return JSON.parse(fs.readFileSync(rcPath, 'utf-8'));
74
+ }
75
+
76
+ return {};
77
+ }
78
+
79
+ function printResults(results: any[], dryRun?: boolean) {
80
+ const total = results.reduce((sum, r) => sum + r.injectedCount, 0);
81
+ const filesWithChanges = results.filter(r => r.injectedCount > 0).length;
82
+
83
+ console.log(`\n${dryRun ? '📋' : '✅'} Результаты:`);
84
+ console.log(` Всего файлов: ${results.length}`);
85
+ console.log(` Файлов с изменениями: ${filesWithChanges}`);
86
+ console.log(` ${dryRun ? 'Будет добавлено' : 'Добавлено'} testid: ${total}`);
87
+
88
+ if (total > 0) {
89
+ console.log('\n📝 Детали:');
90
+ results
91
+ .filter(r => r.injectedCount > 0)
92
+ .slice(0, 5)
93
+ .forEach(r => {
94
+ const relativePath = path.relative(process.cwd(), r.filePath);
95
+ console.log(` 📁 ${relativePath}: +${r.injectedCount} testid`);
96
+ });
97
+
98
+ if (results.filter(r => r.injectedCount > 0).length > 5) {
99
+ console.log(` ... и ещё ${results.filter(r => r.injectedCount > 0).length - 5} файлов`);
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,114 @@
1
+ // src/cli/init.ts
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import * as readline from 'readline';
5
+
6
+ export async function initCommand() {
7
+ console.log('🚀 Инициализация gen-testid для Vue...\n');
8
+
9
+ try {
10
+ console.log('📋 Доступные стратегии:');
11
+ console.log(' 1. hierarchical (default) - иерархические ID (test__Component__elem-1)');
12
+ console.log(' 2. stable-uuid - стабильные UUID');
13
+ console.log(' 3. minimal - простые номера');
14
+ console.log(' 4. semantic - умные ID через Ollama (бесплатно, локально)\n');
15
+
16
+ const strategyChoice = await askQuestion('Выберите стратегию (1-4) [1]: ');
17
+
18
+ let strategy = 'hierarchical';
19
+ let semanticModel = '';
20
+ let ollamaUrl = '';
21
+
22
+ switch (strategyChoice) {
23
+ case '2':
24
+ strategy = 'stable-uuid';
25
+ break;
26
+ case '3':
27
+ strategy = 'minimal';
28
+ break;
29
+ case '4':
30
+ strategy = 'semantic';
31
+ console.log('\n🤖 Настройка семантической генерации через Ollama:');
32
+ console.log(' Требуется Ollama');
33
+ console.log(' Установка: brew install ollama && ollama pull llama3.2:3b');
34
+ console.log(' Запуск: ollama serve\n');
35
+
36
+ const model = await askQuestion('Модель (llama3.2:3b/mistral/gemma:2b) [llama3.2:3b]: ');
37
+ semanticModel = model || 'llama3.2:3b';
38
+
39
+ const url = await askQuestion('URL Ollama [http://localhost:11434]: ');
40
+ ollamaUrl = url || 'http://localhost:11434';
41
+
42
+ console.log('\n💡 Совет: Запустите "ollama serve" в отдельном терминале');
43
+ break;
44
+ default:
45
+ strategy = 'hierarchical';
46
+ }
47
+
48
+ const prefix = await askQuestion('Префикс для testid [default: test]: ');
49
+
50
+ const config = {
51
+ strategy,
52
+ prefix: prefix || 'test',
53
+ semanticModel: semanticModel,
54
+ ollamaUrl: ollamaUrl,
55
+ includeFiles: ['src/**/*.vue'],
56
+ excludeFiles: ['**/*.test.*', '**/node_modules/**', '**/dist/**']
57
+ };
58
+
59
+ const configPath = path.join(process.cwd(), '.gentestidrc');
60
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
61
+ console.log(`\n✅ Конфигурация сохранена в ${configPath}`);
62
+
63
+ if (strategy === 'semantic') {
64
+ console.log('\n🤖 Семантическая генерация активирована!');
65
+ console.log(' При первом запуске ID будут иерархическими,');
66
+ console.log(' а Ollama в фоне будет генерировать умные ID.');
67
+ }
68
+
69
+ await updatePackageJson();
70
+
71
+ console.log('\n📝 Следующие шаги:');
72
+ console.log(' 1. Запустите Ollama: ollama serve');
73
+ console.log(' 2. Запустите "npx gen-testid inject" для простановки testid');
74
+ console.log(' 3. Готово! 🎉\n');
75
+
76
+ } catch (error) {
77
+ console.error('❌ Ошибка при инициализации:', error);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ async function updatePackageJson() {
83
+ try {
84
+ const pkgPath = path.join(process.cwd(), 'package.json');
85
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
86
+
87
+ if (!pkg.scripts) {
88
+ pkg.scripts = {};
89
+ }
90
+
91
+ if (!pkg.scripts['testids']) {
92
+ pkg.scripts['testids'] = 'gen-testid inject';
93
+ }
94
+
95
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2));
96
+ console.log('✅ Скрипты добавлены в package.json');
97
+ } catch (error) {
98
+ console.log('⚠️ Не удалось обновить package.json');
99
+ }
100
+ }
101
+
102
+ function askQuestion(question: string): Promise<string> {
103
+ const rl = readline.createInterface({
104
+ input: process.stdin,
105
+ output: process.stdout
106
+ });
107
+
108
+ return new Promise(resolve => {
109
+ rl.question(question, answer => {
110
+ rl.close();
111
+ resolve(answer);
112
+ });
113
+ });
114
+ }
@@ -0,0 +1,169 @@
1
+ // src/core/GenTestId.ts
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import fg from 'fast-glob';
5
+ import {
6
+ GenTestIdConfig,
7
+ ElementContext,
8
+ NamingStrategy,
9
+ InjectionResult
10
+ } from './types.js';
11
+ import {
12
+ HierarchicalStrategy,
13
+ StableUuidStrategy,
14
+ MinimalStrategy,
15
+ SemanticStrategy
16
+ } from './strategies.js';
17
+ import { ElementCounter } from '../shared/element-counter.js';
18
+ import { parseVueFile } from '../shared/parser.js';
19
+
20
+ export class GenTestId {
21
+ public config: Required<GenTestIdConfig>;
22
+ private strategy: NamingStrategy;
23
+ private counter: ElementCounter;
24
+
25
+ constructor(config: GenTestIdConfig = {}) {
26
+ this.config = {
27
+ prefix: 'test',
28
+ strategy: 'hierarchical',
29
+ semanticModel: '',
30
+ ollamaUrl: 'http://localhost:11434',
31
+ excludeFiles: ['**/*.test.*', '**/*.spec.*', '**/node_modules/**', '**/dist/**'],
32
+ includeFiles: ['src/**/*.vue'],
33
+ ...config
34
+ } as Required<GenTestIdConfig>;
35
+
36
+ this.counter = new ElementCounter();
37
+ this.strategy = this.initStrategy();
38
+ }
39
+
40
+ private initStrategy(): NamingStrategy {
41
+ switch (this.config.strategy) {
42
+ case 'hierarchical':
43
+ return new HierarchicalStrategy(this.config.prefix);
44
+ case 'stable-uuid':
45
+ return new StableUuidStrategy(this.config.prefix);
46
+ case 'minimal':
47
+ return new MinimalStrategy(this.config.prefix);
48
+ case 'semantic':
49
+ return new SemanticStrategy(
50
+ this.config.prefix,
51
+ this.config.semanticModel,
52
+ this.config.ollamaUrl
53
+ );
54
+ default:
55
+ return new HierarchicalStrategy(this.config.prefix);
56
+ }
57
+ }
58
+
59
+ public generateTestId(context: Omit<ElementContext, 'occurrence'>): string {
60
+ const key = `${context.filePath}:${context.componentName}:${context.lineNumber || 'unknown'}`;
61
+ const occurrence = this.counter.increment(key);
62
+
63
+ const fullContext: ElementContext = {
64
+ ...context,
65
+ occurrence,
66
+ lineNumber: context.lineNumber || 0
67
+ };
68
+
69
+ return this.strategy.generateId(fullContext);
70
+ }
71
+
72
+ public async processFile(filePath: string): Promise<InjectionResult> {
73
+ try {
74
+ if (this.shouldExcludeFile(filePath)) {
75
+ return { filePath, injectedCount: 0, elements: [] };
76
+ }
77
+
78
+ let content = await fs.readFile(filePath, 'utf-8');
79
+
80
+ //Добавляем testid
81
+ let parseResult: { injectedCount: number; modifiedContent: string; elements: any[] } = {
82
+ injectedCount: 0,
83
+ modifiedContent: content,
84
+ elements: []
85
+ };
86
+
87
+ if (filePath.endsWith('.vue')) {
88
+ parseResult = await parseVueFile(content, filePath, this);
89
+ if (parseResult.injectedCount > 0) {
90
+ content = parseResult.modifiedContent;
91
+ }
92
+ }
93
+
94
+ //Save
95
+ if (parseResult.injectedCount > 0) {
96
+ await fs.writeFile(filePath, content, 'utf-8');
97
+ }
98
+
99
+ return {
100
+ filePath,
101
+ injectedCount: parseResult.injectedCount,
102
+ elements: parseResult.elements
103
+ };
104
+
105
+ } catch (error) {
106
+ console.error(`❌ Error processing ${filePath}:`, error);
107
+ return { filePath, injectedCount: 0, elements: [] };
108
+ }
109
+ }
110
+
111
+ private shouldExcludeFile(filePath: string): boolean {
112
+ const relativePath = path.relative(process.cwd(), filePath);
113
+
114
+ for (const pattern of this.config.excludeFiles) {
115
+ if (pattern.includes('*')) {
116
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
117
+ if (regex.test(relativePath)) {
118
+ return true;
119
+ }
120
+ } else if (relativePath.includes(pattern)) {
121
+ return true;
122
+ }
123
+ }
124
+
125
+ return false;
126
+ }
127
+
128
+ public async processAllFiles(): Promise<InjectionResult[]> {
129
+ console.log('🔍 Поиск файлов...');
130
+
131
+ const files: string[] = [];
132
+
133
+ for (const pattern of this.config.includeFiles) {
134
+ try {
135
+ const matches = await fg(pattern, {
136
+ ignore: this.config.excludeFiles,
137
+ absolute: true,
138
+ onlyFiles: true
139
+ });
140
+ files.push(...matches);
141
+ } catch (error) {
142
+ console.warn(`⚠️ Ошибка при поиске по паттерну ${pattern}:`, error);
143
+ }
144
+ }
145
+
146
+ console.log(`📁 Найдено файлов: ${files.length}\n`);
147
+
148
+ const results: InjectionResult[] = [];
149
+ let totalTestIds = 0;
150
+
151
+ for (const file of files) {
152
+ console.log(`📄 Обработка: ${path.basename(file)}`);
153
+ const result = await this.processFile(file);
154
+ results.push(result);
155
+
156
+ totalTestIds += result.injectedCount;
157
+
158
+ if (result.injectedCount > 0) {
159
+ console.log(` ✅ Добавлено testid: ${result.injectedCount}\n`);
160
+ }
161
+ }
162
+
163
+ if (totalTestIds > 0) {
164
+ console.log(`\n🏷️ Всего добавлено testid: ${totalTestIds}`);
165
+ }
166
+
167
+ return results;
168
+ }
169
+ }