viberadar 0.3.46 β†’ 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.
@@ -123,90 +123,68 @@ function fmtTool(name, input = {}) {
123
123
  default: return `πŸ”§ ${name}${fp ? ': ' + fp : ''}`;
124
124
  }
125
125
  }
126
- function e2ePlanDir(projectRoot) {
127
- return path.join(projectRoot, '.viberadar', 'e2e-plans');
128
- }
129
- function e2ePlanPath(projectRoot, featureKey) {
130
- return path.join(e2ePlanDir(projectRoot), `${featureKey}.json`);
131
- }
132
- function loadE2ePlan(projectRoot, featureKey) {
133
- const fp = e2ePlanPath(projectRoot, featureKey);
134
- try {
135
- return JSON.parse(fs.readFileSync(fp, 'utf-8'));
136
- }
137
- catch {
138
- return null;
139
- }
140
- }
141
- function saveE2ePlan(projectRoot, plan) {
142
- const dir = e2ePlanDir(projectRoot);
143
- fs.mkdirSync(dir, { recursive: true });
144
- plan.updatedAt = new Date().toISOString();
145
- fs.writeFileSync(e2ePlanPath(projectRoot, plan.featureKey), JSON.stringify(plan, null, 2), 'utf-8');
146
- }
147
- function e2eScreenshotDir(projectRoot, featureKey) {
148
- return path.join(projectRoot, '.viberadar', 'e2e-screenshots', featureKey);
149
- }
150
- function collectScreenshots(projectRoot, featureKey) {
151
- const baseDir = e2eScreenshotDir(projectRoot, featureKey);
152
- const result = new Map();
153
- if (!fs.existsSync(baseDir))
154
- return result;
155
- try {
156
- for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
157
- if (!entry.isDirectory())
158
- continue;
159
- const tcPath = path.join(baseDir, entry.name);
160
- const screenshots = fs.readdirSync(tcPath)
161
- .filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f))
162
- .map(f => `${featureKey}/${entry.name}/${f}`);
163
- result.set(entry.name, screenshots);
164
- }
165
- }
166
- catch { }
167
- return result;
168
- }
169
- function hasPlaywright(projectRoot) {
170
- try {
171
- const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
172
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
173
- return !!deps['@playwright/test'] || !!deps['playwright'];
174
- }
175
- catch {
176
- return false;
177
- }
178
- }
179
126
  // ─── Test runner after agent ──────────────────────────────────────────────────
180
127
  const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
