viberadar 0.3.46 β†’ 0.3.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,21 +52,22 @@ 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, model) {
56
56
  const escaped = taskFile.replace(/\\/g, '\\\\');
57
+ const modelFlag = (agent === 'claude' && model) ? ` --model ${model}` : '';
57
58
  if (WIN) {
58
59
  if (agent === 'claude')
59
- return `type "${escaped}" | claude.cmd --print --verbose --output-format stream-json`;
60
+ return `type "${escaped}" | claude.cmd --print --verbose --output-format stream-json${modelFlag}`;
60
61
  if (agent === 'codex')
61
- return `type "${escaped}" | codex.cmd`;
62
+ return `type "${escaped}" | codex.cmd exec - --color never --sandbox workspace-write`;
62
63
  }
63
64
  else {
64
65
  if (agent === 'claude')
65
- return `claude --print --verbose --output-format stream-json < "${escaped}"`;
66
+ return `claude --print --verbose --output-format stream-json${modelFlag} < "${escaped}"`;
66
67
  if (agent === 'codex')
67
- return `codex < "${escaped}"`;
68
+ return `codex exec - --color never --sandbox workspace-write < "${escaped}"`;
68
69
  }
69
- return `claude --print --verbose --output-format stream-json < "${escaped}"`;
70
+ return `claude --print --verbose --output-format stream-json${modelFlag} < "${escaped}"`;
70
71
  }
71
72
  /** Parse a Claude Code stream-json event into a human-readable line, or null to skip */
72
73
  function parseClaudeEvent(raw) {
@@ -123,6 +124,84 @@ function fmtTool(name, input = {}) {
123
124
  default: return `πŸ”§ ${name}${fp ? ': ' + fp : ''}`;
124
125
  }
125
126
  }
