viberadar 0.3.45 → 0.3.47

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.
@@ -52,22 +52,21 @@ const WIN = process.platform === 'win32';
52
52
  * --output-format stream-json gives real-time events (tool calls, writes, etc.)
53
53
  * File piping avoids TUI mode in Claude Code v2+.
54
54
  */
55
- function buildAgentShellCmd(agent, taskFile, model) {
55
+ function buildAgentShellCmd(agent, taskFile) {
56
56
  const escaped = taskFile.replace(/\\/g, '\\\\');
57
- const modelFlag = (agent === 'claude' && model) ? ` --model ${model}` : '';
58
57
  if (WIN) {
59
58
  if (agent === 'claude')
60
- return `type "${escaped}" | claude.cmd --print --verbose --output-format stream-json${modelFlag}`;
59
+ return `type "${escaped}" | claude.cmd --print --verbose --output-format stream-json`;
61
60
  if (agent === 'codex')
62
- return `type "${escaped}" | codex.cmd exec - --color never --sandbox workspace-write`;
61
+ return `type "${escaped}" | codex.cmd`;
63
62
  }
64
63
  else {
65
64
  if (agent === 'claude')
66
- return `claude --print --verbose --output-format stream-json${modelFlag} < "${escaped}"`;
65
+ return `claude --print --verbose --output-format stream-json < "${escaped}"`;
67
66
  if (agent === 'codex')
68
- return `codex exec - --color never --sandbox workspace-write < "${escaped}"`;
67
+ return `codex < "${escaped}"`;
69
68
  }
70
- return `claude --print --verbose --output-format stream-json${modelFlag} < "${escaped}"`;
69
+ return `claude --print --verbose --output-format stream-json < "${escaped}"`;
71
70
  }
72
71
  /** Parse a Claude Code stream-json event into a human-readable line, or null to skip */
73
72
  function parseClaudeEvent(raw) {
@@ -126,60 +125,66 @@ function fmtTool(name, input = {}) {
126
125
  }
127
126
  // ─── Test runner after agent ──────────────────────────────────────────────────
128
127
  const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
129
- /**
130
- * Normalize a file path (possibly git-bash style /c/Users/...) to a path
131
- * relative to projectRoot, so vitest can always find it regardless of OS.
132
- */
133
- function toRelativeTestPath(filePath, projectRoot) {
134
- // Convert git-bash absolute path (/c/Users/foo) → Windows-style (c:/Users/foo)
135
- const normalized = filePath.replace(/^\/([a-zA-Z])\//, '$1:/').replace(/\\/g, '/');
136
- const rootNorm = projectRoot.replace(/\\/g, '/');
137
- if (normalized.startsWith(rootNorm + '/')) {
138
- return normalized.slice(rootNorm.length + 1); // relative, forward slashes
139
- }
140
- // Already relative or unrecognized format — return as-is
141
- return filePath.replace(/\\/g, '/');
142
- }
143
128
  function runTestFiles(files, projectRoot) {
144
129
  return new Promise((resolve) => {
145
- const relFiles = files.map(f => toRelativeTestPath(f, projectRoot));
146
- const proc = (0, child_process_1.spawn)('npx', ['vitest', 'run', '--reporter=json', ...relFiles], { cwd: projectRoot, shell: true, stdio: 'pipe' });
130
+ // Write JSON to a temp file — avoids stdout encoding/regex issues on Windows
131
+ const tmpDir = path.join(projectRoot, '.viberadar');
132
+ const tmpFile = path.join(tmpDir, '_test-results.json');
133
+ try {
134
+ fs.mkdirSync(tmpDir, { recursive: true });
135
+ }
136
+ catch { }
137
+ try {
138
+ fs.unlinkSync(tmpFile);
139
+ }
140
+ catch { }
141
+ const proc = (0, child_process_1.spawn)('npx', ['vitest', 'run', '--reporter=json', `--outputFile=${tmpFile}`, ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
147
142
  let stdout = '';
148
- let stderr = '';
149
- proc.stdout?.on('data', (d) => stdout += d.toString());
150
- proc.stderr?.on('data', (d) => stderr += d.toString());
151
- proc.on('close', (code) => {
143
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
144
+ proc.on('close', () => {
152
145
  try {
153
- // vitest --reporter=json wraps output; find the JSON object
154
- const match = stdout.match(/\{[\s\S]*"testResults"[\s\S]*\}/);
155
- if (!match) {
156
- // JSON not found — tests likely failed to start (import error, syntax error, etc.)
157
- const hint = (stderr || stdout).split('\n')
158
- .filter(l => l.trim() && !l.includes('VITE') && !l.includes('Duration'))
159
- .slice(0, 5).join('\n');
160
- resolve({ passed: 0, failed: files.length, files, fileDetails: {}, runError: hint || `exit code ${code}` });
161
- return;
146
+ // Prefer the output file (reliable); fall back to stdout
147
+ let jsonStr;
148
+ if (fs.existsSync(tmpFile)) {
149
+ jsonStr = fs.readFileSync(tmpFile, 'utf-8').trim();
150
+ process.stdout.write(` ✅ vitest outputFile: ${tmpFile} (${jsonStr.length} bytes)\n`);
162
151
  }
163
- const json = JSON.parse(match[0]);
152
+ else {
153
+ process.stdout.write(` ⚠️ vitest outputFile not found, falling back to stdout (${stdout.length} bytes)\n`);
154
+ const match = stdout.match(/\{[\s\S]*"testResults"[\s\S]*\}/);
155
+ jsonStr = match ? match[0] : stdout.trim();
156
+ }
157
+ const json = JSON.parse(jsonStr);
158
+ process.stdout.write(` 🔍 vitest JSON: passed=${json.numPassedTests} failed=${json.numFailedTests} testResults=${(json.testResults ?? []).length}\n`);
164
159
  // Extract per-file failure details
165
160
  const fileDetails = {};
166
161
  for (const tr of (json.testResults ?? [])) {
167
- const fp = tr.name ?? tr.testFilePath ?? '';
162
+ const fp = tr.testFilePath ?? '';
163
+ if (!fp)
164
+ continue;
165
+ const assertions = tr.assertionResults ?? tr.testResults ?? [];
168
166
  const errors = [];
169
- for (const ar of (tr.assertionResults ?? [])) {
167
+ for (const ar of assertions) {
170
168
  if (ar.status === 'failed') {
171
169
  errors.push({
172
170
  testName: ar.fullName ?? ar.title ?? 'unknown',
173
- message: (ar.failureMessages?.[0] ?? '').split('\n')[0].slice(0, 300),
171
+ message: (ar.failureMessages?.[0] ?? ar.errors?.[0]?.message ?? '').split('\n')[0].slice(0, 300),
174
172
  });
175
173
  }
176
174
  }
177
175
  fileDetails[fp] = {
178
- passed: (tr.assertionResults ?? []).filter((a) => a.status === 'passed').length,
176
+ passed: assertions.filter((a) => a.status === 'passed').length,
179
177
  failed: errors.length,
180
178
  errors,
181
179
  };
182
180
  }
181
+ const failedFiles = Object.entries(fileDetails).filter(([, d]) => d.failed > 0);
182
+ process.stdout.write(` 🔍 fileDetails: ${Object.keys(fileDetails).length} files, ${failedFiles.length} with failures\n`);
183
+ if (failedFiles.length > 0) {
184
+ for (const [fp, d] of failedFiles) {
185
+ process.stdout.write(` ❌ ${fp} → ${d.failed} failed\n`);
186
+ }
187
+ }
183
188
  resolve({
184
189
  passed: json.numPassedTests ?? 0,
185
190
  failed: json.numFailedTests ?? 0,
@@ -187,57 +192,118 @@ function runTestFiles(files, projectRoot) {
187
192
  fileDetails,
188
193
  });
189
194
  }
190
- catch (e) {
191
- const hint = (stderr || stdout).split('\n').filter(l => l.trim()).slice(0, 5).join('\n');
192
- resolve({ passed: 0, failed: files.length, files, fileDetails: {}, runError: hint || e.message });
195
+ catch (err) {
196
+ process.stdout.write(` ❌ runTestFiles parse error: ${err.message}\n`);
197
+ resolve({ passed: 0, failed: 0, files, fileDetails: {} });
193
198
  }
194
199
  });
195
- proc.on('error', (err) => resolve({ passed: 0, failed: files.length, files, fileDetails: {}, runError: err.message }));
200
+ proc.on('error', () => resolve({ passed: 0, failed: 0, files, fileDetails: {} }));
196
201
  });
197
202
  }
198
- // ─── Prompt builders ──────────────────────────────────────────────────────────
199
- /** Safely read a file, return null if not found */
200
- function tryReadFile(absPath) {
203
+ // ─── E2E plan storage ─────────────────────────────────────────────────────────
204
+ function e2ePlanDir(projectRoot) {
205
+ return path.join(projectRoot, '.viberadar', 'e2e-plans');
206
+ }
207
+ function e2ePlanPath(projectRoot, featureKey) {
208
+ return path.join(e2ePlanDir(projectRoot), `${featureKey}.json`);
209
+ }
210
+ function loadE2ePlan(projectRoot, featureKey) {
201
211
  try {
202
- return fs.readFileSync(absPath, 'utf-8');
212
+ const p = e2ePlanPath(projectRoot, featureKey);
213
+ if (!fs.existsSync(p))
214
+ return null;
215
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
203
216
  }
204
217
  catch {
205
218
  return null;
206
219
  }
207
220
  }
208
- const FILE_BLOCK_LINE_LIMIT = 500;
209
- /** Embed file content as a fenced code block in prompt.
210
- * For large files (> FILE_BLOCK_LINE_LIMIT lines) only embeds the first 200 lines
211
- * (imports + types) and instructs the agent to read the full file by path. */
212
- function fileBlock(relPath, absPath) {
213
- const content = tryReadFile(absPath);
214
- if (!content)
215
- return '';
216
- const ext = absPath.split('.').pop() ?? 'ts';
217
- const lines = content.split('\n');
218
- if (lines.length > FILE_BLOCK_LINE_LIMIT) {
219
- const preview = lines.slice(0, 200).join('\n');
220
- return `### \`${relPath}\`\n_Файл большой (${lines.length} строк) — ниже первые 200 строк для контекста. Прочитай полный файл по пути: \`${absPath}\`_\n\`\`\`${ext}\n${preview}\n\`\`\``;
221
+ function saveE2ePlan(projectRoot, plan) {
222
+ const dir = e2ePlanDir(projectRoot);
223
+ fs.mkdirSync(dir, { recursive: true });
224
+ plan.updatedAt = new Date().toISOString();
225
+ fs.writeFileSync(e2ePlanPath(projectRoot, plan.featureKey), JSON.stringify(plan, null, 2), 'utf-8');
226
+ }
227
+ function e2eScreenshotDir(projectRoot, featureKey) {
228
+ return path.join(projectRoot, '.viberadar', 'e2e-screenshots', featureKey);
229
+ }
230
+ function collectScreenshots(projectRoot, featureKey, testCaseId) {
231
+ const dir = path.join(e2eScreenshotDir(projectRoot, featureKey), testCaseId);
232
+ try {
233
+ if (!fs.existsSync(dir))
234
+ return [];
235
+ return fs.readdirSync(dir)
236
+ .filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f))
237
+ .sort()
238
+ .map(f => `${featureKey}/${testCaseId}/${f}`);
239
+ }
240
+ catch {
241
+ return [];
221
242
  }
222
- return `### \`${relPath}\`\n\`\`\`${ext}\n${content}\n\`\`\``;
223
243
  }
224
- /**
225
- * Pick up to `n` example test files most relevant to `forRelPath`.
226
- * Prefer tests in the same directory (e.g. tests/client/ for client/src/pages/).
227
- */
228
- function pickExampleTests(forRelPath, testModules, n = 2) {
229
- const isClient = forRelPath.includes('client/');
230
- const preferred = testModules.filter(m => isClient ? m.relativePath.includes('client') : !m.relativePath.includes('client'));
231
- const pool = preferred.length > 0 ? preferred : testModules;
232
- return pool.slice(0, n);
244
+ function hasPlaywright(projectRoot) {
245
+ try {
246
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
247
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
248
+ return !!deps['@playwright/test'] || !!deps['playwright'];
249
+ }
250
+ catch {
251
+ return false;
252
+ }
233
253
  }
234
- function buildWriteTestsPrompt(feat, modules, testRunner, projectRoot) {
254
+ // ─── Playwright runner ────────────────────────────────────────────────────────
255
+ function runPlaywrightTests(files, projectRoot) {
256
+ return new Promise((resolve) => {
257
+ const proc = (0, child_process_1.spawn)('npx', ['playwright', 'test', '--reporter=json', ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
258
+ let stdout = '';
259
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
260
+ proc.on('close', () => {
261
+ try {
262
+ const match = stdout.match(/\{[\s\S]*"suites"[\s\S]*\}/);
263
+ const json = JSON.parse(match ? match[0] : stdout);
264
+ const results = {};
265
+ const errors = {};
266
+ let passed = 0, failed = 0;
267
+ const walkSuites = (suites) => {
268
+ for (const suite of suites ?? []) {
269
+ for (const spec of suite.specs ?? []) {
270
+ const ok = spec.tests?.every((t) => t.results?.every((r) => r.status === 'passed'));
271
+ const title = spec.title ?? suite.title ?? 'unknown';
272
+ if (ok) {
273
+ results[title] = 'passed';
274
+ passed++;
275
+ }
276
+ else {
277
+ results[title] = 'failed';
278
+ failed++;
279
+ const errMsg = spec.tests?.[0]?.results?.[0]?.error?.message ?? 'Test failed';
280
+ errors[title] = errMsg.split('\n')[0].slice(0, 300);
281
+ }
282
+ }
283
+ if (suite.suites)
284
+ walkSuites(suite.suites);
285
+ }
286
+ };
287
+ walkSuites(json.suites ?? []);
288
+ resolve({ passed, failed, results, errors });
289
+ }
290
+ catch {
291
+ resolve({ passed: 0, failed: 0, results: {}, errors: {} });
292
+ }
293
+ });
294
+ proc.on('error', () => resolve({ passed: 0, failed: 0, results: {}, errors: {} }));
295
+ });
296
+ }
297
+ // ─── Prompt builders ──────────────────────────────────────────────────────────
298
+ function buildWriteTestsPrompt(feat, modules, testRunner) {
235
299
  const untestedMods = modules
236
300
  .filter(m => m.featureKeys.includes(feat.key) && m.type !== 'test' && !m.hasTests && !m.isInfra);
237
- const existingTestMods = modules
238
- .filter(m => m.featureKeys.includes(feat.key) && m.type === 'test');
301
+ const untestedPaths = untestedMods.map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
302
+ const existing = modules
303
+ .filter(m => m.featureKeys.includes(feat.key) && m.type === 'test')
304
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
239
305
  const hasNoTestInfra = modules.filter(m => m.type === 'test').length === 0;
240
- // Split by suggested type
306
+ // Split by suggested type and give explicit guidance
241
307
  const unitFiles = untestedMods.filter(m => (m.suggestedTestType ?? 'unit') === 'unit');
242
308
  const integrationFiles = untestedMods.filter(m => m.suggestedTestType === 'integration');
243
309
  const typeSummary = [
@@ -248,123 +314,72 @@ function buildWriteTestsPrompt(feat, modules, testRunner, projectRoot) {
248
314
  ? `Integration-тесты (${integrationFiles.length} файлов): используй реальную БД через test-helpers или pg-mem`
249
315
  : '',
250
316
  ].filter(Boolean).join('\n');
251
- // Embed source file contents (cap at 8 files to avoid huge prompts)
252
- const sourceBlocks = untestedMods.slice(0, 8).map(m => fileBlock(m.relativePath.replace(/\\/g, '/'), m.path)).filter(Boolean);
253
- // Pick example tests separately for client and server files
254
- const clientMods = untestedMods.filter(m => m.relativePath.includes('client/'));
255
- const serverMods = untestedMods.filter(m => !m.relativePath.includes('client/'));
256
- const allTestMods = existingTestMods.length > 0 ? existingTestMods
257
- : modules.filter(m => m.type === 'test');
258
- const exampleSet = new Map();
259
- if (clientMods.length > 0) {
260
- pickExampleTests(clientMods[0].relativePath, allTestMods, 1)
261
- .forEach(m => exampleSet.set(m.path, m));
262
- }
263
- if (serverMods.length > 0) {
264
- pickExampleTests(serverMods[0].relativePath, allTestMods, 1)
265
- .forEach(m => exampleSet.set(m.path, m));
266
- }
267
- if (exampleSet.size === 0) {
268
- pickExampleTests('', allTestMods, 2).forEach(m => exampleSet.set(m.path, m));
269
- }
270
- const exampleBlocks = [...exampleSet.values()].map(m => fileBlock(m.relativePath.replace(/\\/g, '/'), m.path)).filter(Boolean);
271
- // Embed test infrastructure: setup.ts + test-helpers.ts
272
- const infraBlocks = [];
273
- const setupPath = path.join(projectRoot, 'tests', 'setup.ts');
274
- const helpersPath = path.join(projectRoot, 'tests', 'test-helpers.ts');
275
- if (tryReadFile(setupPath))
276
- infraBlocks.push(fileBlock('tests/setup.ts', setupPath));
277
- if (tryReadFile(helpersPath))
278
- infraBlocks.push(fileBlock('tests/test-helpers.ts', helpersPath));
279
317
  return [
280
318
  `Напиши тесты для фичи "${feat.label}".`,
281
319
  ``,
282
- typeSummary ? `Рекомендации по типам тестов:\n${typeSummary}` : '',
283
- ``,
284
- `## Исходные файлы для покрытия`,
285
- ...sourceBlocks,
320
+ `Файлов без тестов (${untestedMods.length}):`,
321
+ ...untestedPaths,
286
322
  ``,
287
- exampleBlocks.length > 0 ? `## Примеры тестов (следуй этим паттернам)` : '',
288
- ...exampleBlocks,
323
+ typeSummary ? `Рекомендации по типам тестов:\n${typeSummary}` : '',
289
324
  ``,
290
- infraBlocks.length > 0 ? `## Тестовая инфраструктура` : '',
291
- ...infraBlocks,
325
+ existing.length > 0
326
+ ? `Существующие тест-файлы (для справки по паттернам):\n${existing.join('\n')}`
327
+ : '',
292
328
  ``,
293
329
  hasNoTestInfra
294
330
  ? `⚠️ В проекте пока нет ни одного теста. Если нужна тестовая инфраструктура (test-helpers.ts, vitest.config.ts) — создай её сначала.`
295
331
  : '',
296
- `## Требования`,
332
+ ``,
333
+ `Требования:`,
297
334
  `- Используй ${testRunner}`,
298
- `- Для каждого исходного файла создай соответствующий тест-файл`,
299
- `- Покрой: happy path, edge cases, обработку ошибок`,
300
- `- Следуй паттернам примеров выше`,
335
+ `- Следуй паттернам существующих тестов в проекте`,
336
+ `- Для каждого файла создай соответствующий тест-файл`,
301
337
  `- Не изменяй существующие тесты`,
302
- hasNoTestInfra
303
- ? `- Если нужно — создай test-helpers.ts и vitest.config.ts перед написанием тестов`
304
- : `- Если в исходном файле есть импорты типов которых нет выше — прочитай только эти файлы с типами. Не исследуй проект вширь.`,
305
- ].filter(s => s !== null && s !== undefined && s !== '').join('\n');
338
+ ].filter(Boolean).join('\n');
306
339
  }
307
- function buildWriteTestsForFilePrompt(filePath, feat, modules, testRunner, projectRoot) {
340
+ function buildWriteTestsForFilePrompt(filePath, feat, modules, testRunner) {
308
341
  const normalPath = filePath.replace(/\\/g, '/');
309
- // Find module info to get suggestedTestType and absolute path
342
+ // Find module info to get suggestedTestType (match by relativePath or absolute path)
310
343
  const sourceModule = modules.find(m => m.relativePath.replace(/\\/g, '/') === normalPath || m.path === filePath);
311
344
  const suggestedTestType = sourceModule?.suggestedTestType ?? 'unit';
312
- const absSourcePath = sourceModule?.path ?? path.join(projectRoot, filePath);
313
- // Embed source file content
314
- const sourceBlock = fileBlock(normalPath, absSourcePath);
315
- // Pick 1-2 example tests most relevant to this file
316
- const existingTestMods = modules.filter(m => m.type === 'test');
317
- const featureTestMods = modules.filter(m => m.featureKeys.includes(feat.key) && m.type === 'test');
318
- const examplePool = featureTestMods.length > 0 ? featureTestMods : existingTestMods;
319
- const exampleTests = pickExampleTests(normalPath, examplePool, 2);
320
- const exampleBlocks = exampleTests.map(m => fileBlock(m.relativePath.replace(/\\/g, '/'), m.path)).filter(Boolean);
321
- // Embed test infrastructure: setup.ts + test-helpers.ts
322
- const infraBlocks = [];
323
- const setupPath = path.join(projectRoot, 'tests', 'setup.ts');
324
- const helpersPath = path.join(projectRoot, 'tests', 'test-helpers.ts');
325
- if (tryReadFile(setupPath))
326
- infraBlocks.push(fileBlock('tests/setup.ts', setupPath));
327
- if (tryReadFile(helpersPath))
328
- infraBlocks.push(fileBlock('tests/test-helpers.ts', helpersPath));
345
+ const existing = modules
346
+ .filter(m => m.featureKeys.includes(feat.key) && m.type === 'test')
347
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
348
+ const hasNoTestInfra = modules.filter(m => m.type === 'test').length === 0;
329
349
  const testTypeBlock = suggestedTestType === 'integration'
330
350
  ? [
331
351
  `Тип теста: INTEGRATION`,
332
- `→ Используй test-helpers или реальную БД.`,
352
+ `Файл обращается к БД, репозиториям или внешним сервисам.`,
353
+ `→ Используй test-helpers или pg-mem для работы с реальной БД.`,
333
354
  `→ Не мокай репозитории — проверяй реальное поведение.`,
334
355
  ].join('\n')
335
356
  : [
336
357
  `Тип теста: UNIT`,
337
358
  `→ Замокай все внешние зависимости через \`vi.mock()\`.`,
338
359
  `→ Не используй реальную БД или внешние сервисы.`,
360
+ `→ Тест должен работать быстро без внешних зависимостей.`,
339
361
  ].join('\n');
340
- const hasNoTestInfra = existingTestMods.length === 0;
341
362
  return [
342
- `Напиши тест для файла \`${normalPath}\`. Фича: "${feat.label}"`,
363
+ `Напиши тест для файла \`${normalPath}\`.`,
364
+ `Фича: "${feat.label}"`,
343
365
  ``,
344
366
  testTypeBlock,
345
367
  ``,
346
- `## Исходный файл`,
347
- sourceBlock,
348
- ``,
349
- exampleBlocks.length > 0 ? `## Примеры тестов (следуй этим паттернам)` : '',
350
- ...exampleBlocks,
351
- ``,
352
- infraBlocks.length > 0 ? `## Тестовая инфраструктура` : '',
353
- ...infraBlocks,
368
+ existing.length > 0
369
+ ? `Существующие тест-файлы фичи (следуй этим паттернам):\n${existing.join('\n')}`
370
+ : 'Существующих тестов в этой фиче пока нет — следуй общим паттернам проекта.',
354
371
  ``,
355
372
  hasNoTestInfra
356
373
  ? `⚠️ В проекте пока нет ни одного теста. Если нужна тестовая инфраструктура (test-helpers.ts, vitest.config.ts) — создай её сначала.`
357
374
  : '',
358
- `## Требования`,
375
+ ``,
376
+ `Требования:`,
359
377
  `- Используй ${testRunner}`,
360
378
  `- Создай один тест-файл для \`${normalPath}\``,
361
379
  `- Покрой: happy path, edge cases, обработку ошибок`,
362
- `- Следуй паттернам примеров выше`,
380
+ `- Следуй паттернам существующих тестов в проекте`,
363
381
  `- Не изменяй существующие тесты`,
364
- hasNoTestInfra
365
- ? `- Если нужно — создай test-helpers.ts и vitest.config.ts перед написанием тестов`
366
- : `- Если в исходном файле есть импорты типов которых нет выше — прочитай только эти файлы с типами. Не исследуй проект вширь.`,
367
- ].filter(s => s !== null && s !== undefined && s !== '').join('\n');
382
+ ].filter(Boolean).join('\n');
368
383
  }
369
384
  function buildFixTestsPrompt(filePath, errors) {
370
385
  const normalPath = filePath.replace(/\\/g, '/');
@@ -382,28 +397,6 @@ function buildFixTestsPrompt(filePath, errors) {
382
397
  `- После исправления запусти тест, чтобы убедиться что он проходит`,
383
398
  ].join('\n');
384
399
  }
385
- function buildFixAllTestsPrompt(failedFiles, testType) {
386
- const totalErrors = failedFiles.reduce((sum, f) => sum + f.errors.length, 0);
387
- const fileBlocks = failedFiles.map(f => {
388
- const normalPath = f.filePath.replace(/\\/g, '/');
389
- return [
390
- `### \`${normalPath}\` (${f.errors.length} упало):`,
391
- ...f.errors.map(e => `• "${e.testName}"\n ${e.message}`),
392
- ].join('\n');
393
- });
394
- return [
395
- `Исправь все падающие ${testType} тесты (${totalErrors} тестов в ${failedFiles.length} файлах).`,
396
- ``,
397
- ...fileBlocks,
398
- ``,
399
- `Требования:`,
400
- `- Исправь только падающие тесты, не трогай проходящие`,
401
- `- Не удаляй тесты — исправь логику или моки`,
402
- `- Если тест проверяет несуществующее поведение — адаптируй под реальное поведение кода`,
403
- `- Если ошибка в исходном коде, а не в тесте — исправь код`,
404
- `- После исправления каждого файла запусти его тесты, чтобы убедиться что проходят`,
405
- ].join('\n');
406
- }
407
400
  function buildMapUnmappedPrompt(modules, features) {
408
401
  const unmapped = modules
409
402
  .filter(m => m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0))
@@ -425,6 +418,66 @@ function buildMapUnmappedPrompt(modules, features) {
425
418
  ...unmapped,
426
419
  ].join('\n');
427
420
  }
421
+ function buildE2ePlanPrompt(feat, modules) {
422
+ const files = modules
423
+ .filter(m => m.featureKeys.includes(feat.key) && m.type !== 'test' && !m.isInfra)
424
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
425
+ return [
426
+ `Ты — QA-инженер. Проанализируй исходный код фичи "${feat.label}" и составь подробный план E2E-тестирования.`,
427
+ ``,
428
+ `Файлы фичи:`,
429
+ ...files,
430
+ ``,
431
+ `Прочитай эти файлы, изучи UI: формы, кнопки, навигацию, бизнес-логику.`,
432
+ ``,
433
+ `Верни ТОЛЬКО валидный JSON без markdown-блоков в формате:`,
434
+ `{`,
435
+ ` "baseUrl": "http://localhost:3000",`,
436
+ ` "testCases": [`,
437
+ ` {`,
438
+ ` "id": "feature-key-01",`,
439
+ ` "name": "Краткое название теста",`,
440
+ ` "description": "Что проверяет тест",`,
441
+ ` "steps": ["Шаг 1", "Шаг 2", "Шаг 3"],`,
442
+ ` "expectedResults": ["Ожидаемый результат 1", "Ожидаемый результат 2"]`,
443
+ ` }`,
444
+ ` ]`,
445
+ `}`,
446
+ ``,
447
+ `Требования:`,
448
+ `- 5-15 тест-кейсов, покрывающих основные сценарии`,
449
+ `- Каждый кейс: happy path, edge cases, обработка ошибок`,
450
+ `- id: строчные буквы и цифры через дефис, уникальный`,
451
+ `- Только JSON, без объяснений`,
452
+ ].join('\n');
453
+ }
454
+ function buildWriteE2eTestPrompt(feat, plan, modules) {
455
+ const approvedCases = plan.testCases.filter(tc => tc.status === 'approved');
456
+ const existingE2e = modules
457
+ .filter(m => m.featureKeys.includes(feat.key) && m.type === 'e2e')
458
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
459
+ return [
460
+ `Напиши Playwright E2E тесты для фичи "${feat.label}".`,
461
+ ``,
462
+ `Approved тест-кейсы (${approvedCases.length}):`,
463
+ ...approvedCases.map(tc => [
464
+ `### ${tc.name} (id: ${tc.id})`,
465
+ `${tc.description}`,
466
+ `Шаги: ${tc.steps.join(' → ')}`,
467
+ `Ожидается: ${tc.expectedResults.join('; ')}`,
468
+ ].join('\n')),
469
+ ``,
470
+ existingE2e.length > 0 ? `Существующие E2E тесты (следуй паттернам):\n${existingE2e.join('\n')}` : '',
471
+ ``,
472
+ `Требования:`,
473
+ `- Создай файлы в директории e2e/${feat.key}/`,
474
+ `- Используй @playwright/test`,
475
+ `- baseUrl: ${plan.baseUrl || 'http://localhost:3000'}`,
476
+ `- После ключевых шагов добавь скриншоты: await page.screenshot({ path: '.viberadar/e2e-screenshots/${feat.key}/<testCaseId>/step-N.png' })`,
477
+ `- Группируй по test case id (один файл на кейс или несколько кейсов в одном файле)`,
478
+ `- Следуй существующим паттернам проекта`,
479
+ ].filter(Boolean).join('\n');
480
+ }
428
481
  // ─── Coverage provider auto-install ──────────────────────────────────────────
429
482
  function autoInstallCoverageProvider(projectRoot) {
430
483
  return new Promise(resolve => {
@@ -491,7 +544,6 @@ function startServer({ data: initialData, port, projectRoot }) {
491
544
  let coverageError = false;
492
545
  let agentRunning = false;
493
546
  let testsRunning = false;
494
- const agentQueue = [];
495
547
  // Keyed by absolute file path → per-file failure details from last test run
496
548
  const lastTestResults = new Map();
497
549
  // ── SSE clients ────────────────────────────────────────────────────────────
@@ -590,99 +642,86 @@ function startServer({ data: initialData, port, projectRoot }) {
590
642
  });
591
643
  }
592
644
  // ── Agent runner ───────────────────────────────────────────────────────────
593
- /** Check if the configured agent CLI is installed; broadcast a warning if not */
594
- function checkAgentInstalled(agent) {
595
- if (!agent)
645
+ function runAgent(task, featureKey, filePath) {
646
+ if (agentRunning) {
647
+ process.stdout.write(' ⏳ Agent already running\n');
596
648
  return;
597
- const cliName = agent === 'claude'
598
- ? (WIN ? 'claude.cmd' : 'claude')
599
- : (WIN ? 'codex.cmd' : 'codex');
600
- const agentName = agent === 'claude' ? 'Claude Code' : 'Codex';
601
- const downloadUrl = agent === 'claude' ? 'claude.ai/download' : 'github.com/openai/codex';
602
- const check = (0, child_process_1.spawn)(cliName, ['--version'], { shell: true, stdio: 'pipe' });
603
- check.on('error', () => {
604
- process.stdout.write(` ⚠️ ${agentName} не найден (${cliName})\n`);
605
- broadcast('agent-error', {
606
- message: `${agentName} не установлен. Скачай с ${downloadUrl}`,
607
- notInstalled: true,
608
- agent,
609
- });
610
- });
611
- check.on('close', (code) => {
612
- if (code === 255) {
613
- process.stdout.write(` ⚠️ ${agentName} не авторизован (exit 255 при --version)\n`);
614
- broadcast('agent-error', {
615
- message: `${agentName} не авторизован. Нажми 🔑 Перелогиниться в меню агента.`,
616
- authRequired: true,
617
- agent,
618
- });
619
- }
620
- else if (code === 0) {
621
- process.stdout.write(` ✅ ${agentName} доступен\n`);
622
- }
623
- });
624
- }
625
- /** Execute the next queued item, or broadcast agent-done if queue is empty */
626
- function processNextInQueue() {
627
- if (agentQueue.length > 0) {
628
- const next = agentQueue.shift();
629
- process.stdout.write(` 📋 Starting next from queue: "${next.title}" (remaining: ${agentQueue.length})\n`);
630
- broadcast('agent-output', { line: `📋 Следующая задача из очереди: ${next.title}` });
631
- broadcast('agent-output', { line: ` В очереди осталось: ${agentQueue.length}` });
632
- executeAgentItem(next);
633
649
  }
634
- else {
635
- broadcast('agent-done', { queueLength: 0 });
650
+ const agent = currentData.agent;
651
+ if (!agent) {
652
+ broadcast('agent-error', { message: 'Агент не выбран. Укажи agent в viberadar.config.json' });
653
+ return;
636
654
  }
637
- }
638
- /** Actually spawn the agent process for a queue item */
639
- function executeAgentItem(item) {
640
- const { task, featureKey, filePath, title, agent, savedErrors, savedFailedFiles, savedTestType } = item;
641
- // ── Build prompt lazily here (not pre-built in queue) to avoid holding large strings in memory ──
655
+ // Build prompt
642
656
  let prompt;
657
+ let title;
658
+ const agentLabel = agent === 'claude' ? 'Claude Code' : 'Codex';
643
659
  if (task === 'write-tests') {
644
660
  const feat = currentData.features?.find(f => f.key === featureKey);
645
661
  if (!feat) {
646
662
  broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
647
- agentRunning = false;
648
- processNextInQueue();
649
663
  return;
650
664
  }
651
- prompt = buildWriteTestsPrompt(feat, currentData.modules, currentData.testRunner || 'vitest', projectRoot);
665
+ prompt = buildWriteTestsPrompt(feat, currentData.modules, currentData.testRunner || 'vitest');
666
+ title = `${agentLabel} — тесты для "${feat.label}"`;
652
667
  }
653
668
  else if (task === 'write-tests-file') {
654
669
  const feat = currentData.features?.find(f => f.key === featureKey);
655
670
  if (!feat || !filePath) {
656
- broadcast('agent-error', { message: 'Не указана фича или файл' });
657
- agentRunning = false;
658
- processNextInQueue();
671
+ broadcast('agent-error', { message: `Не указана фича или файл` });
659
672
  return;
660
673
  }
661
- prompt = buildWriteTestsForFilePrompt(filePath, feat, currentData.modules, currentData.testRunner || 'vitest', projectRoot);
674
+ prompt = buildWriteTestsForFilePrompt(filePath, feat, currentData.modules, currentData.testRunner || 'vitest');
675
+ const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
676
+ title = `${agentLabel} — тест для "${fileName}"`;
662
677
  }
663
678
  else if (task === 'fix-tests') {
664
- if (!filePath || !savedErrors || savedErrors.length === 0) {
665
- broadcast('agent-error', { message: `Нет сохранённых ошибок для ${filePath}` });
666
- agentRunning = false;
667
- processNextInQueue();
679
+ if (!filePath) {
680
+ broadcast('agent-error', { message: 'Не указан файл для исправления' });
681
+ return;
682
+ }
683
+ // Look up stored errors for this file (match by absolute or relative path)
684
+ let storedErrors;
685
+ for (const [fp, detail] of lastTestResults) {
686
+ const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
687
+ if (rel === filePath.replace(/\\/g, '/') || fp === filePath) {
688
+ storedErrors = detail.errors;
689
+ break;
690
+ }
691
+ }
692
+ if (!storedErrors || storedErrors.length === 0) {
693
+ broadcast('agent-error', { message: `Нет сохранённых ошибок для ${filePath}. Сначала запусти тесты.` });
668
694
  return;
669
695
  }
670
- prompt = buildFixTestsPrompt(filePath, savedErrors);
696
+ prompt = buildFixTestsPrompt(filePath, storedErrors);
697
+ const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
698
+ title = `${agentLabel} — исправить тесты в "${fileName}"`;
671
699
  }
672
- else if (task === 'fix-tests-all') {
673
- if (!savedFailedFiles || savedFailedFiles.length === 0) {
674
- broadcast('agent-error', { message: 'Нет упавших тестов для исправления' });
675
- agentRunning = false;
676
- processNextInQueue();
700
+ else if (task === 'generate-e2e-plan') {
701
+ const feat = currentData.features?.find(f => f.key === featureKey);
702
+ if (!feat) {
703
+ broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
677
704
  return;
678
705
  }
679
- prompt = buildFixAllTestsPrompt(savedFailedFiles, savedTestType || 'unit');
706
+ prompt = buildE2ePlanPrompt(feat, currentData.modules);
707
+ title = `${agentLabel} — E2E план для "${feat.label}"`;
708
+ }
709
+ else if (task === 'write-e2e-tests') {
710
+ const feat = currentData.features?.find(f => f.key === featureKey);
711
+ const plan = featureKey ? loadE2ePlan(projectRoot, featureKey) : null;
712
+ if (!feat || !plan) {
713
+ broadcast('agent-error', { message: `Фича или план не найдены: ${featureKey}` });
714
+ return;
715
+ }
716
+ prompt = buildWriteE2eTestPrompt(feat, plan, currentData.modules);
717
+ title = `${agentLabel} — пишу E2E тесты для "${feat.label}"`;
680
718
  }
681
719
  else {
682
720
  prompt = buildMapUnmappedPrompt(currentData.modules, currentData.features || []);
721
+ title = `${agentLabel} — разобрать unmapped`;
683
722
  }
684
723
  agentRunning = true;
685
- broadcast('agent-started', { title, task, featureKey, filePath: filePath || null, queueLength: agentQueue.length });
724
+ broadcast('agent-started', { title, task, featureKey });
686
725
  process.stdout.write(` 🤖 Running agent (${agent}): ${task}\n`);
687
726
  // Write prompt to .viberadar/task.md for reference
688
727
  const taskDir = path.join(projectRoot, '.viberadar');
@@ -693,7 +732,7 @@ function startServer({ data: initialData, port, projectRoot }) {
693
732
  }
694
733
  catch { }
695
734
  // Spawn via shell, piping prompt from file (avoids TUI mode, supports stream-json)
696
- const shellCmd = buildAgentShellCmd(agent, taskFile, currentData.model);
735
+ const shellCmd = buildAgentShellCmd(agent, taskFile);
697
736
  process.stdout.write(` 🚀 Shell cmd: ${shellCmd}\n`);
698
737
  const proc = (0, child_process_1.spawn)(shellCmd, [], {
699
738
  cwd: projectRoot,
@@ -704,6 +743,8 @@ function startServer({ data: initialData, port, projectRoot }) {
704
743
  broadcast('agent-output', { line: `📄 Задача записана в .viberadar/task.md` });
705
744
  // Track test files written/edited by agent (for auto-run after)
706
745
  const createdTestFiles = [];
746
+ // Accumulate full result text for E2E plan parsing
747
+ let agentResultText = '';
707
748
  function trackWrittenFiles(raw) {
708
749
  try {
709
750
  const ev = JSON.parse(raw);
@@ -735,8 +776,9 @@ function startServer({ data: initialData, port, projectRoot }) {
735
776
  }
736
777
  if (parsed.startsWith('§RESULT§')) {
737
778
  // Full result summary — split into lines and prefix with indent
779
+ agentResultText = parsed.slice('§RESULT§'.length).trim();
738
780
  broadcast('agent-output', { line: '─────────────────────────────' });
739
- for (const l of parsed.slice('§RESULT§'.length).split('\n')) {
781
+ for (const l of agentResultText.split('\n')) {
740
782
  if (l.trim())
741
783
  broadcast('agent-output', { line: ' ' + l });
742
784
  }
@@ -759,33 +801,21 @@ function startServer({ data: initialData, port, projectRoot }) {
759
801
  agentRunning = false;
760
802
  if (code === 0) {
761
803
  // Auto-run created/fixed test files and show results
762
- let testFilesToRun;
763
- if (task === 'fix-tests-all') {
764
- testFilesToRun = [...lastTestResults.keys()];
765
- }
766
- else if (task === 'fix-tests' && filePath) {
767
- testFilesToRun = [filePath];
768
- }
769
- else {
770
- testFilesToRun = createdTestFiles;
771
- }
772
- if ((task === 'write-tests' || task === 'write-tests-file' || task === 'fix-tests' || task === 'fix-tests-all') && testFilesToRun.length > 0) {
804
+ const testFilesToRun = task === 'fix-tests' && filePath ? [filePath] : createdTestFiles;
805
+ if ((task === 'write-tests' || task === 'write-tests-file' || task === 'fix-tests') && testFilesToRun.length > 0) {
773
806
  broadcast('agent-output', { line: '─────────────────────────────' });
774
807
  broadcast('agent-output', { line: `🧪 Запускаю тесты (${testFilesToRun.length} файлов)...` });
775
808
  const result = await runTestFiles(testFilesToRun, projectRoot);
776
809
  // Store per-file results for "fix-tests" feature
810
+ // path.resolve() normalizes slashes on Windows (vitest outputs forward slashes)
777
811
  lastTestResults.clear();
778
812
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
779
813
  if (detail.failed > 0)
780
814
  lastTestResults.set(path.resolve(fp), { failed: detail.failed, errors: detail.errors });
781
815
  }
782
- const summary = result.runError
783
- ? `❌ Тесты не запустились: ${result.runError}`
784
- : result.failed === 0 && result.passed > 0
785
- ? `✅ Все тесты прошли: ${result.passed} passed`
786
- : result.failed === 0 && result.passed === 0
787
- ? `⚠️ 0 тестов запустилось — проверь файл на ошибки импорта`
788
- : `⚠️ ${result.passed} passed, ${result.failed} failed`;
816
+ const summary = result.failed === 0
817
+ ? `✅ Все тесты прошли: ${result.passed} passed`
818
+ : `⚠️ ${result.passed} passed, ${result.failed} failed`;
789
819
  broadcast('agent-output', { line: summary });
790
820
  if (result.failed > 0) {
791
821
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
@@ -801,120 +831,57 @@ function startServer({ data: initialData, port, projectRoot }) {
801
831
  }
802
832
  broadcast('agent-summary', result);
803
833
  }
834
+ // E2E plan post-processing
835
+ if (task === 'generate-e2e-plan' && featureKey) {
836
+ try {
837
+ const jsonMatch = agentResultText.match(/\{[\s\S]*"testCases"[\s\S]*\}/);
838
+ const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : agentResultText);
839
+ const feat = currentData.features?.find(f => f.key === featureKey);
840
+ const plan = {
841
+ featureKey,
842
+ featureLabel: feat?.label || featureKey,
843
+ generatedAt: new Date().toISOString(),
844
+ updatedAt: new Date().toISOString(),
845
+ baseUrl: parsed.baseUrl,
846
+ testCases: (parsed.testCases || []).map((tc) => ({ ...tc, status: 'pending' })),
847
+ };
848
+ saveE2ePlan(projectRoot, plan);
849
+ broadcast('e2e-plan-ready', { featureKey, plan });
850
+ }
851
+ catch (err) {
852
+ broadcast('e2e-plan-error', { featureKey, message: `Не удалось распарсить план: ${err.message}` });
853
+ }
854
+ }
855
+ if (task === 'write-e2e-tests' && featureKey) {
856
+ const plan = loadE2ePlan(projectRoot, featureKey);
857
+ if (plan) {
858
+ for (const tc of plan.testCases) {
859
+ if (tc.status === 'approved')
860
+ tc.status = 'written';
861
+ }
862
+ saveE2ePlan(projectRoot, plan);
863
+ broadcast('e2e-tests-written', { featureKey, files: createdTestFiles });
864
+ }
865
+ }
804
866
  process.stdout.write(' ✅ Agent done, rescanning...\n');
805
867
  try {
806
868
  currentData = await (0, scanner_1.scanProject)(projectRoot);
807
869
  broadcast('data-updated');
808
870
  }
809
871
  catch { }
810
- processNextInQueue();
811
- }
812
- else if (code === 255) {
813
- process.stdout.write(` ❌ Agent auth error (exit code 255)\n`);
814
- broadcast('agent-error', {
815
- message: `${agent === 'claude' ? 'Claude Code' : 'Codex'} не авторизован. Нажми 🔑 Перелогиниться в меню агента.`,
816
- authRequired: true,
817
- agent,
818
- });
819
- processNextInQueue();
872
+ broadcast('agent-done');
820
873
  }
821
874
  else {
822
875
  process.stdout.write(` ❌ Agent failed (exit code ${code})\n`);
823
876
  broadcast('agent-error', { message: `Агент завершился с кодом ${code}` });
824
- processNextInQueue();
825
877
  }
826
878
  });
827
879
  proc.on('error', (err) => {
828
880
  agentRunning = false;
829
- const isNotFound = err.code === 'ENOENT' || err.message.includes('ENOENT');
830
- const agentName = agent === 'claude' ? 'Claude Code' : 'Codex';
831
- const msg = isNotFound
832
- ? `${agentName} не установлен. Скачай с ${agent === 'claude' ? 'claude.ai/download' : 'github.com/openai/codex'}`
833
- : `Не удалось запустить ${agent}: ${err.message}`;
834
881
  process.stdout.write(' ❌ Agent spawn error: ' + err.message + '\n');
835
- broadcast('agent-error', { message: msg, notInstalled: isNotFound, agent });
836
- processNextInQueue();
882
+ broadcast('agent-error', { message: `Не удалось запустить ${agent}: ${err.message}` });
837
883
  });
838
884
  }
839
- /** Validate task params and enqueue (prompt is built lazily at execution time) */
840
- function runAgent(task, featureKey, filePath) {
841
- const agent = currentData.agent;
842
- if (!agent) {
843
- broadcast('agent-error', { message: 'Агент не выбран. Укажи agent в viberadar.config.json' });
844
- return;
845
- }
846
- // Validate params upfront and snapshot only the small error data for fix tasks.
847
- // The full prompt is NOT built here — executeAgentItem() builds it lazily to save memory.
848
- let title;
849
- let savedErrors;
850
- let savedFailedFiles;
851
- let savedTestType;
852
- const agentLabel = agent === 'claude' ? 'Claude Code' : 'Codex';
853
- if (task === 'write-tests') {
854
- const feat = currentData.features?.find(f => f.key === featureKey);
855
- if (!feat) {
856
- broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
857
- return;
858
- }
859
- title = `${agentLabel} — тесты для "${feat.label}"`;
860
- }
861
- else if (task === 'write-tests-file') {
862
- const feat = currentData.features?.find(f => f.key === featureKey);
863
- if (!feat || !filePath) {
864
- broadcast('agent-error', { message: 'Не указана фича или файл' });
865
- return;
866
- }
867
- const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
868
- title = `${agentLabel} — тест для "${fileName}"`;
869
- }
870
- else if (task === 'fix-tests') {
871
- if (!filePath) {
872
- broadcast('agent-error', { message: 'Не указан файл для исправления' });
873
- return;
874
- }
875
- for (const [fp, detail] of lastTestResults) {
876
- const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
877
- if (rel === filePath.replace(/\\/g, '/') || fp === filePath) {
878
- savedErrors = detail.errors;
879
- break;
880
- }
881
- }
882
- if (!savedErrors || savedErrors.length === 0) {
883
- broadcast('agent-error', { message: `Нет сохранённых ошибок для ${filePath}. Сначала запусти тесты.` });
884
- return;
885
- }
886
- const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
887
- title = `${agentLabel} — исправить тесты в "${fileName}"`;
888
- }
889
- else if (task === 'fix-tests-all') {
890
- savedTestType = filePath || 'unit';
891
- savedFailedFiles = [];
892
- for (const [fp, detail] of lastTestResults) {
893
- const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
894
- const mod = currentData.modules.find(m => m.relativePath.replace(/\\/g, '/') === rel && m.testType === savedTestType);
895
- if (mod && detail.errors.length > 0) {
896
- savedFailedFiles.push({ filePath: rel, errors: detail.errors });
897
- }
898
- }
899
- if (savedFailedFiles.length === 0) {
900
- broadcast('agent-error', { message: `Нет упавших ${savedTestType} тестов. Сначала запусти тесты.` });
901
- return;
902
- }
903
- title = `${agentLabel} — починить все ${savedTestType} тесты (${savedFailedFiles.length} файлов)`;
904
- }
905
- else {
906
- title = `${agentLabel} — разобрать unmapped`;
907
- }
908
- const item = { task, featureKey, filePath, title, agent, savedErrors, savedFailedFiles, savedTestType };
909
- if (agentRunning) {
910
- agentQueue.push(item);
911
- const ql = agentQueue.length;
912
- process.stdout.write(` 📋 Agent busy, queued: "${title}" (queue size: ${ql})\n`);
913
- broadcast('agent-queued', { queueLength: ql, title, task, featureKey: featureKey || null, filePath: filePath || null });
914
- return;
915
- }
916
- executeAgentItem(item);
917
- }
918
885
  // ── Chokidar watcher ───────────────────────────────────────────────────────
919
886
  chokidar_1.default.watch([
920
887
  '**/*.{ts,tsx,js,jsx,vue,svelte}',
@@ -946,13 +913,25 @@ function startServer({ data: initialData, port, projectRoot }) {
946
913
  const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
947
914
  testErrors[rel] = detail;
948
915
  }
916
+ // Check which features have E2E plans
917
+ const e2ePlansExist = {};
918
+ try {
919
+ const planDir = e2ePlanDir(projectRoot);
920
+ if (fs.existsSync(planDir)) {
921
+ for (const f of fs.readdirSync(planDir)) {
922
+ if (f.endsWith('.json'))
923
+ e2ePlansExist[f.replace('.json', '')] = true;
924
+ }
925
+ }
926
+ }
927
+ catch { }
949
928
  res.writeHead(200, { 'Content-Type': 'application/json' });
950
- res.end(JSON.stringify({ ...currentData, testErrors }));
929
+ res.end(JSON.stringify({ ...currentData, testErrors, hasPlaywright: hasPlaywright(projectRoot), e2ePlansExist }));
951
930
  return;
952
931
  }
953
932
  if (url === '/api/status') {
954
933
  res.writeHead(200, { 'Content-Type': 'application/json' });
955
- res.end(JSON.stringify({ coverageRunning, coverageError, agentRunning, queueLength: agentQueue.length }));
934
+ res.end(JSON.stringify({ coverageRunning, coverageError, agentRunning }));
956
935
  return;
957
936
  }
958
937
  if (url === '/api/run-coverage' && req.method === 'POST') {
@@ -995,13 +974,9 @@ function startServer({ data: initialData, port, projectRoot }) {
995
974
  if (detail.failed > 0)
996
975
  lastTestResults.set(path.resolve(fp), { failed: detail.failed, errors: detail.errors });
997
976
  }
998
- const summary = result.runError
999
- ? `❌ Тесты не запустились: ${result.runError}`
1000
- : result.failed === 0 && result.passed > 0
1001
- ? `✅ Все тесты прошли: ${result.passed} passed`
1002
- : result.failed === 0 && result.passed === 0
1003
- ? `⚠️ 0 тестов запустилось — проверь файл на ошибки импорта`
1004
- : `⚠️ ${result.passed} passed, ${result.failed} failed`;
977
+ const summary = result.failed === 0
978
+ ? `✅ Все тесты прошли: ${result.passed} passed`
979
+ : `⚠️ ${result.passed} passed, ${result.failed} failed`;
1005
980
  broadcast('agent-output', { line: summary });
1006
981
  if (result.failed > 0) {
1007
982
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
@@ -1013,7 +988,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1013
988
  }
1014
989
  }
1015
990
  }
1016
- broadcast('agent-output', { line: ' → Нажми 🔧 исправить рядом с файлом чтобы агент починил' });
991
+ broadcast('agent-output', { line: ' → Нажми Починить рядом с файлом чтобы агент исправил' });
1017
992
  }
1018
993
  // Send testErrors directly in event — avoids path/timing issues with /api/data fetch
1019
994
  const testErrorsForClient = {};
@@ -1021,6 +996,11 @@ function startServer({ data: initialData, port, projectRoot }) {
1021
996
  const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
1022
997
  testErrorsForClient[rel] = detail;
1023
998
  }
999
+ const errKeys = Object.keys(testErrorsForClient);
1000
+ process.stdout.write(` 🔍 testErrors to client: ${errKeys.length} keys: ${errKeys.slice(0, 3).join(', ')}\n`);
1001
+ if (result.failed > 0 && errKeys.length === 0) {
1002
+ broadcast('agent-output', { line: `⚠️ Есть ${result.failed} упавших теста, но детали по файлам не получены — проверь viberadar лог`, isDim: true });
1003
+ }
1024
1004
  broadcast('tests-done', { passed: result.passed, failed: result.failed, testErrors: testErrorsForClient });
1025
1005
  }
1026
1006
  catch (err) {
@@ -1053,176 +1033,206 @@ function startServer({ data: initialData, port, projectRoot }) {
1053
1033
  }
1054
1034
  if (url === '/api/cancel-agent' && req.method === 'POST') {
1055
1035
  agentRunning = false;
1056
- agentQueue.length = 0; // clear queue too
1057
- process.stdout.write(' ⏹ Agent state reset by user (queue cleared)\n');
1036
+ process.stdout.write(' ⏹ Agent state reset by user\n');
1058
1037
  res.writeHead(200, { 'Content-Type': 'application/json' });
1059
1038
  res.end(JSON.stringify({ ok: true }));
1060
1039
  return;
1061
1040
  }
1062
- if (url === '/api/get-prompt' && req.method === 'POST') {
1041
+ if (url === '/api/set-agent' && req.method === 'POST') {
1063
1042
  let body = '';
1064
1043
  req.on('data', d => body += d);
1065
1044
  req.on('end', () => {
1066
1045
  try {
1067
- const { task, featureKey, filePath } = JSON.parse(body);
1068
- let prompt = '';
1069
- if (task === 'write-tests-file') {
1070
- const feat = currentData.features?.find(f => f.key === featureKey);
1071
- if (!feat || !filePath) {
1072
- res.writeHead(400);
1073
- res.end(JSON.stringify({ error: 'Missing feat or file' }));
1074
- return;
1075
- }
1076
- prompt = buildWriteTestsForFilePrompt(filePath, feat, currentData.modules, currentData.testRunner || 'vitest', projectRoot);
1077
- }
1078
- else if (task === 'write-tests') {
1079
- const feat = currentData.features?.find(f => f.key === featureKey);
1080
- if (!feat) {
1081
- res.writeHead(400);
1082
- res.end(JSON.stringify({ error: 'Feature not found' }));
1083
- return;
1084
- }
1085
- prompt = buildWriteTestsPrompt(feat, currentData.modules, currentData.testRunner || 'vitest', projectRoot);
1086
- }
1046
+ const { agent } = JSON.parse(body);
1047
+ const configPath = path.join(projectRoot, 'viberadar.config.json');
1048
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1049
+ config.agent = agent;
1050
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1051
+ scheduleRescan();
1087
1052
  res.writeHead(200, { 'Content-Type': 'application/json' });
1088
- res.end(JSON.stringify({ prompt }));
1053
+ res.end(JSON.stringify({ ok: true, agent }));
1089
1054
  }
1090
1055
  catch (err) {
1091
- res.writeHead(400);
1056
+ res.writeHead(500);
1092
1057
  res.end(JSON.stringify({ error: err.message }));
1093
1058
  }
1094
1059
  });
1095
1060
  return;
1096
1061
  }
1097
- if (url === '/api/clear-queue' && req.method === 'POST') {
1098
- const cleared = agentQueue.length;
1099
- agentQueue.length = 0;
1100
- process.stdout.write(` 🗑 Queue cleared (${cleared} items)\n`);
1101
- res.writeHead(200, { 'Content-Type': 'application/json' });
1102
- res.end(JSON.stringify({ ok: true, cleared }));
1062
+ // Server-Sent Events endpoint
1063
+ if (url === '/api/events') {
1064
+ res.writeHead(200, {
1065
+ 'Content-Type': 'text/event-stream',
1066
+ 'Cache-Control': 'no-cache',
1067
+ 'Connection': 'keep-alive',
1068
+ });
1069
+ res.write('data: connected\n\n');
1070
+ sseClients.add(res);
1071
+ req.on('close', () => sseClients.delete(res));
1103
1072
  return;
1104
1073
  }
1105
- if (url === '/api/set-agent' && req.method === 'POST') {
1074
+ // ── E2E routes ─────────────────────────────────────────────────────────
1075
+ if (url === '/api/e2e/generate-plan' && req.method === 'POST') {
1106
1076
  let body = '';
1107
1077
  req.on('data', d => body += d);
1108
1078
  req.on('end', () => {
1109
1079
  try {
1110
- const { agent } = JSON.parse(body);
1111
- const configPath = path.join(projectRoot, 'viberadar.config.json');
1112
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1113
- config.agent = agent;
1114
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1115
- scheduleRescan();
1080
+ const { featureKey } = JSON.parse(body);
1081
+ broadcast('e2e-plan-generating', { featureKey });
1082
+ runAgent('generate-e2e-plan', featureKey);
1116
1083
  res.writeHead(200, { 'Content-Type': 'application/json' });
1117
- res.end(JSON.stringify({ ok: true, agent }));
1084
+ res.end(JSON.stringify({ ok: true }));
1118
1085
  }
1119
1086
  catch (err) {
1120
- res.writeHead(500);
1087
+ res.writeHead(400);
1121
1088
  res.end(JSON.stringify({ error: err.message }));
1122
1089
  }
1123
1090
  });
1124
1091
  return;
1125
1092
  }
1126
- if (url === '/api/set-model' && req.method === 'POST') {
1093
+ const planMatch = url.match(/^\/api\/e2e\/plan\/(.+)$/);
1094
+ if (planMatch && req.method === 'GET') {
1095
+ const featureKey = decodeURIComponent(planMatch[1]);
1096
+ const plan = loadE2ePlan(projectRoot, featureKey);
1097
+ if (!plan) {
1098
+ res.writeHead(404);
1099
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1100
+ return;
1101
+ }
1102
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1103
+ res.end(JSON.stringify(plan));
1104
+ return;
1105
+ }
1106
+ if (url === '/api/e2e/review' && req.method === 'POST') {
1127
1107
  let body = '';
1128
1108
  req.on('data', d => body += d);
1129
1109
  req.on('end', () => {
1130
1110
  try {
1131
- const { model } = JSON.parse(body);
1132
- const configPath = path.join(projectRoot, 'viberadar.config.json');
1133
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1134
- config.model = model || undefined;
1135
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1136
- scheduleRescan();
1111
+ const { featureKey, testCaseId, status } = JSON.parse(body);
1112
+ const plan = loadE2ePlan(projectRoot, featureKey);
1113
+ if (!plan) {
1114
+ res.writeHead(404);
1115
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1116
+ return;
1117
+ }
1118
+ const tc = plan.testCases.find(t => t.id === testCaseId);
1119
+ if (tc)
1120
+ tc.status = status;
1121
+ saveE2ePlan(projectRoot, plan);
1137
1122
  res.writeHead(200, { 'Content-Type': 'application/json' });
1138
- res.end(JSON.stringify({ ok: true, model }));
1123
+ res.end(JSON.stringify({ ok: true, plan }));
1139
1124
  }
1140
1125
  catch (err) {
1141
- res.writeHead(500);
1126
+ res.writeHead(400);
1142
1127
  res.end(JSON.stringify({ error: err.message }));
1143
1128
  }
1144
1129
  });
1145
1130
  return;
1146
1131
  }
1147
- if (url === '/api/agent-reauth' && req.method === 'POST') {
1148
- const agent = currentData.agent;
1149
- if (!agent) {
1150
- res.writeHead(400, { 'Content-Type': 'application/json' });
1151
- res.end(JSON.stringify({ error: 'No agent configured' }));
1152
- return;
1153
- }
1154
- res.writeHead(200, { 'Content-Type': 'application/json' });
1155
- res.end(JSON.stringify({ ok: true }));
1156
- const cliName = agent === 'claude' ? (WIN ? 'claude.cmd' : 'claude') : (WIN ? 'codex.cmd' : 'codex');
1157
- // Claude: `claude auth logout` / `claude auth login`
1158
- // Codex: `codex logout` / `codex login` (no `auth` subcommand)
1159
- const logoutArgs = agent === 'claude' ? ['auth', 'logout'] : ['logout'];
1160
- const loginArgs = agent === 'claude' ? ['auth', 'login'] : ['login'];
1161
- // Step 1: logout
1162
- broadcast('agent-output', { line: `🔑 Выхожу из ${agent}...` });
1163
- const logoutProc = (0, child_process_1.spawn)(cliName, logoutArgs, {
1164
- cwd: projectRoot, shell: true, stdio: 'pipe',
1165
- });
1166
- let logoutStderr = '';
1167
- logoutProc.stderr?.on('data', (d) => { logoutStderr += d.toString(); });
1168
- logoutProc.stdout?.on('data', (d) => {
1169
- for (const l of d.toString().split('\n').filter(Boolean)) {
1170
- broadcast('agent-output', { line: l });
1132
+ if (url === '/api/e2e/review-all' && req.method === 'POST') {
1133
+ let body = '';
1134
+ req.on('data', d => body += d);
1135
+ req.on('end', () => {
1136
+ try {
1137
+ const { featureKey, status } = JSON.parse(body);
1138
+ const plan = loadE2ePlan(projectRoot, featureKey);
1139
+ if (!plan) {
1140
+ res.writeHead(404);
1141
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1142
+ return;
1143
+ }
1144
+ for (const tc of plan.testCases)
1145
+ tc.status = status;
1146
+ saveE2ePlan(projectRoot, plan);
1147
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1148
+ res.end(JSON.stringify({ ok: true, plan }));
1149
+ }
1150
+ catch (err) {
1151
+ res.writeHead(400);
1152
+ res.end(JSON.stringify({ error: err.message }));
1171
1153
  }
1172
1154
  });
1173
- logoutProc.on('close', (logoutCode) => {
1174
- if (logoutCode === 0) {
1175
- broadcast('agent-output', { line: '✅ Вышли из аккаунта' });
1155
+ return;
1156
+ }
1157
+ if (url === '/api/e2e/write-tests' && req.method === 'POST') {
1158
+ let body = '';
1159
+ req.on('data', d => body += d);
1160
+ req.on('end', () => {
1161
+ try {
1162
+ const { featureKey } = JSON.parse(body);
1163
+ broadcast('e2e-tests-writing', { featureKey });
1164
+ runAgent('write-e2e-tests', featureKey);
1165
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1166
+ res.end(JSON.stringify({ ok: true }));
1176
1167
  }
1177
- else {
1178
- broadcast('agent-output', { line: `⚠ logout вернул код ${logoutCode}` });
1179
- if (logoutStderr.trim()) {
1180
- broadcast('agent-output', { line: logoutStderr.trim(), isDim: true });
1181
- }
1168
+ catch (err) {
1169
+ res.writeHead(400);
1170
+ res.end(JSON.stringify({ error: err.message }));
1182
1171
  }
1183
- // Step 2: login (opens browser)
1184
- broadcast('agent-output', { line: `🔑 Запускаю авторизацию ${agent}...` });
1185
- const loginProc = (0, child_process_1.spawn)(cliName, loginArgs, {
1186
- cwd: projectRoot, shell: true, stdio: 'pipe',
1187
- });
1188
- loginProc.stdout?.on('data', (d) => {
1189
- for (const l of d.toString().split('\n').filter(Boolean)) {
1190
- broadcast('agent-output', { line: l });
1191
- }
1192
- });
1193
- loginProc.stderr?.on('data', (d) => {
1194
- for (const l of d.toString().split('\n').filter(Boolean)) {
1195
- broadcast('agent-output', { line: l });
1196
- }
1197
- });
1198
- loginProc.on('close', (loginCode) => {
1199
- if (loginCode === 0) {
1200
- broadcast('agent-output', { line: '✅ Авторизация завершена! Можно запускать агента.' });
1172
+ });
1173
+ return;
1174
+ }
1175
+ if (url === '/api/e2e/run-tests' && req.method === 'POST') {
1176
+ let body = '';
1177
+ req.on('data', d => body += d);
1178
+ req.on('end', async () => {
1179
+ try {
1180
+ const { featureKey } = JSON.parse(body);
1181
+ const plan = loadE2ePlan(projectRoot, featureKey);
1182
+ if (!plan) {
1183
+ res.writeHead(404);
1184
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1185
+ return;
1201
1186
  }
1202
- else {
1203
- broadcast('agent-output', { line: `⚠ Авторизация не завершена (код ${loginCode}).` });
1204
- broadcast('agent-output', { line: ` Попробуй вручную: ${[cliName, ...loginArgs].join(' ')}` });
1187
+ const writtenCases = plan.testCases.filter(tc => ['written', 'passed', 'failed'].includes(tc.status));
1188
+ const testFiles = writtenCases
1189
+ .map(tc => tc.testFilePath)
1190
+ .filter((f) => !!f)
1191
+ .map(f => path.isAbsolute(f) ? f : path.join(projectRoot, f));
1192
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1193
+ res.end(JSON.stringify({ ok: true, count: testFiles.length }));
1194
+ broadcast('e2e-tests-running', { featureKey, count: testFiles.length });
1195
+ const result = await runPlaywrightTests(testFiles.length > 0 ? testFiles : [`e2e/${featureKey}`], projectRoot);
1196
+ // Update plan with results
1197
+ for (const tc of plan.testCases) {
1198
+ if (result.results[tc.name]) {
1199
+ tc.status = result.results[tc.name];
1200
+ if (result.errors[tc.name])
1201
+ tc.lastError = result.errors[tc.name];
1202
+ }
1203
+ tc.screenshotPaths = collectScreenshots(projectRoot, featureKey, tc.id);
1205
1204
  }
1206
- });
1207
- loginProc.on('error', () => {
1208
- broadcast('agent-output', { line: `❌ Не удалось запустить ${cliName} auth login` });
1209
- });
1210
- });
1211
- logoutProc.on('error', () => {
1212
- broadcast('agent-output', { line: `❌ Не удалось запустить ${cliName}. Проверь что CLI установлен.` });
1205
+ saveE2ePlan(projectRoot, plan);
1206
+ broadcast('e2e-tests-done', { featureKey, passed: result.passed, failed: result.failed, plan });
1207
+ }
1208
+ catch (err) {
1209
+ res.writeHead(400);
1210
+ res.end(JSON.stringify({ error: err.message }));
1211
+ }
1213
1212
  });
1214
1213
  return;
1215
1214
  }
1216
- // Server-Sent Events endpoint
1217
- if (url === '/api/events') {
1218
- res.writeHead(200, {
1219
- 'Content-Type': 'text/event-stream',
1220
- 'Cache-Control': 'no-cache',
1221
- 'Connection': 'keep-alive',
1222
- });
1223
- res.write('data: connected\n\n');
1224
- sseClients.add(res);
1225
- req.on('close', () => sseClients.delete(res));
1215
+ if (url.startsWith('/api/e2e/screenshot/') && req.method === 'GET') {
1216
+ const relPath = decodeURIComponent(url.slice('/api/e2e/screenshot/'.length));
1217
+ // Path traversal protection
1218
+ const screenshotBase = path.join(projectRoot, '.viberadar', 'e2e-screenshots');
1219
+ const safePath = path.resolve(screenshotBase, relPath);
1220
+ if (!safePath.startsWith(screenshotBase)) {
1221
+ res.writeHead(403);
1222
+ res.end('Forbidden');
1223
+ return;
1224
+ }
1225
+ try {
1226
+ const img = fs.readFileSync(safePath);
1227
+ const ext = path.extname(safePath).toLowerCase();
1228
+ const mime = ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/webp';
1229
+ res.writeHead(200, { 'Content-Type': mime });
1230
+ res.end(img);
1231
+ }
1232
+ catch {
1233
+ res.writeHead(404);
1234
+ res.end('Not found');
1235
+ }
1226
1236
  return;
1227
1237
  }
1228
1238
  res.writeHead(404);
@@ -1236,11 +1246,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1236
1246
  reject(err);
1237
1247
  }
1238
1248
  });
1239
- server.listen(port, '127.0.0.1', () => {
1240
- resolve({ server, triggerCoverage });
1241
- // Async startup check — runs after server is up, doesn't block
1242
- checkAgentInstalled(currentData.agent);
1243
- });
1249
+ server.listen(port, '127.0.0.1', () => resolve({ server, triggerCoverage }));
1244
1250
  process.once('SIGINT', () => {
1245
1251
  console.log('\n👋 VibeRadar stopped.');
1246
1252
  // Destroy all SSE connections so server.close() doesn't hang waiting for them