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.
@@ -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 jsonMatch = agentResultText.match(/\{[\s\S]*"testCases"[\s\S]*\}/);
1074
- const parsedPlan = JSON.parse(jsonMatch ? jsonMatch[0] : agentResultText);
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: (parsedPlan.testCases || []).map((tc) => ({ ...tc, status: 'pending' })),
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
- broadcast('e2e-plan-error', { featureKey, message: `Не удалось распарсить план: ${err.message}` });
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 { }