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.
- package/dist/scanner/index.d.ts +0 -2
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +0 -1
- package/dist/scanner/index.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +564 -486
- package/dist/server/index.js.map +1 -1
- package/dist/ui/dashboard.html +410 -687
- package/package.json +43 -43
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
59
|
+
return `type "${escaped}" | claude.cmd --print --verbose --output-format stream-json`;
|
|
61
60
|
if (agent === 'codex')
|
|
62
|
-
return `type "${escaped}" | codex.cmd
|
|
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
|
|
65
|
+
return `claude --print --verbose --output-format stream-json < "${escaped}"`;
|
|
67
66
|
if (agent === 'codex')
|
|
68
|
-
return `codex
|
|
67
|
+
return `codex < "${escaped}"`;
|
|
69
68
|
}
|
|
70
|
-
return `claude --print --verbose --output-format stream-json
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
191
|
-
|
|
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', (
|
|
221
|
+
proc.on('error', () => resolve({ passed: 0, failed: 0, files, fileDetails: {} }));
|
|
196
222
|
});
|
|
197
223
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
`## Исходные файлы для покрытия`,
|
|
285
|
-
...sourceBlocks,
|
|
294
|
+
`Файлов без тестов (${untestedMods.length}):`,
|
|
295
|
+
...untestedPaths,
|
|
286
296
|
``,
|
|
287
|
-
|
|
288
|
-
...exampleBlocks,
|
|
297
|
+
typeSummary ? `Рекомендации по типам тестов:\n${typeSummary}` : '',
|
|
289
298
|
``,
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
`-
|
|
300
|
-
`- Следуй паттернам примеров выше`,
|
|
309
|
+
`- Следуй паттернам существующих тестов в проекте`,
|
|
310
|
+
`- Для каждого файла создай соответствующий тест-файл`,
|
|
301
311
|
`- Не изменяй существующие тесты`,
|
|
302
|
-
|
|
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
|
|
314
|
+
function buildWriteTestsForFilePrompt(filePath, feat, modules, testRunner) {
|
|
308
315
|
const normalPath = filePath.replace(/\\/g, '/');
|
|
309
|
-
// Find module info to get suggestedTestType
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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}
|
|
337
|
+
`Напиши тест для файла \`${normalPath}\`.`,
|
|
338
|
+
`Фича: "${feat.label}"`,
|
|
343
339
|
``,
|
|
344
340
|
testTypeBlock,
|
|
345
341
|
``,
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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'
|
|
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'
|
|
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
|
|
665
|
-
broadcast('agent-error', { message:
|
|
666
|
-
|
|
667
|
-
|
|
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,
|
|
698
|
+
prompt = buildFixTestsPrompt(filePath, storedErrors);
|
|
699
|
+
const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
|
|
700
|
+
title = `${agentLabel} — исправить тесты в "${fileName}"`;
|
|
671
701
|
}
|
|
672
|
-
else if (task === '
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
763
|
-
if (task === '
|
|
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.
|
|
783
|
-
?
|
|
784
|
-
: result.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
999
|
-
?
|
|
1000
|
-
: result.
|
|
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
|
-
|
|
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/
|
|
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 {
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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({
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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/
|
|
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 {
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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,
|
|
1148
|
+
res.end(JSON.stringify({ ok: true, plan }));
|
|
1118
1149
|
}
|
|
1119
1150
|
catch (err) {
|
|
1120
|
-
res.writeHead(
|
|
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/
|
|
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 {
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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,
|
|
1175
|
+
res.end(JSON.stringify({ ok: true, plan }));
|
|
1139
1176
|
}
|
|
1140
1177
|
catch (err) {
|
|
1141
|
-
res.writeHead(
|
|
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/
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|