181
128
  function runTestFiles(files, projectRoot) {
182
129
  return new Promise((resolve) => {
183
- const proc = (0, child_process_1.spawn)('npx', ['vitest', 'run', '--reporter=json', ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
130
+ // Write JSON to a temp file β€” avoids stdout encoding/regex issues on Windows
131
+ const tmpDir = path.join(projectRoot, '.viberadar');
132
+ const tmpFile = path.join(tmpDir, '_test-results.json');
133
+ try {
134
+ fs.mkdirSync(tmpDir, { recursive: true });
135
+ }
136
+ catch { }
137
+ try {
138
+ fs.unlinkSync(tmpFile);
139
+ }
140
+ catch { }
141
+ const proc = (0, child_process_1.spawn)('npx', ['vitest', 'run', '--reporter=json', `--outputFile=${tmpFile}`, ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
184
142
  let stdout = '';
185
- proc.stdout?.on('data', (d) => stdout += d.toString());
143
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
186
144
  proc.on('close', () => {
187
145
  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);
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`);
151
+ }
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`);
191
159
  // Extract per-file failure details
192
160
  const fileDetails = {};
193
161
  for (const tr of (json.testResults ?? [])) {
194
162
  const fp = tr.testFilePath ?? '';
163
+ if (!fp)
164
+ continue;
165
+ const assertions = tr.assertionResults ?? tr.testResults ?? [];
195
166
  const errors = [];
196
- for (const ar of (tr.assertionResults ?? [])) {
167
+ for (const ar of assertions) {
197
168
  if (ar.status === 'failed') {
198
169
  errors.push({
199
170
  testName: ar.fullName ?? ar.title ?? 'unknown',
200
- 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),
201
172
  });
202
173
  }
203
174
  }
204
175
  fileDetails[fp] = {
205
- passed: (tr.assertionResults ?? []).filter((a) => a.status === 'passed').length,
176
+ passed: assertions.filter((a) => a.status === 'passed').length,
206
177
  failed: errors.length,
207
178
  errors,
208
179
  };
209
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
+ }
210
188
  resolve({
211
189
  passed: json.numPassedTests ?? 0,
212
190
  failed: json.numFailedTests ?? 0,
@@ -214,58 +192,106 @@ function runTestFiles(files, projectRoot) {
214
192
  fileDetails,
215
193
  });
216
194
  }
217
- catch {
195
+ catch (err) {
196
+ process.stdout.write(` ❌ runTestFiles parse error: ${err.message}\n`);
218
197
  resolve({ passed: 0, failed: 0, files, fileDetails: {} });
219
198
  }
220
199
  });
221
200
  proc.on('error', () => resolve({ passed: 0, failed: 0, files, fileDetails: {} }));
222
201
  });
223
202
  }
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) {
211
+ try {
212
+ const p = e2ePlanPath(projectRoot, featureKey);
213
+ if (!fs.existsSync(p))
214
+ return null;
215
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
216
+ }
217
+ catch {
218
+ return null;
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 [];
242
+ }
243
+ }
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
+ }
253
+ }
254
+ // ─── Playwright runner ────────────────────────────────────────────────────────
224
255
  function runPlaywrightTests(files, projectRoot) {
225
256
  return new Promise((resolve) => {
226
- const proc = (0, child_process_1.spawn)('npx', ['playwright', 'test', ...files, '--reporter=json'], { cwd: projectRoot, shell: true, stdio: 'pipe' });
257
+ const proc = (0, child_process_1.spawn)('npx', ['playwright', 'test', '--reporter=json', ...files], { cwd: projectRoot, shell: true, stdio: 'pipe' });
227
258
  let stdout = '';
228
- proc.stdout?.on('data', (d) => stdout += d.toString());
259
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
229
260
  proc.on('close', () => {
230
261
  try {
231
262
  const match = stdout.match(/\{[\s\S]*"suites"[\s\S]*\}/);
232
263
  const json = JSON.parse(match ? match[0] : stdout);
233
- const testResults = [];
264
+ const results = {};
265
+ const errors = {};
234
266
  let passed = 0, failed = 0;
235
267
  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
- }
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);
255
281
  }
256
282
  }
257
283
  if (suite.suites)
258
284
  walkSuites(suite.suites);
259
285
  }
260
286
  };
261
- walkSuites(json.suites || []);
262
- resolve({ passed, failed, testResults });
287
+ walkSuites(json.suites ?? []);
288
+ resolve({ passed, failed, results, errors });
263
289
  }
264
290
  catch {
265
- resolve({ passed: 0, failed: 0, testResults: [] });
291
+ resolve({ passed: 0, failed: 0, results: {}, errors: {} });
266
292
  }
267
293
  });
268
- proc.on('error', () => resolve({ passed: 0, failed: 0, testResults: [] }));
294
+ proc.on('error', () => resolve({ passed: 0, failed: 0, results: {}, errors: {} }));
269
295
  });
270
296
  }
271
297
  // ─── Prompt builders ──────────────────────────────────────────────────────────
@@ -392,92 +418,64 @@ function buildMapUnmappedPrompt(modules, features) {
392
418
  ...unmapped,
393
419
  ].join('\n');
394
420
  }
