viberadar 0.3.57 → 0.3.59
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/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +154 -6
- package/dist/scanner/index.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +231 -6
- package/dist/server/index.js.map +1 -1
- package/dist/ui/dashboard.html +92 -92
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -241,6 +241,147 @@ function fmtTool(name, input = {}) {
|
|
|
241
241
|
}
|
|
242
242
|
// ─── Test runner after agent ──────────────────────────────────────────────────
|
|
243
243
|
const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
|
|
244
|
+
function stripAnsi(text) {
|
|
245
|
+
return text.replace(/\u001b\[[0-9;]*m/g, '');
|
|
246
|
+
}
|
|
247
|
+
function extractBalancedJsonObjects(text) {
|
|
248
|
+
const result = [];
|
|
249
|
+
let start = -1;
|
|
250
|
+
let depth = 0;
|
|
251
|
+
let inString = false;
|
|
252
|
+
let escapeNext = false;
|
|
253
|
+
for (let i = 0; i < text.length; i++) {
|
|
254
|
+
const ch = text[i];
|
|
255
|
+
if (start === -1) {
|
|
256
|
+
if (ch === '{') {
|
|
257
|
+
start = i;
|
|
258
|
+
depth = 1;
|
|
259
|
+
inString = false;
|
|
260
|
+
escapeNext = false;
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (inString) {
|
|
265
|
+
if (escapeNext) {
|
|
266
|
+
escapeNext = false;
|
|
267
|
+
}
|
|
268
|
+
else if (ch === '\\') {
|
|
269
|
+
escapeNext = true;
|
|
270
|
+
}
|
|
271
|
+
else if (ch === '"') {
|
|
272
|
+
inString = false;
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (ch === '"') {
|
|
277
|
+
inString = true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (ch === '{') {
|
|
281
|
+
depth += 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (ch === '}') {
|
|
285
|
+
depth -= 1;
|
|
286
|
+
if (depth === 0) {
|
|
287
|
+
result.push(text.slice(start, i + 1));
|
|
288
|
+
start = -1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
function toNonEmptyString(value) {
|
|
295
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
296
|
+
}
|
|
297
|
+
function toStringArray(value) {
|
|
298
|
+
if (!Array.isArray(value))
|
|
299
|
+
return [];
|
|
300
|
+
return value
|
|
301
|
+
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
|
302
|
+
.filter((item) => item.length > 0);
|
|
303
|
+
}
|
|
304
|
+
function normalizeE2eCaseId(value, index) {
|
|
305
|
+
const normalized = value
|
|
306
|
+
.toLowerCase()
|
|
307
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
308
|
+
.replace(/-+/g, '-')
|
|
309
|
+
.replace(/^-|-$/g, '');
|
|
310
|
+
return normalized || `e2e-case-${index + 1}`;
|
|
311
|
+
}
|
|
312
|
+
function sanitizeParsedE2eTestCases(value) {
|
|
313
|
+
if (!Array.isArray(value))
|
|
314
|
+
return [];
|
|
315
|
+
const usedIds = new Set();
|
|
316
|
+
const result = [];
|
|
317
|
+
for (let i = 0; i < value.length; i++) {
|
|
318
|
+
const raw = value[i];
|
|
319
|
+
if (!raw || typeof raw !== 'object')
|
|
320
|
+
continue;
|
|
321
|
+
const item = raw;
|
|
322
|
+
const name = toNonEmptyString(item.name) ?? `E2E кейс ${i + 1}`;
|
|
323
|
+
const description = toNonEmptyString(item.description) ?? name;
|
|
324
|
+
const steps = toStringArray(item.steps);
|
|
325
|
+
const expectedResults = toStringArray(item.expectedResults);
|
|
326
|
+
if (steps.length === 0 || expectedResults.length === 0)
|
|
327
|
+
continue;
|
|
328
|
+
let idBase = normalizeE2eCaseId(toNonEmptyString(item.id) ?? `e2e-case-${i + 1}`, i);
|
|
329
|
+
let id = idBase;
|
|
330
|
+
let suffix = 2;
|
|
331
|
+
while (usedIds.has(id)) {
|
|
332
|
+
id = `${idBase}-${suffix}`;
|
|
333
|
+
suffix += 1;
|
|
334
|
+
}
|
|
335
|
+
usedIds.add(id);
|
|
336
|
+
result.push({
|
|
337
|
+
id,
|
|
338
|
+
name,
|
|
339
|
+
description,
|
|
340
|
+
steps,
|
|
341
|
+
expectedResults,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
function parseE2ePlanFromAgentOutput(rawOutput) {
|
|
347
|
+
const cleaned = stripAnsi(rawOutput).replace(/\u0000/g, '').trim();
|
|
348
|
+
if (!cleaned) {
|
|
349
|
+
throw new Error('пустой ответ агента');
|
|
350
|
+
}
|
|
351
|
+
const candidates = [];
|
|
352
|
+
const fencedRe = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
353
|
+
let fencedMatch = null;
|
|
354
|
+
while ((fencedMatch = fencedRe.exec(cleaned)) !== null) {
|
|
355
|
+
const block = fencedMatch[1]?.trim();
|
|
356
|
+
if (block)
|
|
357
|
+
candidates.push(block);
|
|
358
|
+
}
|
|
359
|
+
candidates.push(...extractBalancedJsonObjects(cleaned));
|
|
360
|
+
candidates.push(cleaned);
|
|
361
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
362
|
+
const candidate = candidates[i];
|
|
363
|
+
let parsed;
|
|
364
|
+
try {
|
|
365
|
+
parsed = JSON.parse(candidate);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!parsed || typeof parsed !== 'object')
|
|
371
|
+
continue;
|
|
372
|
+
const obj = parsed;
|
|
373
|
+
const testCases = sanitizeParsedE2eTestCases(obj.testCases);
|
|
374
|
+
if (testCases.length === 0)
|
|
375
|
+
continue;
|
|
376
|
+
const baseUrl = toNonEmptyString(obj.baseUrl) ?? undefined;
|
|
377
|
+
return { baseUrl, testCases };
|
|
378
|
+
}
|
|
379
|
+
const hasTestCasesKey = cleaned.toLowerCase().includes('"testcases"');
|
|
380
|
+
if (hasTestCasesKey) {
|
|
381
|
+
throw new Error('ответ похож на JSON-план, но он обрезан или повреждён');
|
|
382
|
+
}
|
|
383
|
+
throw new Error('в ответе агента не найден валидный JSON с testCases');
|
|
384
|
+
}
|
|
244
385
|
function runTestFiles(files, projectRoot) {
|
|
245
386
|
return new Promise((resolve) => {
|
|
246
387
|
// Write JSON to a temp file — avoids stdout encoding/regex issues on Windows
|
|
@@ -513,14 +654,28 @@ function buildWriteTestsForSelectedPrompt(filePaths, feat, modules, testRunner)
|
|
|
513
654
|
`Выбрано файлов (${selectedModules.length}):`,
|
|
514
655
|
...selectedLines,
|
|
515
656
|
'',
|
|
516
|
-
|
|
657
|
+
`Критерии высокого стандарта (обязательно):`,
|
|
517
658
|
`- Работай только с выбранными файлами из списка`,
|
|
659
|
+
`- Для КАЖДОГО выбранного файла сделай явный результат: created | updated | already-covered | blocked`,
|
|
518
660
|
`- Если теста нет — создай`,
|
|
519
|
-
`- Если тест устарел —
|
|
661
|
+
`- Если тест устарел или слабый — обнови/дополни`,
|
|
520
662
|
`- Для unit файлов мокай внешние зависимости`,
|
|
521
663
|
`- Для integration файлов используй test-helpers или pg-mem`,
|
|
522
664
|
`- Используй ${testRunner}`,
|
|
523
665
|
`- Следуй текущим паттернам тестов в проекте`,
|
|
666
|
+
`- Не завершай задачу без отчета по каждому выбранному файлу`,
|
|
667
|
+
'',
|
|
668
|
+
`Формат финального ответа (строго):`,
|
|
669
|
+
`1) "Матрица покрытия (X/${selectedModules.length})"`,
|
|
670
|
+
` Для каждого выбранного файла отдельная строка:`,
|
|
671
|
+
` - source: <path> | status: <created|updated|already-covered|blocked> | testFile: <path или -> | note: <что сделано/почему blocked>`,
|
|
672
|
+
`2) "Проверка"`,
|
|
673
|
+
` - какие одноразовые команды запускал`,
|
|
674
|
+
` - итог (сколько тестов passed/failed)`,
|
|
675
|
+
`3) "Осталось без тестов"`,
|
|
676
|
+
` - перечисли файлы из выбранного списка, которые все еще без тестов (если есть), иначе напиши "нет"`,
|
|
677
|
+
`4) "Conventional Commit title"`,
|
|
678
|
+
` - одна строка в формате Conventional Commits`,
|
|
524
679
|
].join('\n');
|
|
525
680
|
}
|
|
526
681
|
function buildRefreshTestsForSelectedPrompt(filePaths, feat, modules, testRunner) {
|
|
@@ -539,12 +694,25 @@ function buildRefreshTestsForSelectedPrompt(filePaths, feat, modules, testRunner
|
|
|
539
694
|
`Выбрано файлов (${selectedModules.length}):`,
|
|
540
695
|
...selectedLines,
|
|
541
696
|
'',
|
|
697
|
+
`Критерии высокого стандарта (обязательно):`,
|
|
698
|
+
`- Для КАЖДОГО выбранного файла дай итог: updated | already-covered | blocked`,
|
|
699
|
+
`- Не пропускай файлы молча: если не изменил, объясни почему`,
|
|
700
|
+
'',
|
|
542
701
|
`Для каждого выбранного файла:`,
|
|
543
702
|
`1) Проверь актуальность и качество тестов.`,
|
|
544
703
|
`2) Дополни недостающие сценарии (happy path, edge cases, ошибки).`,
|
|
545
704
|
`3) Если тест отсутствует — создай новый.`,
|
|
546
705
|
`4) Не меняй source-код без крайней необходимости; фокус на тестах.`,
|
|
547
706
|
`5) Используй ${testRunner} и паттерны проекта.`,
|
|
707
|
+
'',
|
|
708
|
+
`Формат финального ответа (строго):`,
|
|
709
|
+
`1) "Матрица покрытия (X/${selectedModules.length})"`,
|
|
710
|
+
` - source: <path> | status: <updated|already-covered|blocked> | testFile: <path или -> | note: <кратко>`,
|
|
711
|
+
`2) "Проверка"`,
|
|
712
|
+
` - одноразовые команды + результат`,
|
|
713
|
+
`3) "Осталось без тестов"`,
|
|
714
|
+
` - список из выбранных, если такие остались`,
|
|
715
|
+
`4) "Conventional Commit title"`,
|
|
548
716
|
].join('\n');
|
|
549
717
|
}
|
|
550
718
|
function buildFixTestsPrompt(filePath, errors) {
|
|
@@ -764,6 +932,21 @@ function startServer({ data: initialData, port, projectRoot }) {
|
|
|
764
932
|
/** Actually spawn the agent process for a queue item */
|
|
765
933
|
function executeAgentItem(item) {
|
|
766
934
|
const { task, featureKey, filePath, selectedFilePaths, title, agent, savedErrors, savedFailedFiles, savedTestType, autoFixAttempt = 0, autoFixSourceTask, } = item;
|
|
935
|
+
const normalizeRelPath = (p) => p.replace(/\\/g, '/');
|
|
936
|
+
const targetSourcePaths = (() => {
|
|
937
|
+
if (task === 'write-tests-file' && filePath) {
|
|
938
|
+
return [normalizeRelPath(filePath)];
|
|
939
|
+
}
|
|
940
|
+
if ((task === 'write-tests-selected' || task === 'refresh-tests-selected') && Array.isArray(selectedFilePaths)) {
|
|
941
|
+
return Array.from(new Set(selectedFilePaths.map(normalizeRelPath)));
|
|
942
|
+
}
|
|
943
|
+
if (task === 'write-tests' && featureKey) {
|
|
944
|
+
return currentData.modules
|
|
945
|
+
.filter((m) => m.featureKeys.includes(featureKey) && m.type !== 'test' && !m.hasTests && !m.isInfra)
|
|
946
|
+
.map((m) => normalizeRelPath(m.relativePath));
|
|
947
|
+
}
|
|
948
|
+
return [];
|
|
949
|
+
})();
|
|
767
950
|
// Build prompt lazily at execution time
|
|
768
951
|
let prompt;
|
|
769
952
|
if (task === 'write-tests') {
|
|
@@ -883,6 +1066,7 @@ function startServer({ data: initialData, port, projectRoot }) {
|
|
|
883
1066
|
const createdTestFiles = [];
|
|
884
1067
|
// Accumulate full result text for E2E plan parsing
|
|
885
1068
|
let agentResultText = '';
|
|
1069
|
+
let agentRawOutputText = '';
|
|
886
1070
|
let queueBlockSignal = null;
|
|
887
1071
|
function inspectQueueBlockSignal(line) {
|
|
888
1072
|
if (queueBlockSignal !== null)
|
|
@@ -918,6 +1102,9 @@ function startServer({ data: initialData, port, projectRoot }) {
|
|
|
918
1102
|
rl.on('line', (raw) => {
|
|
919
1103
|
if (!raw.trim())
|
|
920
1104
|
return;
|
|
1105
|
+
if (task === 'generate-e2e-plan') {
|
|
1106
|
+
agentRawOutputText += raw + '\n';
|
|
1107
|
+
}
|
|
921
1108
|
inspectQueueBlockSignal(raw);
|
|
922
1109
|
trackWrittenFiles(raw);
|
|
923
1110
|
const parsed = agent === 'claude' ? parseClaudeEvent(raw) : raw;
|
|
@@ -944,6 +1131,9 @@ function startServer({ data: initialData, port, projectRoot }) {
|
|
|
944
1131
|
proc.stderr.on('data', (chunk) => {
|
|
945
1132
|
const lines = chunk.toString().split('\n').filter(Boolean);
|
|
946
1133
|
for (const line of lines) {
|
|
1134
|
+
if (task === 'generate-e2e-plan') {
|
|
1135
|
+
agentRawOutputText += line + '\n';
|
|
1136
|
+
}
|
|
947
1137
|
inspectQueueBlockSignal(line);
|
|
948
1138
|
broadcast('agent-output', { line, isError: true });
|
|
949
1139
|
}
|
|
@@ -1070,8 +1260,8 @@ function startServer({ data: initialData, port, projectRoot }) {
|
|
|
1070
1260
|
// E2E plan post-processing
|
|
1071
1261
|
if (task === 'generate-e2e-plan' && featureKey) {
|
|
1072
1262
|
try {
|
|
1073
|
-
const
|
|
1074
|
-
const parsedPlan =
|
|
1263
|
+
const rawPlanOutput = agentResultText.trim().length > 0 ? agentResultText : agentRawOutputText;
|
|
1264
|
+
const parsedPlan = parseE2ePlanFromAgentOutput(rawPlanOutput);
|
|
1075
1265
|
const feat = currentData.features?.find(f => f.key === featureKey);
|
|
1076
1266
|
const plan = {
|
|
1077
1267
|
featureKey,
|
|
@@ -1079,13 +1269,19 @@ function startServer({ data: initialData, port, projectRoot }) {
|
|
|
1079
1269
|
generatedAt: new Date().toISOString(),
|
|
1080
1270
|
updatedAt: new Date().toISOString(),
|
|
1081
1271
|
baseUrl: parsedPlan.baseUrl,
|
|
1082
|
-
testCases:
|
|
1272
|
+
testCases: parsedPlan.testCases.map((tc) => ({ ...tc, status: 'pending' })),
|
|
1083
1273
|
};
|
|
1084
1274
|
saveE2ePlan(projectRoot, plan);
|
|
1085
1275
|
broadcast('e2e-plan-ready', { featureKey, plan });
|
|
1086
1276
|
}
|
|
1087
1277
|
catch (err) {
|
|
1088
|
-
|
|
1278
|
+
const blockHint = queueBlockSignal
|
|
1279
|
+
? ` Возможная причина: блокировка/лимит агента (${queueBlockSignal}).`
|
|
1280
|
+
: '';
|
|
1281
|
+
broadcast('e2e-plan-error', {
|
|
1282
|
+
featureKey,
|
|
1283
|
+
message: `Не удалось распарсить план: ${err.message}.${blockHint}`.trim(),
|
|
1284
|
+
});
|
|
1089
1285
|
}
|
|
1090
1286
|
}
|
|
1091
1287
|
if (task === 'write-e2e-tests' && featureKey) {
|
|
@@ -1102,6 +1298,35 @@ function startServer({ data: initialData, port, projectRoot }) {
|
|
|
1102
1298
|
process.stdout.write(' ✅ Agent done, rescanning...\n');
|
|
1103
1299
|
try {
|
|
1104
1300
|
currentData = await (0, scanner_1.scanProject)(projectRoot);
|
|
1301
|
+
if (targetSourcePaths.length > 0) {
|
|
1302
|
+
const srcByPath = new Map(currentData.modules
|
|
1303
|
+
.filter((m) => m.type !== 'test')
|
|
1304
|
+
.map((m) => [normalizeRelPath(m.relativePath), m]));
|
|
1305
|
+
const unresolved = targetSourcePaths.filter((rel) => {
|
|
1306
|
+
const mod = srcByPath.get(rel);
|
|
1307
|
+
return !!mod && !mod.hasTests && !mod.isInfra;
|
|
1308
|
+
});
|
|
1309
|
+
if (unresolved.length > 0) {
|
|
1310
|
+
broadcast('agent-output', {
|
|
1311
|
+
line: `⚠️ После задачи осталось без тестов: ${unresolved.length}/${targetSourcePaths.length} файлов`,
|
|
1312
|
+
isError: true,
|
|
1313
|
+
});
|
|
1314
|
+
unresolved.slice(0, 20).forEach((rel) => {
|
|
1315
|
+
broadcast('agent-output', { line: ` • ${rel}`, isError: true });
|
|
1316
|
+
});
|
|
1317
|
+
if (unresolved.length > 20) {
|
|
1318
|
+
broadcast('agent-output', {
|
|
1319
|
+
line: ` ... и еще ${unresolved.length - 20} файлов`,
|
|
1320
|
+
isError: true,
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
broadcast('agent-output', {
|
|
1326
|
+
line: `✅ Проверка: все ${targetSourcePaths.length} целевых файлов теперь отмечены как с тестами`,
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1105
1330
|
broadcast('data-updated');
|
|
1106
1331
|
}
|
|
1107
1332
|
catch { }
|