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