395
- // ─── E2E prompt builders ────────────────────────────────────────────────────
396
- function buildE2ePlanPrompt(feat, modules, projectRoot) {
397
- const srcFiles = modules
421
+ function buildE2ePlanPrompt(feat, modules) {
422
+ const files = modules
398
423
  .filter(m => m.featureKeys.includes(feat.key) && m.type !== 'test' && !m.isInfra)
399
424
  .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
425
  return [
407
- `Π‘ΠΎΠ·Π΄Π°ΠΉ ΠΏΠ»Π°Π½ E2E-тСстов для Ρ„ΠΈΡ‡ΠΈ "${feat.label}".`,
408
- ``,
409
- `ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°ΠΉ исходный ΠΊΠΎΠ΄ Ρ„ΠΈΡ‡ΠΈ Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠΎΠ½ΡΡ‚ΡŒ UI: Ρ„ΠΎΡ€ΠΌΡ‹, ΠΊΠ½ΠΎΠΏΠΊΠΈ, Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΡŽ, состояния, API-Π²Ρ‹Π·ΠΎΠ²Ρ‹.`,
426
+ `Π’Ρ‹ β€” QA-ΠΈΠ½ΠΆΠ΅Π½Π΅Ρ€. ΠŸΡ€ΠΎΠ°Π½Π°Π»ΠΈΠ·ΠΈΡ€ΡƒΠΉ исходный ΠΊΠΎΠ΄ Ρ„ΠΈΡ‡ΠΈ "${feat.label}" ΠΈ ΡΠΎΡΡ‚Π°Π²ΡŒ ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Ρ‹ΠΉ ΠΏΠ»Π°Π½ E2E-тСстирования.`,
410
427
  ``,
411
- `Π˜ΡΡ…ΠΎΠ΄Π½Ρ‹Π΅ Ρ„Π°ΠΉΠ»Ρ‹ Ρ„ΠΈΡ‡ΠΈ (${srcFiles.length}):`,
412
- ...srcFiles,
428
+ `Π€Π°ΠΉΠ»Ρ‹ Ρ„ΠΈΡ‡ΠΈ:`,
429
+ ...files,
413
430
  ``,
414
- pageFiles.length > 0 ? `Π‘Ρ‚Ρ€Π°Π½ΠΈΡ†Ρ‹/Ρ€ΠΎΡƒΡ‚Ρ‹:\n${pageFiles.join('\n')}` : '',
431
+ `ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°ΠΉ эти Ρ„Π°ΠΉΠ»Ρ‹, ΠΈΠ·ΡƒΡ‡ΠΈ UI: Ρ„ΠΎΡ€ΠΌΡ‹, ΠΊΠ½ΠΎΠΏΠΊΠΈ, Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΡŽ, бизнСс-Π»ΠΎΠ³ΠΈΠΊΡƒ.`,
415
432
  ``,
416
- `Π’Π°ΠΆΠ½ΠΎ:`,
417
- `- ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ тСст-кСйс Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡΡ‚ΡŒ ΠΎΠ΄ΠΈΠ½ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠΉ сцСнарий`,
418
- `- Π’ΠΊΠ»ΡŽΡ‡ΠΈ happy path, edge cases, ошибки Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠΈ`,
419
- `- Steps Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹ΠΌΠΈ: ΡƒΠΊΠ°ΠΆΠΈ сСлСкторы, URL, тСкст ΠΊΠ½ΠΎΠΏΠΎΠΊ ΠΈΠ· Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΊΠΎΠ΄Π°`,
420
- `- id тСст-кСйса: ${feat.key}-<scenario>-NN (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€: ${feat.key}-login-01)`,
421
- ``,
422
- `ΠžΡ‚Π²Π΅Ρ‚ Π²Π΅Ρ€Π½ΠΈ Π‘Π’Π ΠžΠ“Πž Π² Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π΅ JSON (Π±Π΅Π· markdown-Π±Π»ΠΎΠΊΠΎΠ², Π±Π΅Π· ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅Π², чистый JSON):`,
433
+ `Π’Π΅Ρ€Π½ΠΈ Π’ΠžΠ›Π¬ΠšΠž Π²Π°Π»ΠΈΠ΄Π½Ρ‹ΠΉ JSON Π±Π΅Π· markdown-Π±Π»ΠΎΠΊΠΎΠ² Π² Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π΅:`,
423
434
  `{`,
424
435
  ` "baseUrl": "http://localhost:3000",`,
425
436
  ` "testCases": [`,
426
437
  ` {`,
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
- ` ]`,
438
+ ` "id": "feature-key-01",`,
439
+ ` "name": "ΠšΡ€Π°Ρ‚ΠΊΠΎΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ тСста",`,
440
+ ` "description": "Π§Ρ‚ΠΎ провСряСт тСст",`,
441
+ ` "steps": ["Π¨Π°Π³ 1", "Π¨Π°Π³ 2", "Π¨Π°Π³ 3"],`,
442
+ ` "expectedResults": ["ΠžΠΆΠΈΠ΄Π°Π΅ΠΌΡ‹ΠΉ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ 1", "ΠžΠΆΠΈΠ΄Π°Π΅ΠΌΡ‹ΠΉ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ 2"]`,
439
443
  ` }`,
440
444
  ` ]`,
441
445
  `}`,
442
446
  ``,
443
- `Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠΉ 5–15 тСст-кСйсов, ΠΏΠΎΠΊΡ€Ρ‹Π²Π°ΡŽΡ‰ΠΈΡ… всС основныС сцСнарии Ρ„ΠΈΡ‡ΠΈ.`,
444
- !pwConfigExists ? `\n⚠️ playwright.config.ts Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ β€” Π΅Π³ΠΎ Π½ΡƒΠΆΠ½ΠΎ Π±ΡƒΠ΄Π΅Ρ‚ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΏΠ΅Ρ€Π΅Π΄ написаниСм тСстов.` : '',
445
- ].filter(Boolean).join('\n');
447
+ `ВрСбования:`,
448
+ `- 5-15 тСст-кСйсов, ΠΏΠΎΠΊΡ€Ρ‹Π²Π°ΡŽΡ‰ΠΈΡ… основныС сцСнарии`,
449
+ `- ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ кСйс: happy path, edge cases, ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ошибок`,
450
+ `- id: строчныС Π±ΡƒΠΊΠ²Ρ‹ ΠΈ Ρ†ΠΈΡ„Ρ€Ρ‹ Ρ‡Π΅Ρ€Π΅Π· дСфис, ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ`,
451
+ `- Волько JSON, бСз объяснСний`,
452
+ ].join('\n');
446
453
  }