127
+ // ─── Test runner after agent ──────────────────────────────────────────────────
128
+ const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
129
+ function runTestFiles(files, projectRoot) {
130
+ return new Promise((resolve) => {
131
+ // Write JSON to a temp file β€” avoids stdout encoding/regex issues on Windows
132
+ const tmpDir = path.join(projectRoot, '.viberadar');
133
+ const tmpFile = path.join(tmpDir, '_test-results.json');
134
+ try {
135
+ fs.mkdirSync(tmpDir, { recursive: true });
136
+ }
137
+ catch { }
138
+ try {
139
+ fs.unlinkSync(tmpFile);
140
+ }
141
+ catch { }
142
+ const proc = (0, child_process_1.spawn)('npx', ['vitest', 'run', '--reporter=json', `--outputFile=${tmpFile}`, ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
143
+ let stdout = '';
144
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
145
+ proc.on('close', () => {
146
+ try {
147
+ // Prefer the output file (reliable); fall back to stdout
148
+ let jsonStr;
149
+ if (fs.existsSync(tmpFile)) {
150
+ jsonStr = fs.readFileSync(tmpFile, 'utf-8').trim();
151
+ process.stdout.write(` βœ… vitest outputFile: ${tmpFile} (${jsonStr.length} bytes)\n`);
152
+ }
153
+ else {
154
+ process.stdout.write(` ⚠️ vitest outputFile not found, falling back to stdout (${stdout.length} bytes)\n`);
155
+ const match = stdout.match(/\{[\s\S]*"testResults"[\s\S]*\}/);
156
+ jsonStr = match ? match[0] : stdout.trim();
157
+ }
158
+ const json = JSON.parse(jsonStr);
159
+ process.stdout.write(` πŸ” vitest JSON: passed=${json.numPassedTests} failed=${json.numFailedTests} testResults=${(json.testResults ?? []).length}\n`);
160
+ // Extract per-file failure details
161
+ const fileDetails = {};
162
+ for (const tr of (json.testResults ?? [])) {
163
+ const fp = tr.testFilePath ?? '';
164
+ if (!fp)
165
+ continue;
166
+ const assertions = tr.assertionResults ?? tr.testResults ?? [];
167
+ const errors = [];
168
+ for (const ar of assertions) {
169
+ if (ar.status === 'failed') {
170
+ errors.push({
171
+ testName: ar.fullName ?? ar.title ?? 'unknown',
172
+ message: (ar.failureMessages?.[0] ?? ar.errors?.[0]?.message ?? '').split('\n')[0].slice(0, 300),
173
+ });
174
+ }
175
+ }
176
+ fileDetails[fp] = {
177
+ passed: assertions.filter((a) => a.status === 'passed').length,
178
+ failed: errors.length,
179
+ errors,
180
+ };
181
+ }
182
+ const failedFiles = Object.entries(fileDetails).filter(([, d]) => d.failed > 0);
183
+ process.stdout.write(` πŸ” fileDetails: ${Object.keys(fileDetails).length} files, ${failedFiles.length} with failures\n`);
184
+ if (failedFiles.length > 0) {
185
+ for (const [fp, d] of failedFiles) {
186
+ process.stdout.write(` ❌ ${fp} β†’ ${d.failed} failed\n`);
187
+ }
188
+ }
189
+ resolve({
190
+ passed: json.numPassedTests ?? 0,
191
+ failed: json.numFailedTests ?? 0,
192
+ files,
193
+ fileDetails,
194
+ });
195
+ }
196
+ catch (err) {
197
+ process.stdout.write(` ❌ runTestFiles parse error: ${err.message}\n`);
198
+ resolve({ passed: 0, failed: files.length, files, fileDetails: {}, runError: err.message });
199
+ }
200
+ });
201
+ proc.on('error', (err) => resolve({ passed: 0, failed: files.length, files, fileDetails: {}, runError: err.message }));
202
+ });
203
+ }
204
+ // ─── E2E plan storage ─────────────────────────────────────────────────────────
126
205
  function e2ePlanDir(projectRoot) {
127
206
  return path.join(projectRoot, '.viberadar', 'e2e-plans');
128
207
  }
@@ -130,9 +209,11 @@ function e2ePlanPath(projectRoot, featureKey) {
130
209
  return path.join(e2ePlanDir(projectRoot), `${featureKey}.json`);
131
210
  }
132
211
  function loadE2ePlan(projectRoot, featureKey) {
133
- const fp = e2ePlanPath(projectRoot, featureKey);
134
212
  try {
135
- return JSON.parse(fs.readFileSync(fp, 'utf-8'));
213
+ const p = e2ePlanPath(projectRoot, featureKey);
214
+ if (!fs.existsSync(p))
215
+ return null;
216
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
136
217
  }
137
218
  catch {
138
219
  return null;
@@ -147,24 +228,19 @@ function saveE2ePlan(projectRoot, plan) {
147
228
  function e2eScreenshotDir(projectRoot, featureKey) {
148
229
  return path.join(projectRoot, '.viberadar', 'e2e-screenshots', featureKey);
149
230
  }
150
- function collectScreenshots(projectRoot, featureKey) {
151
- const baseDir = e2eScreenshotDir(projectRoot, featureKey);
152
- const result = new Map();
153
- if (!fs.existsSync(baseDir))
154
- return result;
231
+ function collectScreenshots(projectRoot, featureKey, testCaseId) {
232
+ const dir = path.join(e2eScreenshotDir(projectRoot, featureKey), testCaseId);
155
233
  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
- }
234
+ if (!fs.existsSync(dir))
235
+ return [];
236
+ return fs.readdirSync(dir)
237
+ .filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f))
238
+ .sort()
239
+ .map(f => `${featureKey}/${testCaseId}/${f}`);
240
+ }
241
+ catch {
242
+ return [];
165
243
  }
166
- catch { }
167
- return result;
168
244
  }
169
245
  function hasPlaywright(projectRoot) {
170
246
  try {
@@ -176,96 +252,47 @@ function hasPlaywright(projectRoot) {
176
252
  return false;
177
253
  }
178
254
  }
179
- // ─── Test runner after agent ──────────────────────────────────────────────────
180
- const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
181
- function runTestFiles(files, projectRoot) {
182
- return new Promise((resolve) => {
183
- const proc = (0, child_process_1.spawn)('npx', ['vitest', 'run', '--reporter=json', ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
184
- let stdout = '';
185
- proc.stdout?.on('data', (d) => stdout += d.toString());
186
- proc.on('close', () => {
187
- try {
188
- // vitest --reporter=json wraps output; find the JSON object
189
- const match = stdout.match(/\{[\s\S]*"testResults"[\s\S]*\}/);
190
- const json = JSON.parse(match ? match[0] : stdout);
191
- // Extract per-file failure details
192
- const fileDetails = {};
193
- for (const tr of (json.testResults ?? [])) {
194
- const fp = tr.testFilePath ?? '';
195
- const errors = [];
196
- for (const ar of (tr.assertionResults ?? [])) {
197
- if (ar.status === 'failed') {
198
- errors.push({
199
- testName: ar.fullName ?? ar.title ?? 'unknown',
200
- message: (ar.failureMessages?.[0] ?? '').split('\n')[0].slice(0, 300),
201
- });
202
- }
203
- }
204
- fileDetails[fp] = {
205
- passed: (tr.assertionResults ?? []).filter((a) => a.status === 'passed').length,
206
- failed: errors.length,
207
- errors,
208
- };
209
- }
210
- resolve({
211
- passed: json.numPassedTests ?? 0,
212
- failed: json.numFailedTests ?? 0,
213
- files,
214
- fileDetails,
215
- });
216
- }
217
- catch {
218
- resolve({ passed: 0, failed: 0, files, fileDetails: {} });
219
- }
220
- });
221
- proc.on('error', () => resolve({ passed: 0, failed: 0, files, fileDetails: {} }));
222
- });
223
- }
255
+ // ─── Playwright runner ────────────────────────────────────────────────────────
224
256
  function runPlaywrightTests(files, projectRoot) {
225
257
  return new Promise((resolve) => {
226
- const proc = (0, child_process_1.spawn)('npx', ['playwright', 'test', ...files, '--reporter=json'], { cwd: projectRoot, shell: true, stdio: 'pipe' });
258
+ const proc = (0, child_process_1.spawn)('npx', ['playwright', 'test', '--reporter=json', ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
227
259
  let stdout = '';
228
- proc.stdout?.on('data', (d) => stdout += d.toString());
260
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
229
261
  proc.on('close', () => {
230
262
  try {
231
263
  const match = stdout.match(/\{[\s\S]*"suites"[\s\S]*\}/);
232
264
  const json = JSON.parse(match ? match[0] : stdout);
233
- const testResults = [];
265
+ const results = {};
266
+ const errors = {};
234
267
  let passed = 0, failed = 0;
235
268
  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
- }
269
+ for (const suite of suites ?? []) {
270
+ for (const spec of suite.specs ?? []) {
271
+ const ok = spec.tests?.every((t) => t.results?.every((r) => r.status === 'passed'));
272
+ const title = spec.title ?? suite.title ?? 'unknown';
273
+ if (ok) {
274
+ results[title] = 'passed';
275
+ passed++;
276
+ }
277
+ else {
278
+ results[title] = 'failed';
279
+ failed++;
280
+ const errMsg = spec.tests?.[0]?.results?.[0]?.error?.message ?? 'Test failed';
281
+ errors[title] = errMsg.split('\n')[0].slice(0, 300);
255
282
  }
256
283
  }
257
284
  if (suite.suites)
258
285
  walkSuites(suite.suites);
259
286
  }
260
287
  };
261
- walkSuites(json.suites || []);
262
- resolve({ passed, failed, testResults });
288
+ walkSuites(json.suites ?? []);
289
+ resolve({ passed, failed, results, errors });
263
290
  }
264
291
  catch {
265
- resolve({ passed: 0, failed: 0, testResults: [] });
292
+ resolve({ passed: 0, failed: 0, results: {}, errors: {} });
266
293
  }
267
294
  });
268
- proc.on('error', () => resolve({ passed: 0, failed: 0, testResults: [] }));
295
+ proc.on('error', () => resolve({ passed: 0, failed: 0, results: {}, errors: {} }));
269
296
  });
270
297
  }
271
298
  // ─── Prompt builders ──────────────────────────────────────────────────────────
@@ -371,6 +398,28 @@ function buildFixTestsPrompt(filePath, errors) {
371
398
  `- ПослС исправлСния запусти тСст, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ±Π΅Π΄ΠΈΡ‚ΡŒΡΡ Ρ‡Ρ‚ΠΎ ΠΎΠ½ ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚`,
372
399
  ].join('\n');
373
400
  }
401
+ function buildFixAllTestsPrompt(failedFiles, testType) {
402
+ const totalErrors = failedFiles.reduce((sum, f) => sum + f.errors.length, 0);
403
+ const fileBlocks = failedFiles.map(f => {
404
+ const normalPath = f.filePath.replace(/\\/g, '/');
405
+ return [
406
+ `### \`${normalPath}\` (${f.errors.length} ΡƒΠΏΠ°Π»ΠΎ):`,
407
+ ...f.errors.map(e => `β€’ "${e.testName}"\n ${e.message}`),
408
+ ].join('\n');
409
+ });
410
+ return [
411
+ `Π˜ΡΠΏΡ€Π°Π²ΡŒ всС ΠΏΠ°Π΄Π°ΡŽΡ‰ΠΈΠ΅ ${testType} тСсты (${totalErrors} тСстов Π² ${failedFiles.length} Ρ„Π°ΠΉΠ»Π°Ρ…).`,
412
+ ``,
413
+ ...fileBlocks,
414
+ ``,
415
+ `ВрСбования:`,
416
+ `- Π˜ΡΠΏΡ€Π°Π²ΡŒ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΠ°Π΄Π°ΡŽΡ‰ΠΈΠ΅ тСсты, Π½Π΅ Ρ‚Ρ€ΠΎΠ³Π°ΠΉ проходящиС`,
417
+ `- НС удаляй тСсты β€” ΠΈΡΠΏΡ€Π°Π²ΡŒ Π»ΠΎΠ³ΠΈΠΊΡƒ ΠΈΠ»ΠΈ ΠΌΠΎΠΊΠΈ`,
418
+ `- Если тСст провСряСт Π½Π΅ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰Π΅Π΅ ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅ β€” Π°Π΄Π°ΠΏΡ‚ΠΈΡ€ΡƒΠΉ ΠΏΠΎΠ΄ Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠ΅ ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅ ΠΊΠΎΠ΄Π°`,
419
+ `- Если ошибка Π² исходном ΠΊΠΎΠ΄Π΅, Π° Π½Π΅ Π² тСстС β€” ΠΈΡΠΏΡ€Π°Π²ΡŒ ΠΊΠΎΠ΄`,
420
+ `- ПослС исправлСния ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ Ρ„Π°ΠΉΠ»Π° запусти Π΅Π³ΠΎ тСсты, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ±Π΅Π΄ΠΈΡ‚ΡŒΡΡ Ρ‡Ρ‚ΠΎ проходят`,
421
+ ].join('\n');
422
+ }
374
423
  function buildMapUnmappedPrompt(modules, features) {
375
424
  const unmapped = modules
376
425
  .filter(m => m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0))
@@ -392,92 +441,64 @@ function buildMapUnmappedPrompt(modules, features) {
392
441
  ...unmapped,
393
442
  ].join('\n');
394
443
  }
395
- // ─── E2E prompt builders ────────────────────────────────────────────────────
396
- function buildE2ePlanPrompt(feat, modules, projectRoot) {
397
- const srcFiles = modules
444
+ function buildE2ePlanPrompt(feat, modules) {
445
+ const files = modules
398
446
  .filter(m => m.featureKeys.includes(feat.key) && m.type !== 'test' && !m.isInfra)
399
447
  .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
448
  return [
407
- `Π‘ΠΎΠ·Π΄Π°ΠΉ ΠΏΠ»Π°Π½ E2E-тСстов для Ρ„ΠΈΡ‡ΠΈ "${feat.label}".`,
408
- ``,
409
- `ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°ΠΉ исходный ΠΊΠΎΠ΄ Ρ„ΠΈΡ‡ΠΈ Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠΎΠ½ΡΡ‚ΡŒ UI: Ρ„ΠΎΡ€ΠΌΡ‹, ΠΊΠ½ΠΎΠΏΠΊΠΈ, Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΡŽ, состояния, API-Π²Ρ‹Π·ΠΎΠ²Ρ‹.`,
410
- ``,
411
- `Π˜ΡΡ…ΠΎΠ΄Π½Ρ‹Π΅ Ρ„Π°ΠΉΠ»Ρ‹ Ρ„ΠΈΡ‡ΠΈ (${srcFiles.length}):`,
412
- ...srcFiles,
449
+ `Π’Ρ‹ β€” QA-ΠΈΠ½ΠΆΠ΅Π½Π΅Ρ€. ΠŸΡ€ΠΎΠ°Π½Π°Π»ΠΈΠ·ΠΈΡ€ΡƒΠΉ исходный ΠΊΠΎΠ΄ Ρ„ΠΈΡ‡ΠΈ "${feat.label}" ΠΈ ΡΠΎΡΡ‚Π°Π²ΡŒ ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Ρ‹ΠΉ ΠΏΠ»Π°Π½ E2E-тСстирования.`,
413
450
  ``,
414
- pageFiles.length > 0 ? `Π‘Ρ‚Ρ€Π°Π½ΠΈΡ†Ρ‹/Ρ€ΠΎΡƒΡ‚Ρ‹:\n${pageFiles.join('\n')}` : '',
451
+ `Π€Π°ΠΉΠ»Ρ‹ Ρ„ΠΈΡ‡ΠΈ:`,
452
+ ...files,
415
453
  ``,
416
- `Π’Π°ΠΆΠ½ΠΎ:`,
417
- `- ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ тСст-кСйс Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡΡ‚ΡŒ ΠΎΠ΄ΠΈΠ½ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠΉ сцСнарий`,
418
- `- Π’ΠΊΠ»ΡŽΡ‡ΠΈ happy path, edge cases, ошибки Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠΈ`,
419
- `- Steps Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹ΠΌΠΈ: ΡƒΠΊΠ°ΠΆΠΈ сСлСкторы, URL, тСкст ΠΊΠ½ΠΎΠΏΠΎΠΊ ΠΈΠ· Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΊΠΎΠ΄Π°`,
420
- `- id тСст-кСйса: ${feat.key}-<scenario>-NN (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€: ${feat.key}-login-01)`,
454
+ `ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°ΠΉ эти Ρ„Π°ΠΉΠ»Ρ‹, ΠΈΠ·ΡƒΡ‡ΠΈ UI: Ρ„ΠΎΡ€ΠΌΡ‹, ΠΊΠ½ΠΎΠΏΠΊΠΈ, Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΡŽ, бизнСс-Π»ΠΎΠ³ΠΈΠΊΡƒ.`,
421
455
  ``,
422
- `ΠžΡ‚Π²Π΅Ρ‚ Π²Π΅Ρ€Π½ΠΈ Π‘Π’Π ΠžΠ“Πž Π² Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π΅ JSON (Π±Π΅Π· markdown-Π±Π»ΠΎΠΊΠΎΠ², Π±Π΅Π· ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅Π², чистый JSON):`,
456
+ `Π’Π΅Ρ€Π½ΠΈ Π’ΠžΠ›Π¬ΠšΠž Π²Π°Π»ΠΈΠ΄Π½Ρ‹ΠΉ JSON Π±Π΅Π· markdown-Π±Π»ΠΎΠΊΠΎΠ² Π² Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π΅:`,
423
457
  `{`,
424
458
  ` "baseUrl": "http://localhost:3000",`,
425
459
  ` "testCases": [`,
426
460
  ` {`,
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
- ` ]`,
461
+ ` "id": "feature-key-01",`,
462
+ ` "name": "ΠšΡ€Π°Ρ‚ΠΊΠΎΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ тСста",`,
463
+ ` "description": "Π§Ρ‚ΠΎ провСряСт тСст",`,
464
+ ` "steps": ["Π¨Π°Π³ 1", "Π¨Π°Π³ 2", "Π¨Π°Π³ 3"],`,
465
+ ` "expectedResults": ["ΠžΠΆΠΈΠ΄Π°Π΅ΠΌΡ‹ΠΉ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ 1", "ΠžΠΆΠΈΠ΄Π°Π΅ΠΌΡ‹ΠΉ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ 2"]`,
439
466
  ` }`,
440
467
  ` ]`,
441
468
  `}`,
442
469
  ``,
443
- `Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠΉ 5–15 тСст-кСйсов, ΠΏΠΎΠΊΡ€Ρ‹Π²Π°ΡŽΡ‰ΠΈΡ… всС основныС сцСнарии Ρ„ΠΈΡ‡ΠΈ.`,
444
- !pwConfigExists ? `\n⚠️ playwright.config.ts Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ β€” Π΅Π³ΠΎ Π½ΡƒΠΆΠ½ΠΎ Π±ΡƒΠ΄Π΅Ρ‚ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΏΠ΅Ρ€Π΅Π΄ написаниСм тСстов.` : '',
445
- ].filter(Boolean).join('\n');
470
+ `ВрСбования:`,
471
+ `- 5-15 тСст-кСйсов, ΠΏΠΎΠΊΡ€Ρ‹Π²Π°ΡŽΡ‰ΠΈΡ… основныС сцСнарии`,
472
+ `- ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ кСйс: happy path, edge cases, ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ошибок`,
473
+ `- id: строчныС Π±ΡƒΠΊΠ²Ρ‹ ΠΈ Ρ†ΠΈΡ„Ρ€Ρ‹ Ρ‡Π΅Ρ€Π΅Π· дСфис, ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ`,
474
+ `- Волько JSON, бСз объяснСний`,
475
+ ].join('\n');
446
476
  }
447
477
  function buildWriteE2eTestPrompt(feat, plan, modules) {
448
478
  const approvedCases = plan.testCases.filter(tc => tc.status === 'approved');
449
479
  const existingE2e = modules
450
- .filter(m => m.testType === 'e2e' && m.featureKeys.includes(feat.key))
480
+ .filter(m => m.featureKeys.includes(feat.key) && m.type === 'e2e')
451
481
  .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
452
482
  return [
453
- `Напиши Playwright E2E-тСсты для Ρ„ΠΈΡ‡ΠΈ "${feat.label}".`,
454
- `Base URL: ${plan.baseUrl || 'http://localhost:3000'}`,
483
+ `Напиши Playwright E2E тСсты для Ρ„ΠΈΡ‡ΠΈ "${feat.label}".`,
455
484
  ``,
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
- ``,
485
+ `Approved тСст-кСйсы (${approvedCases.length}):`,
486
+ ...approvedCases.map(tc => [
487
+ `### ${tc.name} (id: ${tc.id})`,
488
+ `${tc.description}`,
489
+ `Π¨Π°Π³ΠΈ: ${tc.steps.join(' β†’ ')}`,
490
+ `ΠžΠΆΠΈΠ΄Π°Π΅Ρ‚ΡΡ: ${tc.expectedResults.join('; ')}`,
466
491
  ].join('\n')),
467
492
  ``,
468
- existingE2e.length > 0
469
- ? `Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ E2E тСсты (слСдуй этим ΠΏΠ°Ρ‚Ρ‚Π΅Ρ€Π½Π°ΠΌ):\n${existingE2e.join('\n')}`
470
- : 'Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΡ… E2E тСстов ΠΏΠΎΠΊΠ° Π½Π΅Ρ‚.',
493
+ existingE2e.length > 0 ? `Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ E2E тСсты (слСдуй ΠΏΠ°Ρ‚Ρ‚Π΅Ρ€Π½Π°ΠΌ):\n${existingE2e.join('\n')}` : '',
471
494
  ``,
472
495
  `ВрСбования:`,
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
- `- НС измСняй ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ тСсты`,
496
+ `- Π‘ΠΎΠ·Π΄Π°ΠΉ Ρ„Π°ΠΉΠ»Ρ‹ Π² Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ e2e/${feat.key}/`,
497
+ `- Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉ @playwright/test`,
498
+ `- baseUrl: ${plan.baseUrl || 'http://localhost:3000'}`,
499
+ `- ПослС ΠΊΠ»ΡŽΡ‡Π΅Π²Ρ‹Ρ… шагов добавь ΡΠΊΡ€ΠΈΠ½ΡˆΠΎΡ‚Ρ‹: await page.screenshot({ path: '.viberadar/e2e-screenshots/${feat.key}/<testCaseId>/step-N.png' })`,
500
+ `- Π“Ρ€ΡƒΠΏΠΏΠΈΡ€ΡƒΠΉ ΠΏΠΎ test case id (ΠΎΠ΄ΠΈΠ½ Ρ„Π°ΠΉΠ» Π½Π° кСйс ΠΈΠ»ΠΈ нСсколько кСйсов Π² ΠΎΠ΄Π½ΠΎΠΌ Ρ„Π°ΠΉΠ»Π΅)`,
501
+ `- Π‘Π»Π΅Π΄ΡƒΠΉ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΌ ΠΏΠ°Ρ‚Ρ‚Π΅Ρ€Π½Π°ΠΌ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°`,
481
502
  ].filter(Boolean).join('\n');
482
503
  }
483
504
  // ─── Coverage provider auto-install ──────────────────────────────────────────
@@ -546,6 +567,7 @@ function startServer({ data: initialData, port, projectRoot }) {
546
567
  let coverageError = false;
547
568
  let agentRunning = false;
548
569
  let testsRunning = false;
570
+ const agentQueue = [];
549
571
  // Keyed by absolute file path β†’ per-file failure details from last test run
550
572
  const lastTestResults = new Map();
551
573
  // ── SSE clients ────────────────────────────────────────────────────────────
@@ -644,95 +666,88 @@ function startServer({ data: initialData, port, projectRoot }) {
644
666
  });
645
667
  }
646
668
  // ── Agent runner ───────────────────────────────────────────────────────────
647
- function runAgent(task, featureKey, filePath) {
648
- if (agentRunning) {
649
- process.stdout.write(' ⏳ Agent already running\n');
650
- return;
669
+ /** Execute next queued item, or broadcast agent-done if queue is empty */
670
+ function processNextInQueue() {
671
+ if (agentQueue.length > 0) {
672
+ const next = agentQueue.shift();
673
+ process.stdout.write(` πŸ“‹ Starting next from queue: "${next.title}" (remaining: ${agentQueue.length})\n`);
674
+ broadcast('agent-output', { line: `πŸ“‹ Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π°Ρ Π·Π°Π΄Π°Ρ‡Π° ΠΈΠ· ΠΎΡ‡Π΅Ρ€Π΅Π΄ΠΈ: ${next.title}` });
675
+ broadcast('agent-output', { line: ` Π’ ΠΎΡ‡Π΅Ρ€Π΅Π΄ΠΈ ΠΎΡΡ‚Π°Π»ΠΎΡΡŒ: ${agentQueue.length}` });
676
+ executeAgentItem(next);
651
677
  }
652
- const agent = currentData.agent;
653
- if (!agent) {
654
- broadcast('agent-error', { message: 'АгСнт Π½Π΅ Π²Ρ‹Π±Ρ€Π°Π½. Π£ΠΊΠ°ΠΆΠΈ agent Π² viberadar.config.json' });
655
- return;
678
+ else {
679
+ broadcast('agent-done', { queueLength: 0 });
656
680
  }
657
- // Build prompt
681
+ }
682
+ /** Actually spawn the agent process for a queue item */
683
+ function executeAgentItem(item) {
684
+ const { task, featureKey, filePath, title, agent, savedErrors, savedFailedFiles, savedTestType } = item;
685
+ // Build prompt lazily at execution time
658
686
  let prompt;
659
- let title;
660
- const agentLabel = agent === 'claude' ? 'Claude Code' : 'Codex';
661
687
  if (task === 'write-tests') {
662
688
  const feat = currentData.features?.find(f => f.key === featureKey);
663
689
  if (!feat) {
664
690
  broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°: ${featureKey}` });
691
+ agentRunning = false;
692
+ processNextInQueue();
665
693
  return;
666
694
  }
667
695
  prompt = buildWriteTestsPrompt(feat, currentData.modules, currentData.testRunner || 'vitest');
668
- title = `${agentLabel} β€” тСсты для "${feat.label}"`;
669
696
  }
670
697
  else if (task === 'write-tests-file') {
671
698
  const feat = currentData.features?.find(f => f.key === featureKey);
672
699
  if (!feat || !filePath) {
673
- broadcast('agent-error', { message: `НС ΡƒΠΊΠ°Π·Π°Π½Π° Ρ„ΠΈΡ‡Π° ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ»` });
700
+ broadcast('agent-error', { message: 'НС ΡƒΠΊΠ°Π·Π°Π½Π° Ρ„ΠΈΡ‡Π° ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ»' });
701
+ agentRunning = false;
702
+ processNextInQueue();
674
703
  return;
675
704
  }
676
705
  prompt = buildWriteTestsForFilePrompt(filePath, feat, currentData.modules, currentData.testRunner || 'vitest');
677
- const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
678
- title = `${agentLabel} β€” тСст для "${fileName}"`;
679
706
  }
680
707
  else if (task === 'fix-tests') {
681
- if (!filePath) {
682
- broadcast('agent-error', { message: 'НС ΡƒΠΊΠ°Π·Π°Π½ Ρ„Π°ΠΉΠ» для исправлСния' });
708
+ if (!filePath || !savedErrors || savedErrors.length === 0) {
709
+ broadcast('agent-error', { message: `НСт сохранённых ошибок для ${filePath}` });
710
+ agentRunning = false;
711
+ processNextInQueue();
683
712
  return;
684
713
  }
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}. Π‘Π½Π°Ρ‡Π°Π»Π° запусти тСсты.` });
714
+ prompt = buildFixTestsPrompt(filePath, savedErrors);
715
+ }
716
+ else if (task === 'fix-tests-all') {
717
+ if (!savedFailedFiles || savedFailedFiles.length === 0) {
718
+ broadcast('agent-error', { message: 'НСт ΡƒΠΏΠ°Π²ΡˆΠΈΡ… тСстов для исправлСния' });
719
+ agentRunning = false;
720
+ processNextInQueue();
696
721
  return;
697
722
  }
698
- prompt = buildFixTestsPrompt(filePath, storedErrors);
699
- const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
700
- title = `${agentLabel} β€” ΠΈΡΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ тСсты Π² "${fileName}"`;
723
+ prompt = buildFixAllTestsPrompt(savedFailedFiles, savedTestType || 'unit');
701
724
  }
702
725
  else if (task === 'generate-e2e-plan') {
703
726
  const feat = currentData.features?.find(f => f.key === featureKey);
704
727
  if (!feat) {
705
728
  broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°: ${featureKey}` });
729
+ agentRunning = false;
730
+ processNextInQueue();
706
731
  return;
707
732
  }
708
- prompt = buildE2ePlanPrompt(feat, currentData.modules, projectRoot);
709
- title = `${agentLabel} β€” E2E ΠΏΠ»Π°Π½ для "${feat.label}"`;
733
+ prompt = buildE2ePlanPrompt(feat, currentData.modules);
710
734
  }
711
735
  else if (task === 'write-e2e-tests') {
712
736
  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}"` });
737
+ const plan = featureKey ? loadE2ePlan(projectRoot, featureKey) : null;
738
+ if (!feat || !plan) {
739
+ broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° ΠΈΠ»ΠΈ ΠΏΠ»Π°Π½ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Ρ‹: ${featureKey}` });
740
+ agentRunning = false;
741
+ processNextInQueue();
725
742
  return;
726
743
  }
727
744
  prompt = buildWriteE2eTestPrompt(feat, plan, currentData.modules);
728
- title = `${agentLabel} β€” E2E тСсты для "${feat.label}" (${approved.length} кСйсов)`;
729
745
  }
730
746
  else {
731
747
  prompt = buildMapUnmappedPrompt(currentData.modules, currentData.features || []);
732
- title = `${agentLabel} β€” Ρ€Π°Π·ΠΎΠ±Ρ€Π°Ρ‚ΡŒ unmapped`;
733
748
  }
734
749
  agentRunning = true;
735
- broadcast('agent-started', { title, task, featureKey });
750
+ broadcast('agent-started', { title, task, featureKey, filePath: filePath || null, queueLength: agentQueue.length });
736
751
  process.stdout.write(` πŸ€– Running agent (${agent}): ${task}\n`);
737
752
  // Write prompt to .viberadar/task.md for reference
738
753
  const taskDir = path.join(projectRoot, '.viberadar');
@@ -743,7 +758,7 @@ function startServer({ data: initialData, port, projectRoot }) {
743
758
  }
744
759
  catch { }
745
760
  // Spawn via shell, piping prompt from file (avoids TUI mode, supports stream-json)
746
- const shellCmd = buildAgentShellCmd(agent, taskFile);
761
+ const shellCmd = buildAgentShellCmd(agent, taskFile, currentData.model);
747
762
  process.stdout.write(` πŸš€ Shell cmd: ${shellCmd}\n`);
748
763
  const proc = (0, child_process_1.spawn)(shellCmd, [], {
749
764
  cwd: projectRoot,
@@ -754,7 +769,7 @@ function startServer({ data: initialData, port, projectRoot }) {
754
769
  broadcast('agent-output', { line: `πŸ“„ Π—Π°Π΄Π°Ρ‡Π° записана Π² .viberadar/task.md` });
755
770
  // Track test files written/edited by agent (for auto-run after)
756
771
  const createdTestFiles = [];
757
- // Accumulate full result text from agent (for JSON plan parsing)
772
+ // Accumulate full result text for E2E plan parsing
758
773
  let agentResultText = '';
759
774
  function trackWrittenFiles(raw) {
760
775
  try {
@@ -780,17 +795,14 @@ function startServer({ data: initialData, port, projectRoot }) {
780
795
  return;
781
796
  trackWrittenFiles(raw);
782
797
  const parsed = agent === 'claude' ? parseClaudeEvent(raw) : raw;
783
- // If parsing returned null (noise), still show raw line dimmed so user knows output is coming
784
798
  if (!parsed) {
785
799
  broadcast('agent-output', { line: raw.slice(0, 120), isDim: true });
786
800
  return;
787
801
  }
788
802
  if (parsed.startsWith('Β§RESULTΒ§')) {
789
- const resultBody = parsed.slice('Β§RESULTΒ§'.length);
790
- agentResultText += resultBody + '\n';
791
- // Full result summary β€” split into lines and prefix with indent
803
+ agentResultText = parsed.slice('Β§RESULTΒ§'.length).trim();
792
804
  broadcast('agent-output', { line: '─────────────────────────────' });
793
- for (const l of resultBody.split('\n')) {
805
+ for (const l of agentResultText.split('\n')) {
794
806
  if (l.trim())
795
807
  broadcast('agent-output', { line: ' ' + l });
796
808
  }
@@ -813,21 +825,32 @@ function startServer({ data: initialData, port, projectRoot }) {
813
825
  agentRunning = false;
814
826
  if (code === 0) {
815
827
  // Auto-run created/fixed test files and show results
816
- const testFilesToRun = task === 'fix-tests' && filePath ? [filePath] : createdTestFiles;
817
- if ((task === 'write-tests' || task === 'write-tests-file' || task === 'fix-tests') && testFilesToRun.length > 0) {
828
+ let testFilesToRun;
829
+ if (task === 'fix-tests-all') {
830
+ testFilesToRun = [...lastTestResults.keys()];
831
+ }
832
+ else if (task === 'fix-tests' && filePath) {
833
+ testFilesToRun = [filePath];
834
+ }
835
+ else {
836
+ testFilesToRun = createdTestFiles;
837
+ }
838
+ if ((task === 'write-tests' || task === 'write-tests-file' || task === 'fix-tests' || task === 'fix-tests-all') && testFilesToRun.length > 0) {
818
839
  broadcast('agent-output', { line: '─────────────────────────────' });
819
840
  broadcast('agent-output', { line: `πŸ§ͺ Π—Π°ΠΏΡƒΡΠΊΠ°ΡŽ тСсты (${testFilesToRun.length} Ρ„Π°ΠΉΠ»ΠΎΠ²)...` });
820
841
  const result = await runTestFiles(testFilesToRun, projectRoot);
821
- // Store per-file results for "fix-tests" feature
822
- // path.resolve() normalizes slashes on Windows (vitest outputs forward slashes)
823
842
  lastTestResults.clear();
824
843
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
825
844
  if (detail.failed > 0)
826
845
  lastTestResults.set(path.resolve(fp), { failed: detail.failed, errors: detail.errors });
827
846
  }
828
- const summary = result.failed === 0
829
- ? `βœ… ВсС тСсты ΠΏΡ€ΠΎΡˆΠ»ΠΈ: ${result.passed} passed`
830
- : `⚠️ ${result.passed} passed, ${result.failed} failed`;
847
+ const summary = result.runError
848
+ ? `❌ ВСсты Π½Π΅ Π·Π°ΠΏΡƒΡΡ‚ΠΈΠ»ΠΈΡΡŒ: ${result.runError}`
849
+ : result.failed === 0 && result.passed > 0
850
+ ? `βœ… ВсС тСсты ΠΏΡ€ΠΎΡˆΠ»ΠΈ: ${result.passed} passed`
851
+ : result.failed === 0 && result.passed === 0
852
+ ? `⚠️ 0 тСстов Π·Π°ΠΏΡƒΡΡ‚ΠΈΠ»ΠΎΡΡŒ β€” ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒ Ρ„Π°ΠΉΠ» Π½Π° ошибки ΠΈΠΌΠΏΠΎΡ€Ρ‚Π°`
853
+ : `⚠️ ${result.passed} passed, ${result.failed} failed`;
831
854
  broadcast('agent-output', { line: summary });
832
855
  if (result.failed > 0) {
833
856
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
@@ -843,61 +866,36 @@ function startServer({ data: initialData, port, projectRoot }) {
843
866
  }
844
867
  broadcast('agent-summary', result);
845
868
  }
846
- // ── E2E plan post-processing ─────────────────────────────────────
869
+ // E2E plan post-processing
847
870
  if (task === 'generate-e2e-plan' && featureKey) {
848
- const feat = currentData.features?.find(f => f.key === featureKey);
849
871
  try {
850
- // Extract JSON from agent result text
851
872
  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
- }
873
+ const parsedPlan = JSON.parse(jsonMatch ? jsonMatch[0] : agentResultText);
874
+ const feat = currentData.features?.find(f => f.key === featureKey);
875
+ const plan = {
876
+ featureKey,
877
+ featureLabel: feat?.label || featureKey,
878
+ generatedAt: new Date().toISOString(),
879
+ updatedAt: new Date().toISOString(),
880
+ baseUrl: parsedPlan.baseUrl,
881
+ testCases: (parsedPlan.testCases || []).map((tc) => ({ ...tc, status: 'pending' })),
882
+ };
883
+ saveE2ePlan(projectRoot, plan);
884
+ broadcast('e2e-plan-ready', { featureKey, plan });
877
885
  }
878
886
  catch (err) {
879
- broadcast('e2e-plan-error', { featureKey, message: err.message });
880
- broadcast('agent-output', { line: `❌ Ошибка парсинга ΠΏΠ»Π°Π½Π°: ${err.message}`, isError: true });
887
+ broadcast('e2e-plan-error', { featureKey, message: `НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Ρ€Π°ΡΠΏΠ°Ρ€ΡΠΈΡ‚ΡŒ ΠΏΠ»Π°Π½: ${err.message}` });
881
888
  }
882
889
  }
883
890
  if (task === 'write-e2e-tests' && featureKey) {
884
- // Update plan: mark approved test cases as 'written' and set file paths
885
891
  const plan = loadE2ePlan(projectRoot, featureKey);
886
892
  if (plan) {
887
893
  for (const tc of plan.testCases) {
888
- if (tc.status === 'approved') {
894
+ if (tc.status === 'approved')
889
895
  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
896
  }
898
897
  saveE2ePlan(projectRoot, plan);
899
- broadcast('e2e-plan-ready', { featureKey, plan: plan });
900
- broadcast('agent-output', { line: `βœ… E2E ΠΏΠ»Π°Π½ ΠΎΠ±Π½ΠΎΠ²Π»Ρ‘Π½: тСсты ΠΎΡ‚ΠΌΠ΅Ρ‡Π΅Π½Ρ‹ ΠΊΠ°ΠΊ написанныС` });
898
+ broadcast('e2e-tests-written', { featureKey, files: createdTestFiles });
901
899
  }
902
900
  }
903
901
  process.stdout.write(' βœ… Agent done, rescanning...\n');
@@ -906,19 +904,128 @@ function startServer({ data: initialData, port, projectRoot }) {
906
904
  broadcast('data-updated');
907
905
  }
908
906
  catch { }
909
- broadcast('agent-done');
907
+ processNextInQueue();
908
+ }
909
+ else if (code === 255) {
910
+ process.stdout.write(` ❌ Agent auth error (exit code 255)\n`);
911
+ broadcast('agent-error', {
912
+ message: `${agent === 'claude' ? 'Claude Code' : 'Codex'} Π½Π΅ Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·ΠΎΠ²Π°Π½. НаТми πŸ”‘ ΠŸΠ΅Ρ€Π΅Π»ΠΎΠ³ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Π² мСню Π°Π³Π΅Π½Ρ‚Π°.`,
913
+ authRequired: true,
914
+ agent,
915
+ });
916
+ processNextInQueue();
910
917
  }
911
918
  else {
912
919
  process.stdout.write(` ❌ Agent failed (exit code ${code})\n`);
913
920
  broadcast('agent-error', { message: `АгСнт Π·Π°Π²Π΅Ρ€ΡˆΠΈΠ»ΡΡ с ΠΊΠΎΠ΄ΠΎΠΌ ${code}` });
921
+ processNextInQueue();
914
922
  }
915
923
  });
916
924
  proc.on('error', (err) => {
917
925
  agentRunning = false;
926
+ const isNotFound = err.code === 'ENOENT' || err.message.includes('ENOENT');
927
+ const agentName = agent === 'claude' ? 'Claude Code' : 'Codex';
928
+ const msg = isNotFound
929
+ ? `${agentName} Π½Π΅ установлСн. Π‘ΠΊΠ°Ρ‡Π°ΠΉ с ${agent === 'claude' ? 'claude.ai/download' : 'github.com/openai/codex'}`
930
+ : `НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ ${agent}: ${err.message}`;
918
931
  process.stdout.write(' ❌ Agent spawn error: ' + err.message + '\n');
919
- broadcast('agent-error', { message: `НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ ${agent}: ${err.message}` });
932
+ broadcast('agent-error', { message: msg, notInstalled: isNotFound, agent });
933
+ processNextInQueue();
920
934
  });
921
935
  }
936
+ /** Validate task params and enqueue (prompt is built lazily at execution time) */
937
+ function runAgent(task, featureKey, filePath) {
938
+ const agent = currentData.agent;
939
+ if (!agent) {
940
+ broadcast('agent-error', { message: 'АгСнт Π½Π΅ Π²Ρ‹Π±Ρ€Π°Π½. Π£ΠΊΠ°ΠΆΠΈ agent Π² viberadar.config.json' });
941
+ return;
942
+ }
943
+ const agentLabel = agent === 'claude' ? 'Claude Code' : 'Codex';
944
+ let title;
945
+ let savedErrors;
946
+ let savedFailedFiles;
947
+ let savedTestType;
948
+ if (task === 'write-tests') {
949
+ const feat = currentData.features?.find(f => f.key === featureKey);
950
+ if (!feat) {
951
+ broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°: ${featureKey}` });
952
+ return;
953
+ }
954
+ title = `${agentLabel} β€” тСсты для "${feat.label}"`;
955
+ }
956
+ else if (task === 'write-tests-file') {
957
+ const feat = currentData.features?.find(f => f.key === featureKey);
958
+ if (!feat || !filePath) {
959
+ broadcast('agent-error', { message: 'НС ΡƒΠΊΠ°Π·Π°Π½Π° Ρ„ΠΈΡ‡Π° ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ»' });
960
+ return;
961
+ }
962
+ const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
963
+ title = `${agentLabel} β€” тСст для "${fileName}"`;
964
+ }
965
+ else if (task === 'fix-tests') {
966
+ if (!filePath) {
967
+ broadcast('agent-error', { message: 'НС ΡƒΠΊΠ°Π·Π°Π½ Ρ„Π°ΠΉΠ» для исправлСния' });
968
+ return;
969
+ }
970
+ for (const [fp, detail] of lastTestResults) {
971
+ const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
972
+ if (rel === filePath.replace(/\\/g, '/') || fp === filePath) {
973
+ savedErrors = detail.errors;
974
+ break;
975
+ }
976
+ }
977
+ if (!savedErrors || savedErrors.length === 0) {
978
+ broadcast('agent-error', { message: `НСт сохранённых ошибок для ${filePath}. Π‘Π½Π°Ρ‡Π°Π»Π° запусти тСсты.` });
979
+ return;
980
+ }
981
+ const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
982
+ title = `${agentLabel} β€” ΠΈΡΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ тСсты Π² "${fileName}"`;
983
+ }
984
+ else if (task === 'fix-tests-all') {
985
+ savedTestType = filePath || 'unit'; // filePath param is reused for testType in this case
986
+ savedFailedFiles = [];
987
+ for (const [fp, detail] of lastTestResults) {
988
+ const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
989
+ const mod = currentData.modules.find(m => m.relativePath.replace(/\\/g, '/') === rel && m.testType === savedTestType);
990
+ if (mod && detail.errors.length > 0) {
991
+ savedFailedFiles.push({ filePath: rel, errors: detail.errors });
992
+ }
993
+ }
994
+ if (savedFailedFiles.length === 0) {
995
+ broadcast('agent-error', { message: `НСт ΡƒΠΏΠ°Π²ΡˆΠΈΡ… ${savedTestType} тСстов. Π‘Π½Π°Ρ‡Π°Π»Π° запусти тСсты.` });
996
+ return;
997
+ }
998
+ title = `${agentLabel} β€” ΠΏΠΎΡ‡ΠΈΠ½ΠΈΡ‚ΡŒ всС ${savedTestType} тСсты (${savedFailedFiles.length} Ρ„Π°ΠΉΠ»ΠΎΠ²)`;
999
+ }
1000
+ else if (task === 'generate-e2e-plan') {
1001
+ const feat = currentData.features?.find(f => f.key === featureKey);
1002
+ if (!feat) {
1003
+ broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°: ${featureKey}` });
1004
+ return;
1005
+ }
1006
+ title = `${agentLabel} β€” E2E ΠΏΠ»Π°Π½ для "${feat.label}"`;
1007
+ }
1008
+ else if (task === 'write-e2e-tests') {
1009
+ const feat = currentData.features?.find(f => f.key === featureKey);
1010
+ if (!feat) {
1011
+ broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°: ${featureKey}` });
1012
+ return;
1013
+ }
1014
+ title = `${agentLabel} β€” ΠΏΠΈΡˆΡƒ E2E тСсты для "${feat.label}"`;
1015
+ }
1016
+ else {
1017
+ title = `${agentLabel} β€” Ρ€Π°Π·ΠΎΠ±Ρ€Π°Ρ‚ΡŒ unmapped`;
1018
+ }
1019
+ const item = { task, featureKey, filePath, title, agent, savedErrors, savedFailedFiles, savedTestType };
1020
+ if (agentRunning) {
1021
+ agentQueue.push(item);
1022
+ const ql = agentQueue.length;
1023
+ process.stdout.write(` πŸ“‹ Agent busy, queued: "${title}" (queue size: ${ql})\n`);
1024
+ broadcast('agent-queued', { queueLength: ql, title, task, featureKey: featureKey || null, filePath: filePath || null });
1025
+ return;
1026
+ }
1027
+ executeAgentItem(item);
1028
+ }
922
1029
  // ── Chokidar watcher ───────────────────────────────────────────────────────
