viberadar 0.3.44 → 0.3.46

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) {
@@ -124,47 +123,75 @@ function fmtTool(name, input = {}) {
124
123
  default: return `🔧 ${name}${fp ? ': ' + fp : ''}`;
125
124
  }
126
125
  }
127
- // ─── Test runner after agent ──────────────────────────────────────────────────
128
- 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
126
+ function e2ePlanDir(projectRoot) {
127
+ return path.join(projectRoot, '.viberadar', 'e2e-plans');
128
+ }
129
+ function e2ePlanPath(projectRoot, featureKey) {
130
+ return path.join(e2ePlanDir(projectRoot), `${featureKey}.json`);
131
+ }
132
+ function loadE2ePlan(projectRoot, featureKey) {
133
+ const fp = e2ePlanPath(projectRoot, featureKey);
134
+ try {
135
+ return JSON.parse(fs.readFileSync(fp, 'utf-8'));
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ function saveE2ePlan(projectRoot, plan) {
142
+ const dir = e2ePlanDir(projectRoot);
143
+ fs.mkdirSync(dir, { recursive: true });
144
+ plan.updatedAt = new Date().toISOString();
145
+ fs.writeFileSync(e2ePlanPath(projectRoot, plan.featureKey), JSON.stringify(plan, null, 2), 'utf-8');
146
+ }
147
+ function e2eScreenshotDir(projectRoot, featureKey) {
148
+ return path.join(projectRoot, '.viberadar', 'e2e-screenshots', featureKey);
149
+ }
150
+ function collectScreenshots(projectRoot, featureKey) {
151
+ const baseDir = e2eScreenshotDir(projectRoot, featureKey);
152
+ const result = new Map();
153
+ if (!fs.existsSync(baseDir))
154
+ return result;
155
+ try {
156
+ for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
157
+ if (!entry.isDirectory())
158
+ continue;
159
+ const tcPath = path.join(baseDir, entry.name);
160
+ const screenshots = fs.readdirSync(tcPath)
161
+ .filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f))
162
+ .map(f => `${featureKey}/${entry.name}/${f}`);
163
+ result.set(entry.name, screenshots);
164
+ }
165
+ }
166
+ catch { }
167
+ return result;
168
+ }
169
+ function hasPlaywright(projectRoot) {
170
+ try {
171
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
172
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
173
+ return !!deps['@playwright/test'] || !!deps['playwright'];
174
+ }
175
+ catch {
176
+ return false;
139
177
  }
140
- // Already relative or unrecognized format — return as-is
141
- return filePath.replace(/\\/g, '/');
142
178
  }
179
+ // ─── Test runner after agent ──────────────────────────────────────────────────
180
+ const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
143
181
  function runTestFiles(files, projectRoot) {
144
182
  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' });
183
+ const proc = (0, child_process_1.spawn)('npx', ['vitest', 'run', '--reporter=json', ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
147
184
  let stdout = '';
148
- let stderr = '';
149
185
  proc.stdout?.on('data', (d) => stdout += d.toString());
150
- proc.stderr?.on('data', (d) => stderr += d.toString());
151
- proc.on('close', (code) => {
186
+ proc.on('close', () => {
152
187
  try {
153
188
  // vitest --reporter=json wraps output; find the JSON object
154
189
  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;
162
- }
163
- const json = JSON.parse(match[0]);
190
+ const json = JSON.parse(match ? match[0] : stdout);
164
191
  // Extract per-file failure details
165
192
  const fileDetails = {};
166
193
  for (const tr of (json.testResults ?? [])) {
167
- const fp = tr.name ?? tr.testFilePath ?? '';
194
+ const fp = tr.testFilePath ?? '';
168
195
  const errors = [];
169
196
  for (const ar of (tr.assertionResults ?? [])) {
170
197
  if (ar.status === 'failed') {
@@ -187,57 +214,70 @@ function runTestFiles(files, projectRoot) {
187
214
  fileDetails,
188
215
  });
189
216
  }
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 });
217
+ catch {
218
+ resolve({ passed: 0, failed: 0, files, fileDetails: {} });
193
219
  }
194
220
  });
195
- proc.on('error', (err) => resolve({ passed: 0, failed: files.length, files, fileDetails: {}, runError: err.message }));
221
+ proc.on('error', () => resolve({ passed: 0, failed: 0, files, fileDetails: {} }));
196
222
  });
197
223
  }
198
- // ─── Prompt builders ──────────────────────────────────────────────────────────
199
- /** Safely read a file, return null if not found */
200
- function tryReadFile(absPath) {
201
- try {
202
- return fs.readFileSync(absPath, 'utf-8');
203
- }
204
- catch {
205
- return null;
206
- }
207
- }
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
- }
222
- return `### \`${relPath}\`\n\`\`\`${ext}\n${content}\n\`\`\``;
223
- }
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);
224
+ function runPlaywrightTests(files, projectRoot) {
225
+ return new Promise((resolve) => {
226
+ const proc = (0, child_process_1.spawn)('npx', ['playwright', 'test', ...files, '--reporter=json'], { cwd: projectRoot, shell: true, stdio: 'pipe' });
227
+ let stdout = '';
228
+ proc.stdout?.on('data', (d) => stdout += d.toString());
229
+ proc.on('close', () => {
230
+ try {
231
+ const match = stdout.match(/\{[\s\S]*"suites"[\s\S]*\}/);
232
+ const json = JSON.parse(match ? match[0] : stdout);
233
+ const testResults = [];
234
+ let passed = 0, failed = 0;
235
+ const walkSuites = (suites) => {
236
+ for (const suite of suites) {
237
+ for (const spec of (suite.specs || [])) {
238
+ for (const test of (spec.tests || [])) {
239
+ for (const result of (test.results || [])) {
240
+ const status = result.status === 'passed' ? 'passed'
241
+ : result.status === 'failed' ? 'failed'
242
+ : 'skipped';
243
+ if (status === 'passed')
244
+ passed++;
245
+ if (status === 'failed')
246
+ failed++;
247
+ testResults.push({
248
+ testCaseId: spec.title || '',
249
+ title: spec.title || '',
250
+ status,
251
+ error: result.error?.message?.slice(0, 500),
252
+ duration: result.duration || 0,
253
+ });
254
+ }
255
+ }
256
+ }
257
+ if (suite.suites)
258
+ walkSuites(suite.suites);
259
+ }
260
+ };
261
+ walkSuites(json.suites || []);
262
+ resolve({ passed, failed, testResults });
263
+ }
264
+ catch {
265
+ resolve({ passed: 0, failed: 0, testResults: [] });
266
+ }
267
+ });
268
+ proc.on('error', () => resolve({ passed: 0, failed: 0, testResults: [] }));
269
+ });
233
270
  }