447
454
  function buildWriteE2eTestPrompt(feat, plan, modules) {
448
455
  const approvedCases = plan.testCases.filter(tc => tc.status === 'approved');
449
456
  const existingE2e = modules
450
- .filter(m => m.testType === 'e2e' && m.featureKeys.includes(feat.key))
457
+ .filter(m => m.featureKeys.includes(feat.key) && m.type === 'e2e')
451
458
  .map(m => '- ' + m.relativePath.replace(/\\/g, '/'));
452
459
  return [
453
- `Напиши Playwright E2E-тСсты для Ρ„ΠΈΡ‡ΠΈ "${feat.label}".`,
454
- `Base URL: ${plan.baseUrl || 'http://localhost:3000'}`,
455
- ``,
456
- `Π£Ρ‚Π²Π΅Ρ€ΠΆΠ΄Ρ‘Π½Π½Ρ‹Π΅ тСст-кСйсы (${approvedCases.length}):`,
460
+ `Напиши Playwright E2E тСсты для Ρ„ΠΈΡ‡ΠΈ "${feat.label}".`,
457
461
  ``,
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
- ``,
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('; ')}`,
466
468
  ].join('\n')),
467
469
  ``,
468
- existingE2e.length > 0
469
- ? `Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ E2E тСсты (слСдуй этим ΠΏΠ°Ρ‚Ρ‚Π΅Ρ€Π½Π°ΠΌ):\n${existingE2e.join('\n')}`
470
- : 'Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΡ… E2E тСстов ΠΏΠΎΠΊΠ° Π½Π΅Ρ‚.',
470
+ existingE2e.length > 0 ? `Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ E2E тСсты (слСдуй ΠΏΠ°Ρ‚Ρ‚Π΅Ρ€Π½Π°ΠΌ):\n${existingE2e.join('\n')}` : '',
471
471
  ``,
472
472
  `ВрСбования:`,
473
- `- Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉ Playwright: import { test, expect } from '@playwright/test'`,
474
- `- Π€Π°ΠΉΠ»Ρ‹ создавай Π² e2e/${feat.key}/ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ`,
475
- `- ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ тСст-кСйс β€” ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΉ test() Π±Π»ΠΎΠΊ`,
476
- `- Добавляй ΡΠΊΡ€ΠΈΠ½ΡˆΠΎΡ‚Ρ‹ послС ΠΊΠ»ΡŽΡ‡Π΅Π²Ρ‹Ρ… шагов:`,
477
- ` await page.screenshot({ path: '.viberadar/e2e-screenshots/${feat.key}/<test-id>/step-N.png' })`,
478
- `- Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉ data-testid сСлСкторы Π³Π΄Π΅ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎ`,
479
- `- Π”ΠΎΠ±Π°Π²ΡŒ page.waitForLoadState() ΠΈ Π΄Ρ€ΡƒΠ³ΠΈΠ΅ оТидания`,
480
- `- НС измСняй ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ тСсты`,
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
+ `- Π‘Π»Π΅Π΄ΡƒΠΉ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΌ ΠΏΠ°Ρ‚Ρ‚Π΅Ρ€Π½Π°ΠΌ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°`,
481
479
  ].filter(Boolean).join('\n');
482
480
  }
483
481
  // ─── Coverage provider auto-install ──────────────────────────────────────────
@@ -705,27 +703,18 @@ function startServer({ data: initialData, port, projectRoot }) {
705
703
  broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°: ${featureKey}` });