923
1030
  chokidar_1.default.watch([
924
1031
  '**/*.{ts,tsx,js,jsx,vue,svelte}',
@@ -951,13 +1058,13 @@ function startServer({ data: initialData, port, projectRoot }) {
951
1058
  testErrors[rel] = detail;
952
1059
  }
953
1060
  // Check which features have E2E plans
954
- const e2ePlansExist = [];
955
- const plansDir = e2ePlanDir(projectRoot);
1061
+ const e2ePlansExist = {};
956
1062
  try {
957
- if (fs.existsSync(plansDir)) {
958
- for (const f of fs.readdirSync(plansDir)) {
1063
+ const planDir = e2ePlanDir(projectRoot);
1064
+ if (fs.existsSync(planDir)) {
1065
+ for (const f of fs.readdirSync(planDir)) {
959
1066
  if (f.endsWith('.json'))
960
- e2ePlansExist.push(f.replace('.json', ''));
1067
+ e2ePlansExist[f.replace('.json', '')] = true;
961
1068
  }
962
1069
  }
963
1070
  }
@@ -968,7 +1075,7 @@ function startServer({ data: initialData, port, projectRoot }) {
968
1075
  }
969
1076
  if (url === '/api/status') {
970
1077
  res.writeHead(200, { 'Content-Type': 'application/json' });
971
- res.end(JSON.stringify({ coverageRunning, coverageError, agentRunning }));
1078
+ res.end(JSON.stringify({ coverageRunning, coverageError, agentRunning, queueLength: agentQueue.length }));
972
1079
  return;
973
1080
  }
974
1081
  if (url === '/api/run-coverage' && req.method === 'POST') {
@@ -1025,7 +1132,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1025
1132
  }
1026
1133
  }
1027
1134
  }
1028
- broadcast('agent-output', { line: ' β†’ НаТми πŸ”§ ΠΈΡΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ рядом с Ρ„Π°ΠΉΠ»ΠΎΠΌ Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π°Π³Π΅Π½Ρ‚ ΠΏΠΎΡ‡ΠΈΠ½ΠΈΠ»' });
1135
+ broadcast('agent-output', { line: ' β†’ НаТми ΠŸΠΎΡ‡ΠΈΠ½ΠΈΡ‚ΡŒ рядом с Ρ„Π°ΠΉΠ»ΠΎΠΌ Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π°Π³Π΅Π½Ρ‚ исправил' });
1029
1136
  }
1030
1137
  // Send testErrors directly in event β€” avoids path/timing issues with /api/data fetch
1031
1138
  const testErrorsForClient = {};
@@ -1033,6 +1140,11 @@ function startServer({ data: initialData, port, projectRoot }) {
1033
1140
  const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
1034
1141
  testErrorsForClient[rel] = detail;
1035
1142
  }
1143
+ const errKeys = Object.keys(testErrorsForClient);
1144
+ process.stdout.write(` πŸ” testErrors to client: ${errKeys.length} keys: ${errKeys.slice(0, 3).join(', ')}\n`);
1145
+ if (result.failed > 0 && errKeys.length === 0) {
1146
+ broadcast('agent-output', { line: `⚠️ Π•ΡΡ‚ΡŒ ${result.failed} ΡƒΠΏΠ°Π²ΡˆΠΈΡ… тСста, Π½ΠΎ Π΄Π΅Ρ‚Π°Π»ΠΈ ΠΏΠΎ Ρ„Π°ΠΉΠ»Π°ΠΌ Π½Π΅ ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½Ρ‹ β€” ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒ viberadar Π»ΠΎΠ³`, isDim: true });
1147
+ }
1036
1148
  broadcast('tests-done', { passed: result.passed, failed: result.failed, testErrors: testErrorsForClient });
1037
1149
  }