234
- function buildWriteTestsPrompt(feat, modules, testRunner, projectRoot) {
271
+ // ─── Prompt builders ──────────────────────────────────────────────────────────
272
+ function buildWriteTestsPrompt(feat, modules, testRunner) {
235
273
  const untestedMods = modules
236
274
  .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');
275
+ const untestedPaths = untestedMods.map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
276
+ const existing = modules
277
+ .filter(m => m.featureKeys.includes(feat.key) && m.type === 'test')
278
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
239
279
  const hasNoTestInfra = modules.filter(m => m.type === 'test').length === 0;
240
- // Split by suggested type
280
+ // Split by suggested type and give explicit guidance
241
281
  const unitFiles = untestedMods.filter(m => (m.suggestedTestType ?? 'unit') === 'unit');
242
282
  const integrationFiles = untestedMods.filter(m => m.suggestedTestType === 'integration');
243
283
  const typeSummary = [
@@ -248,123 +288,72 @@ function buildWriteTestsPrompt(feat, modules, testRunner, projectRoot) {
248
288
  ? `Integration-тесты (${integrationFiles.length} файлов): используй реальную БД через test-helpers или pg-mem`
249
289
  : '',
250
290
  ].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
291
  return [
280
292
  `Напиши тесты для фичи "${feat.label}".`,
281
293
  ``,
282
- typeSummary ? `Рекомендации по типам тестов:\n${typeSummary}` : '',
283
- ``,
284
- `## Исходные файлы для покрытия`,
285
- ...sourceBlocks,
294
+ `Файлов без тестов (${untestedMods.length}):`,
295
+ ...untestedPaths,
286
296
  ``,
287
- exampleBlocks.length > 0 ? `## Примеры тестов (следуй этим паттернам)` : '',
288
- ...exampleBlocks,
297
+ typeSummary ? `Рекомендации по типам тестов:\n${typeSummary}` : '',
289
298
  ``,
290
- infraBlocks.length > 0 ? `## Тестовая инфраструктура` : '',
291
- ...infraBlocks,
299
+ existing.length > 0
300
+ ? `Существующие тест-файлы (для справки по паттернам):\n${existing.join('\n')}`
301
+ : '',
292
302
  ``,
293
303
  hasNoTestInfra
294
304
  ? `⚠️ В проекте пока нет ни одного теста. Если нужна тестовая инфраструктура (test-helpers.ts, vitest.config.ts) — создай её сначала.`
295
305
  : '',
296
- `## Требования`,
306
+ ``,
307
+ `Требования:`,
297
308
  `- Используй ${testRunner}`,
298
- `- Для каждого исходного файла создай соответствующий тест-файл`,
299
- `- Покрой: happy path, edge cases, обработку ошибок`,
300
- `- Следуй паттернам примеров выше`,
309
+ `- Следуй паттернам существующих тестов в проекте`,
310
+ `- Для каждого файла создай соответствующий тест-файл`,
301
311
  `- Не изменяй существующие тесты`,
302
- hasNoTestInfra
303
- ? `- Если нужно — создай test-helpers.ts и vitest.config.ts перед написанием тестов`
304
- : `- Если в исходном файле есть импорты типов которых нет выше — прочитай только эти файлы с типами. Не исследуй проект вширь.`,
305
- ].filter(s => s !== null && s !== undefined && s !== '').join('\n');
312
+ ].filter(Boolean).join('\n');
306
313
  }
307
- function buildWriteTestsForFilePrompt(filePath, feat, modules, testRunner, projectRoot) {
314
+ function buildWriteTestsForFilePrompt(filePath, feat, modules, testRunner) {
308
315
  const normalPath = filePath.replace(/\\/g, '/');
309
- // Find module info to get suggestedTestType and absolute path
316
+ // Find module info to get suggestedTestType (match by relativePath or absolute path)
310
317
  const sourceModule = modules.find(m => m.relativePath.replace(/\\/g, '/') === normalPath || m.path === filePath);
311
318
  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));
319
+ const existing = modules
320
+ .filter(m => m.featureKeys.includes(feat.key) && m.type === 'test')
321
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
322
+ const hasNoTestInfra = modules.filter(m => m.type === 'test').length === 0;
329
323
  const testTypeBlock = suggestedTestType === 'integration'
330
324
  ? [
331
325
  `Тип теста: INTEGRATION`,
332
- `→ Используй test-helpers или реальную БД.`,
326
+ `Файл обращается к БД, репозиториям или внешним сервисам.`,
327
+ `→ Используй test-helpers или pg-mem для работы с реальной БД.`,
333
328
  `→ Не мокай репозитории — проверяй реальное поведение.`,
334
329
  ].join('\n')
335
330
  : [
336
331
  `Тип теста: UNIT`,
337
332
  `→ Замокай все внешние зависимости через \`vi.mock()\`.`,
338
333
  `→ Не используй реальную БД или внешние сервисы.`,
334
+ `→ Тест должен работать быстро без внешних зависимостей.`,
339
335
  ].join('\n');
340
- const hasNoTestInfra = existingTestMods.length === 0;
341
336
  return [
342
- `Напиши тест для файла \`${normalPath}\`. Фича: "${feat.label}"`,
337
+ `Напиши тест для файла \`${normalPath}\`.`,
338
+ `Фича: "${feat.label}"`,
343
339
  ``,
344
340
  testTypeBlock,
345
341
  ``,
346
- `## Исходный файл`,
347
- sourceBlock,
348
- ``,
349
- exampleBlocks.length > 0 ? `## Примеры тестов (следуй этим паттернам)` : '',
350
- ...exampleBlocks,
351
- ``,
352
- infraBlocks.length > 0 ? `## Тестовая инфраструктура` : '',
353
- ...infraBlocks,
342
+ existing.length > 0
343
+ ? `Существующие тест-файлы фичи (следуй этим паттернам):\n${existing.join('\n')}`
344
+ : 'Существующих тестов в этой фиче пока нет — следуй общим паттернам проекта.',
354
345
  ``,
355
346
  hasNoTestInfra
356
347
  ? `⚠️ В проекте пока нет ни одного теста. Если нужна тестовая инфраструктура (test-helpers.ts, vitest.config.ts) — создай её сначала.`
357
348
  : '',
358
- `## Требования`,
349
+ ``,
350
+ `Требования:`,
359
351
  `- Используй ${testRunner}`,
360
352
  `- Создай один тест-файл для \`${normalPath}\``,
361
353
  `- Покрой: happy path, edge cases, обработку ошибок`,
362
- `- Следуй паттернам примеров выше`,
354
+ `- Следуй паттернам существующих тестов в проекте`,
363
355
  `- Не изменяй существующие тесты`,
364
- hasNoTestInfra
365
- ? `- Если нужно — создай test-helpers.ts и vitest.config.ts перед написанием тестов`
366
- : `- Если в исходном файле есть импорты типов которых нет выше — прочитай только эти файлы с типами. Не исследуй проект вширь.`,
367
- ].filter(s => s !== null && s !== undefined && s !== '').join('\n');
356
+ ].filter(Boolean).join('\n');
368
357
  }