706
704
  return;
707
705
  }
708
- prompt = buildE2ePlanPrompt(feat, currentData.modules, projectRoot);
706
+ prompt = buildE2ePlanPrompt(feat, currentData.modules);
709
707
  title = `${agentLabel} β€” E2E ΠΏΠ»Π°Π½ для "${feat.label}"`;
710
708
  }
711
709
  else if (task === 'write-e2e-tests') {
712
710
  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}"` });
711
+ const plan = featureKey ? loadE2ePlan(projectRoot, featureKey) : null;
712
+ if (!feat || !plan) {
713
+ broadcast('agent-error', { message: `Π€ΠΈΡ‡Π° ΠΈΠ»ΠΈ ΠΏΠ»Π°Π½ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Ρ‹: ${featureKey}` });
725
714
  return;
726
715
  }
727
716
  prompt = buildWriteE2eTestPrompt(feat, plan, currentData.modules);
728
- title = `${agentLabel} β€” E2E тСсты для "${feat.label}" (${approved.length} кСйсов)`;
717
+ title = `${agentLabel} β€” ΠΏΠΈΡˆΡƒ E2E тСсты для "${feat.label}"`;
729
718
  }
730
719
  else {
731
720
  prompt = buildMapUnmappedPrompt(currentData.modules, currentData.features || []);
@@ -754,7 +743,7 @@ function startServer({ data: initialData, port, projectRoot }) {
754
743
  broadcast('agent-output', { line: `πŸ“„ Π—Π°Π΄Π°Ρ‡Π° записана Π² .viberadar/task.md` });
755
744
  // Track test files written/edited by agent (for auto-run after)
756
745
  const createdTestFiles = [];
757
- // Accumulate full result text from agent (for JSON plan parsing)
746
+ // Accumulate full result text for E2E plan parsing
758
747
  let agentResultText = '';
759
748
  function trackWrittenFiles(raw) {
760
749
  try {
@@ -786,11 +775,10 @@ function startServer({ data: initialData, port, projectRoot }) {
786
775
  return;
787
776
  }
788
777
  if (parsed.startsWith('Β§RESULTΒ§')) {
789
- const resultBody = parsed.slice('Β§RESULTΒ§'.length);
790
- agentResultText += resultBody + '\n';
791
778
  // Full result summary β€” split into lines and prefix with indent
779
+ agentResultText = parsed.slice('Β§RESULTΒ§'.length).trim();
792
780
  broadcast('agent-output', { line: '─────────────────────────────' });
793
- for (const l of resultBody.split('\n')) {
781
+ for (const l of agentResultText.split('\n')) {
794
782
  if (l.trim())
795
783
  broadcast('agent-output', { line: ' ' + l });
796
784
  }
@@ -843,61 +831,36 @@ function startServer({ data: initialData, port, projectRoot }) {
843
831
  }
844
832
  broadcast('agent-summary', result);
845
833
  }
846
- // ── E2E plan post-processing ─────────────────────────────────────
834
+ // E2E plan post-processing
847
835
  if (task === 'generate-e2e-plan' && featureKey) {
848
- const feat = currentData.features?.find(f => f.key === featureKey);
849
836
  try {
850
- // Extract JSON from agent result text
851
837
  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
- }
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 });
877
850
  }
878
851
  catch (err) {
879
- broadcast('e2e-plan-error', { featureKey, message: err.message });
880
- broadcast('agent-output', { line: `❌ Ошибка парсинга ΠΏΠ»Π°Π½Π°: ${err.message}`, isError: true });
852
+ broadcast('e2e-plan-error', { featureKey, message: `НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Ρ€Π°ΡΠΏΠ°Ρ€ΡΠΈΡ‚ΡŒ ΠΏΠ»Π°Π½: ${err.message}` });
881
853
  }
882
854
  }
883
855
  if (task === 'write-e2e-tests' && featureKey) {
884
- // Update plan: mark approved test cases as 'written' and set file paths
885
856
  const plan = loadE2ePlan(projectRoot, featureKey);
886
857
  if (plan) {
887
858
  for (const tc of plan.testCases) {
888
- if (tc.status === 'approved') {
859
+ if (tc.status === 'approved')
889
860
  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
861
  }
898
862
  saveE2ePlan(projectRoot, plan);
899
- broadcast('e2e-plan-ready', { featureKey, plan: plan });
900
- broadcast('agent-output', { line: `βœ… E2E ΠΏΠ»Π°Π½ ΠΎΠ±Π½ΠΎΠ²Π»Ρ‘Π½: тСсты ΠΎΡ‚ΠΌΠ΅Ρ‡Π΅Π½Ρ‹ ΠΊΠ°ΠΊ написанныС` });
863
+ broadcast('e2e-tests-written', { featureKey, files: createdTestFiles });
901
864
  }
902
865
  }
903
866
  process.stdout.write(' βœ… Agent done, rescanning...\n');
@@ -951,13 +914,13 @@ function startServer({ data: initialData, port, projectRoot }) {
951
914
  testErrors[rel] = detail;
952
915
  }
953
916
  // Check which features have E2E plans
954
- const e2ePlansExist = [];
955
- const plansDir = e2ePlanDir(projectRoot);
917
+ const e2ePlansExist = {};
956
918
  try {
957
- if (fs.existsSync(plansDir)) {
958
- for (const f of fs.readdirSync(plansDir)) {
919
+ const planDir = e2ePlanDir(projectRoot);
920
+ if (fs.existsSync(planDir)) {
921
+ for (const f of fs.readdirSync(planDir)) {
959
922
  if (f.endsWith('.json'))
960
- e2ePlansExist.push(f.replace('.json', ''));
923
+ e2ePlansExist[f.replace('.json', '')] = true;
961
924
  }
962
925
  }
963
926
  }
@@ -1025,7 +988,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1025
988
  }
1026
989
  }
1027
990
  }
1028
- broadcast('agent-output', { line: ' β†’ НаТми πŸ”§ ΠΈΡΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ рядом с Ρ„Π°ΠΉΠ»ΠΎΠΌ Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π°Π³Π΅Π½Ρ‚ ΠΏΠΎΡ‡ΠΈΠ½ΠΈΠ»' });
991
+ broadcast('agent-output', { line: ' β†’ НаТми ΠŸΠΎΡ‡ΠΈΠ½ΠΈΡ‚ΡŒ рядом с Ρ„Π°ΠΉΠ»ΠΎΠΌ Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π°Π³Π΅Π½Ρ‚ исправил' });
1029
992
  }
1030
993
  // Send testErrors directly in event β€” avoids path/timing issues with /api/data fetch
1031
994
  const testErrorsForClient = {};
@@ -1033,6 +996,11 @@ function startServer({ data: initialData, port, projectRoot }) {
1033
996
  const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
1034
997
  testErrorsForClient[rel] = detail;
1035
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
+ }
1036
1004
  broadcast('tests-done', { passed: result.passed, failed: result.failed, testErrors: testErrorsForClient });
1037
1005
  }