1038
1150
  catch (err) {
@@ -1065,11 +1177,20 @@ function startServer({ data: initialData, port, projectRoot }) {
1065
1177
  }
1066
1178
  if (url === '/api/cancel-agent' && req.method === 'POST') {
1067
1179
  agentRunning = false;
1068
- process.stdout.write(' ⏹ Agent state reset by user\n');
1180
+ agentQueue.length = 0; // clear queue too
1181
+ process.stdout.write(' ⏹ Agent state reset by user (queue cleared)\n');
1069
1182
  res.writeHead(200, { 'Content-Type': 'application/json' });
1070
1183
  res.end(JSON.stringify({ ok: true }));
1071
1184
  return;
1072
1185
  }
1186
+ if (url === '/api/clear-queue' && req.method === 'POST') {
1187
+ const cleared = agentQueue.length;
1188
+ agentQueue.length = 0;
1189
+ process.stdout.write(` πŸ—‘ Queue cleared (${cleared} items)\n`);
1190
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1191
+ res.end(JSON.stringify({ ok: true, cleared }));
1192
+ return;
1193
+ }
1073
1194
  if (url === '/api/set-agent' && req.method === 'POST') {
1074
1195
  let body = '';
1075
1196
  req.on('data', d => body += d);
@@ -1091,7 +1212,19 @@ function startServer({ data: initialData, port, projectRoot }) {
1091
1212
  });
1092
1213
  return;
1093
1214
  }