369
358
  function buildFixTestsPrompt(filePath, errors) {
370
359
  const normalPath = filePath.replace(/\\/g, '/');
@@ -382,28 +371,6 @@ function buildFixTestsPrompt(filePath, errors) {
382
371
  `- После исправления запусти тест, чтобы убедиться что он проходит`,
383
372
  ].join('\n');
384
373
  }
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
374
  function buildMapUnmappedPrompt(modules, features) {
408
375
  const unmapped = modules
409
376
  .filter(m => m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0))
@@ -425,6 +392,94 @@ function buildMapUnmappedPrompt(modules, features) {
425
392
  ...unmapped,
426
393
  ].join('\n');
427
394
  }
395
+ // ─── E2E prompt builders ────────────────────────────────────────────────────
396
+ function buildE2ePlanPrompt(feat, modules, projectRoot) {
397
+ const srcFiles = modules
398
+ .filter(m => m.featureKeys.includes(feat.key) && m.type !== 'test' && !m.isInfra)
399
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
400
+ const pageFiles = modules
401
+ .filter(m => m.featureKeys.includes(feat.key) &&
402
+ (/page\./i.test(m.name) || /route/i.test(m.name) || /\/(pages|app)\//i.test(m.relativePath)))
403
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
404
+ const pwConfigExists = fs.existsSync(path.join(projectRoot, 'playwright.config.ts')) ||
405
+ fs.existsSync(path.join(projectRoot, 'playwright.config.js'));
406
+ return [
407
+ `Создай план E2E-тестов для фичи "${feat.label}".`,
408
+ ``,
409
+ `Прочитай исходный код фичи чтобы понять UI: формы, кнопки, навигацию, состояния, API-вызовы.`,
410
+ ``,
411
+ `Исходные файлы фичи (${srcFiles.length}):`,
412
+ ...srcFiles,
413
+ ``,
414
+ pageFiles.length > 0 ? `Страницы/роуты:\n${pageFiles.join('\n')}` : '',
415
+ ``,
416
+ `Важно:`,
417
+ `- Каждый тест-кейс должен проверять один пользовательский сценарий`,
418
+ `- Включи happy path, edge cases, ошибки валидации`,
419
+ `- Steps должны быть конкретными: укажи селекторы, URL, текст кнопок из реального кода`,
420
+ `- id тест-кейса: ${feat.key}-<scenario>-NN (например: ${feat.key}-login-01)`,
421
+ ``,
422
+ `Ответ верни СТРОГО в формате JSON (без markdown-блоков, без комментариев, чистый JSON):`,
423
+ `{`,
424
+ ` "baseUrl": "http://localhost:3000",`,
425
+ ` "testCases": [`,
426
+ ` {`,
427
+ ` "id": "${feat.key}-example-01",`,
428
+ ` "name": "Описание сценария на английском",`,
429
+ ` "description": "Что проверяет этот тест",`,
430
+ ` "steps": [`,
431
+ ` "Navigate to /path",`,
432
+ ` "Fill [data-testid=email] with test@example.com",`,
433
+ ` "Click button:has-text('Submit')"`,
434
+ ` ],`,
435
+ ` "expectedResults": [`,
436
+ ` "URL changes to /dashboard",`,
437
+ ` "Text 'Welcome' is visible"`,
438
+ ` ]`,
439
+ ` }`,
440
+ ` ]`,
441
+ `}`,
442
+ ``,
443
+ `Генерируй 5–15 тест-кейсов, покрывающих все основные сценарии фичи.`,
444
+ !pwConfigExists ? `\n⚠️ playwright.config.ts не найден — его нужно будет создать перед написанием тестов.` : '',
445
+ ].filter(Boolean).join('\n');
446
+ }
447
+ function buildWriteE2eTestPrompt(feat, plan, modules) {
448
+ const approvedCases = plan.testCases.filter(tc => tc.status === 'approved');
449
+ const existingE2e = modules
450
+ .filter(m => m.testType === 'e2e' && m.featureKeys.includes(feat.key))
451
+ .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
452
+ return [
453
+ `Напиши Playwright E2E-тесты для фичи "${feat.label}".`,
454
+ `Base URL: ${plan.baseUrl || 'http://localhost:3000'}`,
455
+ ``,
456
+ `Утверждённые тест-кейсы (${approvedCases.length}):`,
457
+ ``,
458
+ ...approvedCases.map((tc, i) => [
459
+ `### ${i + 1}. ${tc.name} (id: ${tc.id})`,
460
+ `Описание: ${tc.description}`,
461
+ `Шаги:`,
462
+ ...tc.steps.map((s, j) => ` ${j + 1}. ${s}`),
463
+ `Ожидаемый результат:`,
464
+ ...tc.expectedResults.map(r => ` - ${r}`),
465
+ ``,
466
+ ].join('\n')),
467
+ ``,
468
+ existingE2e.length > 0
469
+ ? `Существующие E2E тесты (следуй этим паттернам):\n${existingE2e.join('\n')}`
470
+ : 'Существующих E2E тестов пока нет.',
471
+ ``,
472
+ `Требования:`,
473
+ `- Используй Playwright: import { test, expect } from '@playwright/test'`,
474
+ `- Файлы создавай в e2e/${feat.key}/ директории`,
475
+ `- Каждый тест-кейс — отдельный test() блок`,
476
+ `- Добавляй скриншоты после ключевых шагов:`,
477
+ ` await page.screenshot({ path: '.viberadar/e2e-screenshots/${feat.key}/<test-id>/step-N.png' })`,
478
+ `- Используй data-testid селекторы где возможно`,
479
+ `- Добавь page.waitForLoadState() и другие ожидания`,
480
+ `- Не изменяй существующие тесты`,
481
+ ].filter(Boolean).join('\n');
482
+ }
428
483
  // ─── Coverage provider auto-install ──────────────────────────────────────────
429
484
  function autoInstallCoverageProvider(projectRoot) {
430
485
  return new Promise(resolve => {
@@ -491,7 +546,6 @@ function startServer({ data: initialData, port, projectRoot }) {
491
546
  let coverageError = false;
492
547
  let agentRunning = false;
493
548
  let testsRunning = false;
494
- const agentQueue = [];
495
549
  // Keyed by absolute file path → per-file failure details from last test run
496
550
  const lastTestResults = new Map();
497
551
  // ── SSE clients ────────────────────────────────────────────────────────────
@@ -590,99 +644,95 @@ function startServer({ data: initialData, port, projectRoot }) {
590
644
  });
591
645
  }
592
646
  // ── Agent runner ───────────────────────────────────────────────────────────
593
- /** Check if the configured agent CLI is installed; broadcast a warning if not */
594
- function checkAgentInstalled(agent) {
595
- if (!agent)
647
+ function runAgent(task, featureKey, filePath) {
648
+ if (agentRunning) {
649
+ process.stdout.write(' ⏳ Agent already running\n');
596
650
  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
651
  }
634
- else {
635
- broadcast('agent-done', { queueLength: 0 });
652
+ const agent = currentData.agent;
653
+ if (!agent) {
654
+ broadcast('agent-error', { message: 'Агент не выбран. Укажи agent в viberadar.config.json' });
655
+ return;
636
656
  }
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 ──
657
+ // Build prompt
642
658
  let prompt;
659
+ let title;
660
+ const agentLabel = agent === 'claude' ? 'Claude Code' : 'Codex';
643
661
  if (task === 'write-tests') {
644
662
  const feat = currentData.features?.find(f => f.key === featureKey);
645
663
  if (!feat) {
646
664
  broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
647
- agentRunning = false;
648
- processNextInQueue();
649
665
  return;
650
666
  }
651
- prompt = buildWriteTestsPrompt(feat, currentData.modules, currentData.testRunner || 'vitest', projectRoot);
667
+ prompt = buildWriteTestsPrompt(feat, currentData.modules, currentData.testRunner || 'vitest');
668
+ title = `${agentLabel} — тесты для "${feat.label}"`;
652
669
  }
653
670
  else if (task === 'write-tests-file') {
654
671
  const feat = currentData.features?.find(f => f.key === featureKey);
655
672
  if (!feat || !filePath) {
656
- broadcast('agent-error', { message: 'Не указана фича или файл' });
657
- agentRunning = false;
658
- processNextInQueue();
673
+ broadcast('agent-error', { message: `Не указана фича или файл` });
659
674
  return;
660
675
  }
661
- prompt = buildWriteTestsForFilePrompt(filePath, feat, currentData.modules, currentData.testRunner || 'vitest', projectRoot);
676
+ prompt = buildWriteTestsForFilePrompt(filePath, feat, currentData.modules, currentData.testRunner || 'vitest');
677
+ const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
678
+ title = `${agentLabel} — тест для "${fileName}"`;
662
679
  }
663
680
  else if (task === 'fix-tests') {
664
- if (!filePath || !savedErrors || savedErrors.length === 0) {
665
- broadcast('agent-error', { message: `Нет сохранённых ошибок для ${filePath}` });
666
- agentRunning = false;
667
- processNextInQueue();
681
+ if (!filePath) {
682
+ broadcast('agent-error', { message: 'Не указан файл для исправления' });
683
+ return;
684
+ }
685
+ // Look up stored errors for this file (match by absolute or relative path)
686
+ let storedErrors;
687
+ for (const [fp, detail] of lastTestResults) {
688
+ const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
689
+ if (rel === filePath.replace(/\\/g, '/') || fp === filePath) {
690
+ storedErrors = detail.errors;
691
+ break;
692
+ }
693
+ }
694
+ if (!storedErrors || storedErrors.length === 0) {
695
+ broadcast('agent-error', { message: `Нет сохранённых ошибок для ${filePath}. Сначала запусти тесты.` });
668
696
  return;
669
697
  }
670
- prompt = buildFixTestsPrompt(filePath, savedErrors);
698
+ prompt = buildFixTestsPrompt(filePath, storedErrors);
699
+ const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
700
+ title = `${agentLabel} — исправить тесты в "${fileName}"`;
671
701
  }
672
- else if (task === 'fix-tests-all') {
673
- if (!savedFailedFiles || savedFailedFiles.length === 0) {
674
- broadcast('agent-error', { message: 'Нет упавших тестов для исправления' });
675
- agentRunning = false;
676
- processNextInQueue();
702
+ else if (task === 'generate-e2e-plan') {
703
+ const feat = currentData.features?.find(f => f.key === featureKey);
704
+ if (!feat) {
705
+ broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
677
706
  return;
678
707
  }
679
- prompt = buildFixAllTestsPrompt(savedFailedFiles, savedTestType || 'unit');
708
+ prompt = buildE2ePlanPrompt(feat, currentData.modules, projectRoot);
709
+ title = `${agentLabel} — E2E план для "${feat.label}"`;
710
+ }
711
+ else if (task === 'write-e2e-tests') {
712
+ const feat = currentData.features?.find(f => f.key === featureKey);
713
+ if (!feat) {
714
+ broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
715
+ return;
716
+ }
717
+ const plan = loadE2ePlan(projectRoot, feat.key);
718
+ if (!plan) {
719
+ broadcast('agent-error', { message: `E2E план не найден для "${feat.label}". Сначала сгенерируй план.` });
720
+ return;
721
+ }
722
+ const approved = plan.testCases.filter(tc => tc.status === 'approved');
723
+ if (approved.length === 0) {
724
+ broadcast('agent-error', { message: `Нет одобренных тест-кейсов в плане "${feat.label}"` });
725
+ return;
726
+ }
727
+ prompt = buildWriteE2eTestPrompt(feat, plan, currentData.modules);
728
+ title = `${agentLabel} — E2E тесты для "${feat.label}" (${approved.length} кейсов)`;
680
729
  }
681
730
  else {
682
731
  prompt = buildMapUnmappedPrompt(currentData.modules, currentData.features || []);
732
+ title = `${agentLabel} — разобрать unmapped`;
683
733
  }
684
734
  agentRunning = true;
685
- broadcast('agent-started', { title, task, featureKey, filePath: filePath || null, queueLength: agentQueue.length });
735
+ broadcast('agent-started', { title, task, featureKey });
686
736
  process.stdout.write(` 🤖 Running agent (${agent}): ${task}\n`);
687
737
  // Write prompt to .viberadar/task.md for reference
688
738
  const taskDir = path.join(projectRoot, '.viberadar');
@@ -693,7 +743,7 @@ function startServer({ data: initialData, port, projectRoot }) {
693
743
  }
694
744
  catch { }
695
745
  // Spawn via shell, piping prompt from file (avoids TUI mode, supports stream-json)
696
- const shellCmd = buildAgentShellCmd(agent, taskFile, currentData.model);
746
+ const shellCmd = buildAgentShellCmd(agent, taskFile);
697
747
  process.stdout.write(` 🚀 Shell cmd: ${shellCmd}\n`);
698
748
  const proc = (0, child_process_1.spawn)(shellCmd, [], {
699
749
  cwd: projectRoot,
@@ -704,6 +754,8 @@ function startServer({ data: initialData, port, projectRoot }) {
704
754
  broadcast('agent-output', { line: `📄 Задача записана в .viberadar/task.md` });
705
755
  // Track test files written/edited by agent (for auto-run after)
706
756
  const createdTestFiles = [];
757
+ // Accumulate full result text from agent (for JSON plan parsing)
758
+ let agentResultText = '';
707
759
  function trackWrittenFiles(raw) {
708
760
  try {
709
761
  const ev = JSON.parse(raw);
@@ -734,9 +786,11 @@ function startServer({ data: initialData, port, projectRoot }) {
734
786
  return;
735
787
  }
736
788
  if (parsed.startsWith('§RESULT§')) {
789
+ const resultBody = parsed.slice('§RESULT§'.length);
790
+ agentResultText += resultBody + '\n';
737
791
  // Full result summary — split into lines and prefix with indent
738
792
  broadcast('agent-output', { line: '─────────────────────────────' });
739
- for (const l of parsed.slice('§RESULT§'.length).split('\n')) {
793
+ for (const l of resultBody.split('\n')) {
740
794
  if (l.trim())
741
795
  broadcast('agent-output', { line: ' ' + l });
742
796
  }
@@ -759,33 +813,21 @@ function startServer({ data: initialData, port, projectRoot }) {
759
813
  agentRunning = false;
760
814
  if (code === 0) {
761
815
  // 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) {
816
+ const testFilesToRun = task === 'fix-tests' && filePath ? [filePath] : createdTestFiles;
817
+ if ((task === 'write-tests' || task === 'write-tests-file' || task === 'fix-tests') && testFilesToRun.length > 0) {
773
818
  broadcast('agent-output', { line: '─────────────────────────────' });
774
819
  broadcast('agent-output', { line: `🧪 Запускаю тесты (${testFilesToRun.length} файлов)...` });
775
820
  const result = await runTestFiles(testFilesToRun, projectRoot);
776
821
  // Store per-file results for "fix-tests" feature
822
+ // path.resolve() normalizes slashes on Windows (vitest outputs forward slashes)
777
823
  lastTestResults.clear();
778
824
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
779
825
  if (detail.failed > 0)
780
826
  lastTestResults.set(path.resolve(fp), { failed: detail.failed, errors: detail.errors });
781
827
  }
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`;
828
+ const summary = result.failed === 0
829
+ ? `✅ Все тесты прошли: ${result.passed} passed`
830
+ : `⚠️ ${result.passed} passed, ${result.failed} failed`;
789
831
  broadcast('agent-output', { line: summary });
790
832
  if (result.failed > 0) {
791
833
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
@@ -801,120 +843,82 @@ function startServer({ data: initialData, port, projectRoot }) {
801
843
  }
802
844
  broadcast('agent-summary', result);
803
845
  }
846
+ // ── E2E plan post-processing ─────────────────────────────────────
847
+ if (task === 'generate-e2e-plan' && featureKey) {
848
+ const feat = currentData.features?.find(f => f.key === featureKey);
849
+ try {
850
+ // Extract JSON from agent result text
851
+ const jsonMatch = agentResultText.match(/\{[\s\S]*"testCases"[\s\S]*\}/);
852
+ if (jsonMatch) {
853
+ const parsed = JSON.parse(jsonMatch[0]);
854
+ const plan = {
855
+ featureKey,
856
+ featureLabel: feat?.label || featureKey,
857
+ generatedAt: new Date().toISOString(),
858
+ updatedAt: new Date().toISOString(),
859
+ baseUrl: parsed.baseUrl || 'http://localhost:3000',
860
+ testCases: (parsed.testCases || []).map((tc) => ({
861
+ id: tc.id || `${featureKey}-${Math.random().toString(36).slice(2, 6)}`,
862
+ name: tc.name || 'Unnamed test',
863
+ description: tc.description || '',
864
+ steps: tc.steps || [],
865
+ expectedResults: tc.expectedResults || [],
866
+ status: 'pending',
867
+ })),
868
+ };
869
+ saveE2ePlan(projectRoot, plan);
870
+ broadcast('e2e-plan-ready', { featureKey, plan: plan });
871
+ broadcast('agent-output', { line: `✅ E2E план сохранён: ${plan.testCases.length} тест-кейсов` });
872
+ }
873
+ else {
874
+ broadcast('e2e-plan-error', { featureKey, message: 'Не удалось извлечь JSON из ответа агента' });
875
+ broadcast('agent-output', { line: '❌ Не удалось распарсить JSON план из ответа', isError: true });
876
+ }
877
+ }
878
+ catch (err) {
879
+ broadcast('e2e-plan-error', { featureKey, message: err.message });
880
+ broadcast('agent-output', { line: `❌ Ошибка парсинга плана: ${err.message}`, isError: true });
881
+ }
882
+ }
883
+ if (task === 'write-e2e-tests' && featureKey) {
884
+ // Update plan: mark approved test cases as 'written' and set file paths
885
+ const plan = loadE2ePlan(projectRoot, featureKey);
886
+ if (plan) {
887
+ for (const tc of plan.testCases) {
888
+ if (tc.status === 'approved') {
889
+ tc.status = 'written';
890
+ // Try to match created test files to test cases
891
+ const matchingFile = createdTestFiles.find(fp => fp.replace(/\\/g, '/').includes(tc.id) ||
892
+ fp.replace(/\\/g, '/').includes(featureKey));
893
+ if (matchingFile) {
894
+ tc.testFilePath = path.relative(projectRoot, matchingFile).replace(/\\/g, '/');
895
+ }
896
+ }
897
+ }
898
+ saveE2ePlan(projectRoot, plan);
899
+ broadcast('e2e-plan-ready', { featureKey, plan: plan });
900
+ broadcast('agent-output', { line: `✅ E2E план обновлён: тесты отмечены как написанные` });
901
+ }
902
+ }
804
903
  process.stdout.write(' ✅ Agent done, rescanning...\n');
805
904
  try {
806
905
  currentData = await (0, scanner_1.scanProject)(projectRoot);
807
906
  broadcast('data-updated');
808
907
  }
809
908
  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();
909
+ broadcast('agent-done');
820
910
  }
821
911
  else {
822
912
  process.stdout.write(` ❌ Agent failed (exit code ${code})\n`);
823
913
  broadcast('agent-error', { message: `Агент завершился с кодом ${code}` });
824
- processNextInQueue();
825
914
  }
826
915
  });
827
916
  proc.on('error', (err) => {
828
917
  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
918
  process.stdout.write(' ❌ Agent spawn error: ' + err.message + '\n');
835
- broadcast('agent-error', { message: msg, notInstalled: isNotFound, agent });
836
- processNextInQueue();
919
+ broadcast('agent-error', { message: `Не удалось запустить ${agent}: ${err.message}` });
837
920
  });
838
921
  }
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
922
  // ── Chokidar watcher ───────────────────────────────────────────────────────
919
923
  chokidar_1.default.watch([
920
924
  '**/*.{ts,tsx,js,jsx,vue,svelte}',
@@ -946,13 +950,25 @@ function startServer({ data: initialData, port, projectRoot }) {
946
950
  const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
947
951
  testErrors[rel] = detail;
948
952
  }
953
+ // Check which features have E2E plans
954
+ const e2ePlansExist = [];
955
+ const plansDir = e2ePlanDir(projectRoot);
956
+ try {
957
+ if (fs.existsSync(plansDir)) {
958
+ for (const f of fs.readdirSync(plansDir)) {
959
+ if (f.endsWith('.json'))
960
+ e2ePlansExist.push(f.replace('.json', ''));
961
+ }
962
+ }
963
+ }
964
+ catch { }
949
965
  res.writeHead(200, { 'Content-Type': 'application/json' });
950
- res.end(JSON.stringify({ ...currentData, testErrors }));
966
+ res.end(JSON.stringify({ ...currentData, testErrors, hasPlaywright: hasPlaywright(projectRoot), e2ePlansExist }));
951
967
  return;
952
968
  }
953
969
  if (url === '/api/status') {
954
970
  res.writeHead(200, { 'Content-Type': 'application/json' });
955
- res.end(JSON.stringify({ coverageRunning, coverageError, agentRunning, queueLength: agentQueue.length }));
971
+ res.end(JSON.stringify({ coverageRunning, coverageError, agentRunning }));
956
972
  return;
957
973
  }
958
974
  if (url === '/api/run-coverage' && req.method === 'POST') {
@@ -995,13 +1011,9 @@ function startServer({ data: initialData, port, projectRoot }) {
995
1011
  if (detail.failed > 0)
996
1012
  lastTestResults.set(path.resolve(fp), { failed: detail.failed, errors: detail.errors });
997
1013
  }
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`;
1014
+ const summary = result.failed === 0
1015
+ ? `✅ Все тесты прошли: ${result.passed} passed`
1016
+ : `⚠️ ${result.passed} passed, ${result.failed} failed`;
1005
1017
  broadcast('agent-output', { line: summary });
1006
1018
  if (result.failed > 0) {
1007
1019
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
@@ -1053,39 +1065,43 @@ function startServer({ data: initialData, port, projectRoot }) {
1053
1065
  }
1054
1066
  if (url === '/api/cancel-agent' && req.method === 'POST') {
1055
1067
  agentRunning = false;
1056
- agentQueue.length = 0; // clear queue too
1057
- process.stdout.write(' ⏹ Agent state reset by user (queue cleared)\n');
1068
+ process.stdout.write(' ⏹ Agent state reset by user\n');
1058
1069
  res.writeHead(200, { 'Content-Type': 'application/json' });
1059
1070
  res.end(JSON.stringify({ ok: true }));
1060
1071
  return;
1061
1072
  }
1062
- if (url === '/api/get-prompt' && req.method === 'POST') {
1073
+ if (url === '/api/set-agent' && req.method === 'POST') {
1063
1074
  let body = '';
1064
1075
  req.on('data', d => body += d);
1065
1076
  req.on('end', () => {
1066
1077
  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
- }
1078
+ const { agent } = JSON.parse(body);
1079
+ const configPath = path.join(projectRoot, 'viberadar.config.json');
1080
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1081
+ config.agent = agent;
1082
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1083
+ scheduleRescan();
1087
1084
  res.writeHead(200, { 'Content-Type': 'application/json' });
1088
- res.end(JSON.stringify({ prompt }));
1085
+ res.end(JSON.stringify({ ok: true, agent }));
1086
+ }
1087
+ catch (err) {
1088
+ res.writeHead(500);
1089
+ res.end(JSON.stringify({ error: err.message }));
1090
+ }
1091
+ });
1092
+ return;
1093
+ }
1094
+ // ── E2E Plan API ──────────────────────────────────────────────────────────
1095
+ if (url === '/api/e2e/generate-plan' && req.method === 'POST') {
1096
+ let body = '';
1097
+ req.on('data', d => body += d);
1098
+ req.on('end', () => {
1099
+ try {
1100
+ const { featureKey } = JSON.parse(body);
1101
+ broadcast('e2e-plan-generating', { featureKey });
1102
+ runAgent('generate-e2e-plan', featureKey);
1103
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1104
+ res.end(JSON.stringify({ ok: true }));
1089
1105
  }
1090
1106
  catch (err) {
1091
1107
  res.writeHead(400);
@@ -1094,125 +1110,191 @@ function startServer({ data: initialData, port, projectRoot }) {
1094
1110
  });
1095
1111
  return;
1096
1112
  }
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 }));
1113
+ // GET /api/e2e/plan/<featureKey>
1114
+ if (url.startsWith('/api/e2e/plan/') && req.method === 'GET') {
1115
+ const featureKey = decodeURIComponent(url.slice('/api/e2e/plan/'.length));
1116
+ const plan = loadE2ePlan(projectRoot, featureKey);
1117
+ if (plan) {
1118
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1119
+ res.end(JSON.stringify(plan));
1120
+ }
1121
+ else {
1122
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1123
+ res.end(JSON.stringify({ error: 'not found' }));
1124
+ }
1103
1125
  return;
1104
1126
  }
1105
- if (url === '/api/set-agent' && req.method === 'POST') {
1127
+ if (url === '/api/e2e/review' && req.method === 'POST') {
1106
1128
  let body = '';
1107
1129
  req.on('data', d => body += d);
1108
1130
  req.on('end', () => {
1109
1131
  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();
1132
+ const { featureKey, testCaseId, status } = JSON.parse(body);
1133
+ const plan = loadE2ePlan(projectRoot, featureKey);
1134
+ if (!plan) {
1135
+ res.writeHead(404);
1136
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1137
+ return;
1138
+ }
1139
+ const tc = plan.testCases.find(t => t.id === testCaseId);
1140
+ if (!tc) {
1141
+ res.writeHead(404);
1142
+ res.end(JSON.stringify({ error: 'Test case not found' }));
1143
+ return;
1144
+ }
1145
+ tc.status = status;
1146
+ saveE2ePlan(projectRoot, plan);
1116
1147
  res.writeHead(200, { 'Content-Type': 'application/json' });
1117
- res.end(JSON.stringify({ ok: true, agent }));
1148
+ res.end(JSON.stringify({ ok: true, plan }));
1118
1149
  }
1119
1150
  catch (err) {
1120
- res.writeHead(500);
1151
+ res.writeHead(400);
1121
1152
  res.end(JSON.stringify({ error: err.message }));
1122
1153
  }
1123
1154
  });
1124
1155
  return;
1125
1156
  }
1126
- if (url === '/api/set-model' && req.method === 'POST') {
1157
+ if (url === '/api/e2e/review-all' && req.method === 'POST') {
1127
1158
  let body = '';
1128
1159
  req.on('data', d => body += d);
1129
1160
  req.on('end', () => {
1130
1161
  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();
1162
+ const { featureKey, status } = JSON.parse(body);
1163
+ const plan = loadE2ePlan(projectRoot, featureKey);
1164
+ if (!plan) {
1165
+ res.writeHead(404);
1166
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1167
+ return;
1168
+ }
1169
+ for (const tc of plan.testCases) {
1170
+ if (tc.status === 'pending')
1171
+ tc.status = status;
1172
+ }
1173
+ saveE2ePlan(projectRoot, plan);
1137
1174
  res.writeHead(200, { 'Content-Type': 'application/json' });
1138
- res.end(JSON.stringify({ ok: true, model }));
1175
+ res.end(JSON.stringify({ ok: true, plan }));
1139
1176
  }
1140
1177
  catch (err) {
1141
- res.writeHead(500);
1178
+ res.writeHead(400);
1142
1179
  res.end(JSON.stringify({ error: err.message }));
1143
1180
  }
1144
1181
  });
1145
1182
  return;
1146
1183
  }
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 });
1171
- }
1172
- });
1173
- logoutProc.on('close', (logoutCode) => {
1174
- if (logoutCode === 0) {
1175
- broadcast('agent-output', { line: '✅ Вышли из аккаунта' });
1184
+ if (url === '/api/e2e/write-tests' && req.method === 'POST') {
1185
+ let body = '';
1186
+ req.on('data', d => body += d);
1187
+ req.on('end', () => {
1188
+ try {
1189
+ const { featureKey } = JSON.parse(body);
1190
+ broadcast('e2e-tests-writing', { featureKey });
1191
+ runAgent('write-e2e-tests', featureKey);
1192
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1193
+ res.end(JSON.stringify({ ok: true }));
1176
1194
  }
1177
- else {
1178
- broadcast('agent-output', { line: `⚠ logout вернул код ${logoutCode}` });
1179
- if (logoutStderr.trim()) {
1180
- broadcast('agent-output', { line: logoutStderr.trim(), isDim: true });
1181
- }
1195
+ catch (err) {
1196
+ res.writeHead(400);
1197
+ res.end(JSON.stringify({ error: err.message }));
1182
1198
  }
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 });
1199
+ });
1200
+ return;
1201
+ }
1202
+ if (url === '/api/e2e/run-tests' && req.method === 'POST') {
1203
+ let body = '';
1204
+ req.on('data', d => body += d);
1205
+ req.on('end', async () => {
1206
+ try {
1207
+ const { featureKey } = JSON.parse(body);
1208
+ const plan = loadE2ePlan(projectRoot, featureKey);
1209
+ if (!plan) {
1210
+ res.writeHead(404);
1211
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1212
+ return;
1191
1213
  }
1192
- });
1193
- loginProc.stderr?.on('data', (d) => {
1194
- for (const l of d.toString().split('\n').filter(Boolean)) {
1195
- broadcast('agent-output', { line: l });
1214
+ // Collect test files from plan (written/passed/failed)
1215
+ const testFiles = plan.testCases
1216
+ .filter(tc => ['written', 'passed', 'failed'].includes(tc.status) && tc.testFilePath)
1217
+ .map(tc => tc.testFilePath);
1218
+ // Also scan e2e/<featureKey>/ for any test files
1219
+ const e2eDir = path.join(projectRoot, 'e2e', featureKey);
1220
+ if (fs.existsSync(e2eDir)) {
1221
+ for (const f of fs.readdirSync(e2eDir)) {
1222
+ if (/\.(spec|test)\.(ts|js)$/.test(f)) {
1223
+ const relPath = `e2e/${featureKey}/${f}`;
1224
+ if (!testFiles.includes(relPath))
1225
+ testFiles.push(relPath);
1226
+ }
1227
+ }
1196
1228
  }
1197
- });
1198
- loginProc.on('close', (loginCode) => {
1199
- if (loginCode === 0) {
1200
- broadcast('agent-output', { line: ' Авторизация завершена! Можно запускать агента.' });
1229
+ if (testFiles.length === 0) {
1230
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1231
+ res.end(JSON.stringify({ ok: true, count: 0 }));
1232
+ broadcast('agent-output', { line: 'Нет E2E тест-файлов для запуска' });
1233
+ return;
1201
1234
  }
1202
- else {
1203
- broadcast('agent-output', { line: `⚠ Авторизация не завершена (код ${loginCode}).` });
1204
- broadcast('agent-output', { line: ` Попробуй вручную: ${[cliName, ...loginArgs].join(' ')}` });
1235
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1236
+ res.end(JSON.stringify({ ok: true, count: testFiles.length }));
1237
+ broadcast('e2e-tests-running', { featureKey, count: testFiles.length });
1238
+ broadcast('agent-output', { line: `🎭 Запускаю E2E тесты (${testFiles.length} файлов)…` });
1239
+ const result = await runPlaywrightTests(testFiles, projectRoot);
1240
+ // Collect screenshots
1241
+ const screenshots = collectScreenshots(projectRoot, featureKey);
1242
+ // Update plan with results
1243
+ for (const tc of plan.testCases) {
1244
+ if (!['written', 'passed', 'failed'].includes(tc.status))
1245
+ continue;
1246
+ const matching = result.testResults.find(r => r.title.toLowerCase().includes(tc.id.toLowerCase()) ||
1247
+ tc.name.toLowerCase().includes(r.title.toLowerCase()) ||
1248
+ r.title.toLowerCase().includes(tc.name.toLowerCase()));
1249
+ if (matching) {
1250
+ tc.status = matching.status === 'passed' ? 'passed' : 'failed';
1251
+ tc.lastError = matching.error;
1252
+ }
1253
+ tc.screenshotPaths = screenshots.get(tc.id) || [];
1205
1254
  }
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 установлен.` });
1255
+ saveE2ePlan(projectRoot, plan);
1256
+ const summary = result.failed === 0
1257
+ ? `✅ E2E: все ${result.passed} тестов прошли`
1258
+ : `⚠️ E2E: ${result.passed} passed, ${result.failed} failed`;
1259
+ broadcast('agent-output', { line: summary });
1260
+ broadcast('e2e-tests-done', {
1261
+ featureKey,
1262
+ passed: result.passed,
1263
+ failed: result.failed,
1264
+ plan: plan,
1265
+ });
1266
+ }
1267
+ catch (err) {
1268
+ res.writeHead(400);
1269
+ res.end(JSON.stringify({ error: err.message }));
1270
+ }
1213
1271
  });
1214
1272
  return;
1215
1273
  }
1274
+ // Serve E2E screenshots
1275
+ if (url.startsWith('/api/e2e/screenshot/')) {
1276
+ const relPath = decodeURIComponent(url.slice('/api/e2e/screenshot/'.length));
1277
+ const filePath = path.join(projectRoot, '.viberadar', 'e2e-screenshots', relPath);
1278
+ // Security: prevent path traversal
1279
+ const resolved = path.resolve(filePath);
1280
+ const screenshotsRoot = path.resolve(path.join(projectRoot, '.viberadar', 'e2e-screenshots'));
1281
+ if (!resolved.startsWith(screenshotsRoot)) {
1282
+ res.writeHead(403);
1283
+ res.end('Forbidden');
1284
+ return;
1285
+ }
1286
+ if (fs.existsSync(filePath)) {
1287
+ const ext = path.extname(filePath).toLowerCase();
1288
+ const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
1289
+ res.writeHead(200, { 'Content-Type': mime[ext] || 'image/png' });
1290
+ fs.createReadStream(filePath).pipe(res);
1291
+ }
1292
+ else {
1293
+ res.writeHead(404);
1294
+ res.end('Screenshot not found');
1295
+ }
1296
+ return;
1297
+ }
1216
1298
  // Server-Sent Events endpoint
1217
1299
  if (url === '/api/events') {
1218
1300
  res.writeHead(200, {
@@ -1236,11 +1318,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1236
1318
  reject(err);
1237
1319
  }
1238
1320
  });
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
- });
1321
+ server.listen(port, '127.0.0.1', () => resolve({ server, triggerCoverage }));
1244
1322
  process.once('SIGINT', () => {
1245
1323
  console.log('\n👋 VibeRadar stopped.');
1246
1324
  // Destroy all SSE connections so server.close() doesn't hang waiting for them