1038
1006
  catch (err) {
@@ -1091,7 +1059,19 @@ function startServer({ data: initialData, port, projectRoot }) {
1091
1059
  });
1092
1060
  return;
1093
1061
  }
1094
- // ── E2E Plan API ──────────────────────────────────────────────────────────
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));
1072
+ return;
1073
+ }
1074
+ // ── E2E routes ─────────────────────────────────────────────────────────
1095
1075
  if (url === '/api/e2e/generate-plan' && req.method === 'POST') {
1096
1076
  let body = '';
1097
1077
  req.on('data', d => body += d);
@@ -1110,18 +1090,17 @@ function startServer({ data: initialData, port, projectRoot }) {
1110
1090
  });
1111
1091
  return;
1112
1092
  }
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));
1093
+ const planMatch = url.match(/^\/api\/e2e\/plan\/(.+)$/);
1094
+ if (planMatch && req.method === 'GET') {
1095
+ const featureKey = decodeURIComponent(planMatch[1]);
1116
1096
  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' }));
1097
+ if (!plan) {
1098
+ res.writeHead(404);
1099
+ res.end(JSON.stringify({ error: 'Plan not found' }));
1100
+ return;
1124
1101
  }
1102
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1103
+ res.end(JSON.stringify(plan));
1125
1104
  return;
1126
1105
  }