1094
- // ── E2E Plan API ──────────────────────────────────────────────────────────
1215
+ // Server-Sent Events endpoint
1216
+ if (url === '/api/events') {
1217
+ res.writeHead(200, {
1218
+ 'Content-Type': 'text/event-stream',
1219
+ 'Cache-Control': 'no-cache',
1220
+ 'Connection': 'keep-alive',
1221
+ });
1222
+ res.write('data: connected\n\n');
1223
+ sseClients.add(res);
1224
+ req.on('close', () => sseClients.delete(res));
1225
+ return;
1226
+ }
1227
+ // ── E2E routes ─────────────────────────────────────────────────────────
1095
1228
  if (url === '/api/e2e/generate-plan' && req.method === 'POST') {
1096
1229
  let body = '';
1097
1230
  req.on('data', d => body += d);
@@ -1110,18 +1243,17 @@ function startServer({ data: initialData, port, projectRoot }) {
1110
1243
  });
1111
1244
  return;
1112
1245
  }
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));
1246
+ const planMatch = url.match(/^\/api\/e2e\/plan\/(.+)$/);
1247
+ if (planMatch && req.method === 'GET') {
1248
+ const featureKey = decodeURIComponent(planMatch[1]);
1116
1249
  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' }));
1250
+ if (!plan) {
1251
+ res.writeHead(404);
1252
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1253
+ return;
1124
1254
  }
1255
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1256
+ res.end(JSON.stringify(plan));
1125
1257
  return;
1126
1258
  }
1127
1259
  if (url === '/api/e2e/review' && req.method === 'POST') {
@@ -1137,12 +1269,8 @@ function startServer({ data: initialData, port, projectRoot }) {
1137
1269
  return;
1138
1270
  }
1139
1271
  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;
1272
+ if (tc)
1273
+ tc.status = status;
1146
1274
  saveE2ePlan(projectRoot, plan);
1147
1275
  res.writeHead(200, { 'Content-Type': 'application/json' });
1148
1276
  res.end(JSON.stringify({ ok: true, plan }));
@@ -1166,10 +1294,8 @@ function startServer({ data: initialData, port, projectRoot }) {
1166
1294
  res.end(JSON.stringify({ error: 'Plan not found' }));
1167
1295
  return;
1168
1296
  }
1169
- for (const tc of plan.testCases) {
1170
- if (tc.status === 'pending')
1171
- tc.status = status;
1172
- }
1297
+ for (const tc of plan.testCases)
1298
+ tc.status = status;
1173
1299
  saveE2ePlan(projectRoot, plan);
1174
1300
  res.writeHead(200, { 'Content-Type': 'application/json' });
1175
1301
  res.end(JSON.stringify({ ok: true, plan }));
@@ -1211,58 +1337,26 @@ function startServer({ data: initialData, port, projectRoot }) {
1211
1337
  res.end(JSON.stringify({ error: 'Plan not found' }));
1212
1338
  return;
1213
1339
  }
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
- }
1228
- }
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;
1234
- }
1340
+ const writtenCases = plan.testCases.filter(tc => ['written', 'passed', 'failed'].includes(tc.status));
1341
+ const testFiles = writtenCases
1342
+ .map(tc => tc.testFilePath)
1343
+ .filter((f) => !!f)
1344
+ .map(f => path.isAbsolute(f) ? f : path.join(projectRoot, f));
1235
1345
  res.writeHead(200, { 'Content-Type': 'application/json' });