1127
1106
  if (url === '/api/e2e/review' && req.method === 'POST') {
@@ -1137,12 +1116,8 @@ function startServer({ data: initialData, port, projectRoot }) {
1137
1116
  return;
1138
1117
  }
1139
1118
  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;
1119
+ if (tc)
1120
+ tc.status = status;
1146
1121
  saveE2ePlan(projectRoot, plan);
1147
1122
  res.writeHead(200, { 'Content-Type': 'application/json' });
1148
1123
  res.end(JSON.stringify({ ok: true, plan }));
@@ -1166,10 +1141,8 @@ function startServer({ data: initialData, port, projectRoot }) {
1166
1141
  res.end(JSON.stringify({ error: 'Plan not found' }));
1167
1142
  return;
1168
1143
  }
1169
- for (const tc of plan.testCases) {
1170
- if (tc.status === 'pending')
1171
- tc.status = status;
1172
- }
1144
+ for (const tc of plan.testCases)
1145
+ tc.status = status;
1173
1146
  saveE2ePlan(projectRoot, plan);
1174
1147
  res.writeHead(200, { 'Content-Type': 'application/json' });
1175
1148
  res.end(JSON.stringify({ ok: true, plan }));
@@ -1211,58 +1184,26 @@ function startServer({ data: initialData, port, projectRoot }) {
1211
1184
  res.end(JSON.stringify({ error: 'Plan not found' }));
1212
1185
  return;
1213
1186
  }
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
- }
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));
1235
1192
  res.writeHead(200, { 'Content-Type': 'application/json' });
1236
1193
  res.end(JSON.stringify({ ok: true, count: testFiles.length }));
1237
1194
  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);
1195
+ const result = await runPlaywrightTests(testFiles.length > 0 ? testFiles : [`e2e/${featureKey}`], projectRoot);
1242
1196
  // Update plan with results
1243
1197
  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;
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];
1252
1202
  }
1253
- tc.screenshotPaths = screenshots.get(tc.id) || [];
1203
+ tc.screenshotPaths = collectScreenshots(projectRoot, featureKey, tc.id);
1254
1204
  }
1255
1205
  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
- });
1206
+ broadcast('e2e-tests-done', { featureKey, passed: result.passed, failed: result.failed, plan });
1266
1207
  }
1267
1208
  catch (err) {
1268
1209
  res.writeHead(400);
@@ -1271,42 +1212,29 @@ function startServer({ data: initialData, port, projectRoot }) {
1271
1212
  });
1272
1213
  return;
1273
1214
  }
1274
- // Serve E2E screenshots
1275
- if (url.startsWith('/api/e2e/screenshot/')) {
1215
+ if (url.startsWith('/api/e2e/screenshot/') && req.method === 'GET') {
1276
1216
  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)) {
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)) {
1282
1221
  res.writeHead(403);
1283
1222
  res.end('Forbidden');
1284
1223
  return;
1285
1224
  }
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);
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);
1291
1231
  }
1292
- else {
1232
+ catch {
1293
1233
  res.writeHead(404);
1294
- res.end('Screenshot not found');
1234
+ res.end('Not found');
1295
1235
  }
1296
1236
  return;
1297
1237
  }
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
1238
  res.writeHead(404);
1311
1239
  res.end('Not found');
1312
1240
  });