1236
1346
  res.end(JSON.stringify({ ok: true, count: testFiles.length }));
1237
1347
  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);
1348
+ const result = await runPlaywrightTests(testFiles.length > 0 ? testFiles : [`e2e/${featureKey}`], projectRoot);
1242
1349
  // Update plan with results
1243
1350
  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;
1351
+ if (result.results[tc.name]) {
1352
+ tc.status = result.results[tc.name];
1353
+ if (result.errors[tc.name])
1354
+ tc.lastError = result.errors[tc.name];
1252
1355
  }
1253
- tc.screenshotPaths = screenshots.get(tc.id) || [];
1356
+ tc.screenshotPaths = collectScreenshots(projectRoot, featureKey, tc.id);
1254
1357
  }
1255
1358
  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
- });
1359
+ broadcast('e2e-tests-done', { featureKey, passed: result.passed, failed: result.failed, plan });
1266
1360
  }
1267
1361
  catch (err) {
1268
1362
  res.writeHead(400);
@@ -1271,42 +1365,29 @@ function startServer({ data: initialData, port, projectRoot }) {
1271
1365
  });
1272
1366
  return;
1273
1367
  }
1274
- // Serve E2E screenshots
1275
- if (url.startsWith('/api/e2e/screenshot/')) {
1368
+ if (url.startsWith('/api/e2e/screenshot/') && req.method === 'GET') {
1276
1369
  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)) {
1370
+ // Path traversal protection
1371
+ const screenshotBase = path.join(projectRoot, '.viberadar', 'e2e-screenshots');
1372
+ const safePath = path.resolve(screenshotBase, relPath);
1373
+ if (!safePath.startsWith(screenshotBase)) {
1282
1374
  res.writeHead(403);
1283
1375
  res.end('Forbidden');
1284
1376
  return;
1285
1377
  }
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);
1378
+ try {
1379
+ const img = fs.readFileSync(safePath);
1380
+ const ext = path.extname(safePath).toLowerCase();
1381
+ const mime = ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/webp';
1382
+ res.writeHead(200, { 'Content-Type': mime });
1383
+ res.end(img);
1291
1384
  }
1292
- else {
1385
+ catch {
1293
1386
  res.writeHead(404);
1294
- res.end('Screenshot not found');
1387
+ res.end('Not found');
1295
1388
  }
1296
1389
  return;
1297
1390
  }
1298
- // Server-Sent Events endpoint
1299
- if (url === '/api/events') {
1300
- res.writeHead(200, {
1301
- 'Content-Type': 'text/event-stream',
1302
- 'Cache-Control': 'no-cache',
1303
- 'Connection': 'keep-alive',
1304
- });
1305
- res.write('data: connected\n\n');
1306
- sseClients.add(res);
1307
- req.on('close', () => sseClients.delete(res));
1308
- return;
1309
- }
1310
1391
  res.writeHead(404);
1311
1392
  res.end('Not found');
1312
1393
  });