viberadar 0.3.44 → 0.3.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -83,68 +83,6 @@
83
83
  #termBtn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
84
84
  #termBtn.term-active { color: var(--accent); border-color: var(--accent); }
85
85
 
86
- #agentBtn {
87
- padding: 5px 12px;
88
- background: var(--bg);
89
- border: 1px solid var(--border);
90
- border-radius: 6px;
91
- color: var(--muted);
92
- font-size: 12px;
93
- cursor: pointer;
94
- display: flex;
95
- align-items: center;
96
- gap: 5px;
97
- transition: background 0.1s, color 0.1s, border-color 0.1s;
98
- white-space: nowrap;
99
- }
100
- #agentBtn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
101
-
102
- .agent-menu {
103
- display: none;
104
- position: absolute;
105
- top: calc(100% + 6px);
106
- right: 0;
107
- min-width: 200px;
108
- background: var(--bg-card);
109
- border: 1px solid var(--border);
110
- border-radius: 8px;
111
- padding: 6px;
112
- z-index: 1000;
113
- box-shadow: 0 8px 24px rgba(0,0,0,0.4);
114
- }
115
- .agent-menu.open { display: block; }
116
- .agent-menu-label {
117
- font-size: 10px;
118
- text-transform: uppercase;
119
- letter-spacing: 0.5px;
120
- color: var(--dim);
121
- padding: 6px 10px 4px;
122
- }
123
- .agent-menu-item {
124
- display: flex;
125
- align-items: center;
126
- justify-content: space-between;
127
- gap: 8px;
128
- width: 100%;
129
- padding: 8px 10px;
130
- background: none;
131
- border: none;
132
- border-radius: 6px;
133
- color: var(--text);
134
- font-size: 13px;
135
- cursor: pointer;
136
- text-align: left;
137
- transition: background 0.1s;
138
- }
139
- .agent-menu-item:hover { background: var(--bg-hover); }
140
- .agent-menu-check { color: var(--green); font-weight: 700; display: none; }
141
- .agent-menu-check.active { display: inline; }
142
- .agent-menu-divider {
143
- height: 1px;
144
- background: var(--border);
145
- margin: 4px 0;
146
- }
147
-
148
86
  /* ── Stats bar ───────────────────────────────────────────────────────────── */
149
87
  .stats-bar {
150
88
  display: flex;
@@ -234,8 +172,7 @@
234
172
  .type-count { margin-left: auto; font-size: 11px; color: var(--dim); }
235
173
 
236
174
  /* ── Content area ────────────────────────────────────────────────────────── */
237
- .content { flex: 1; overflow-y: auto; padding: 18px 20px; transition: padding-bottom 0.25s ease; }
238
- .content.panel-open { padding-bottom: 300px; }
175
+ .content { flex: 1; overflow-y: auto; padding: 18px 20px; }
239
176
 
240
177
  /* ── Feature cards ───────────────────────────────────────────────────────── */
241
178
  .features-grid {
@@ -482,56 +419,57 @@
482
419
 
483
420
  /* ── Test-type cards inside feature detail ───────────────────────────────── */
484
421
  .test-type-grid {
485
- display: flex;
486
- gap: 8px;
487
- margin-bottom: 14px;
488
- flex-wrap: wrap;
489
- align-items: center;
422
+ display: grid;
423
+ grid-template-columns: repeat(4, 1fr);
424
+ gap: 10px;
425
+ margin-bottom: 20px;
490
426
  }
491
427
  .test-type-card {
492
- display: flex;
493
- align-items: center;
494
- gap: 8px;
495
428
  background: var(--bg-card);
496
429
  border: 1px solid var(--border);
497
- border-left: 3px solid transparent;
498
430
  border-radius: 8px;
499
- padding: 7px 12px;
431
+ padding: 14px 16px;
500
432
  cursor: pointer;
501
433
  transition: background 0.15s, border-color 0.15s;
502
- flex-shrink: 0;
434
+ position: relative;
435
+ overflow: hidden;
436
+ }
437
+ .test-type-card:hover { background: var(--bg-hover); border-color: var(--dim); }
438
+ .test-type-card .tt-accent {
439
+ position: absolute; top: 0; left: 0; right: 0; height: 3px;
503
440
  }
504
- .test-type-card:hover { background: var(--bg-hover); }
505
- .test-type-card .tt-accent { display: none; } /* replaced by border-left */
506
441
  .test-type-card .tt-label {
507
442
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
508
- color: var(--muted); white-space: nowrap;
443
+ color: var(--muted); margin-bottom: 6px; margin-top: 2px;
509
444
  }
510
445
  .test-type-card .tt-count {
511
- font-size: 18px; font-weight: 700; line-height: 1;
446
+ font-size: 26px; font-weight: 700; line-height: 1;
447
+ margin-bottom: 4px;
512
448
  }
513
449
  .test-type-card .tt-sub {
514
- font-size: 11px; color: var(--dim); white-space: nowrap;
450
+ font-size: 11px; color: var(--dim);
451
+ }
452
+ .test-type-card.tt-empty .tt-count { color: var(--dim); }
453
+ .test-type-card.tt-empty { opacity: 0.7; }
454
+ .test-type-card.tt-active {
455
+ background: var(--bg-hover);
456
+ border-width: 2px;
515
457
  }
516
- .test-type-card.tt-empty { opacity: 0.65; }
517
- .test-type-card.tt-active { background: var(--bg-hover); }
518
458
  .test-type-card.tt-failed {
519
459
  border-color: var(--red) !important;
460
+ border-width: 2px;
520
461
  background: rgba(248, 81, 73, 0.06);
521
462
  }
522
- .tt-chip-actions { display: flex; gap: 4px; margin-left: 4px; }
523
463
  .tt-run-btn {
524
- display: inline-block; margin: 0;
525
- padding: 3px 9px;
464
+ display: block; width: 100%; margin-top: 8px;
465
+ padding: 3px 0;
526
466
  background: transparent; border: 1px solid var(--border);
527
467
  border-radius: 4px; color: var(--muted); font-size: 10px;
528
- cursor: pointer; white-space: nowrap;
468
+ cursor: pointer; text-align: center;
529
469
  transition: background 0.1s, color 0.1s, border-color 0.1s;
530
470
  }
531
471
  .tt-run-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
532
472
  .tt-run-btn:disabled { opacity: 0.4; cursor: not-allowed; }
533
- .tt-fix-btn { border-color: var(--yellow); color: var(--yellow); font-weight: 600; }
534
- .tt-fix-btn:hover { background: rgba(255,200,0,0.1); color: var(--yellow); border-color: var(--yellow); }
535
473
  .file-rows { display: flex; flex-direction: column; gap: 2px; }
536
474
  .file-row {
537
475
  display: flex;
@@ -583,10 +521,6 @@
583
521
  background: rgba(248, 81, 73, 0.07);
584
522
  }
585
523
  .file-row.has-errors:hover { background: rgba(248, 81, 73, 0.12); }
586
- .file-agent-spinner { display: inline-block; width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
587
- .file-agent-spinner.running { border: 2px solid var(--yellow); border-top-color: transparent; animation: spin 0.7s linear infinite; }
588
- .file-agent-spinner.queued { border: 2px solid var(--dim); border-top-color: transparent; animation: spin 1.5s linear infinite; }
589
- @keyframes spin { to { transform: rotate(360deg); } }
590
524
  .file-row-errors {
591
525
  padding: 4px 10px 6px 32px;
592
526
  display: flex; flex-direction: column; gap: 3px;
@@ -668,74 +602,11 @@
668
602
  font-size: 14px; padding: 3px 6px; border-radius: 4px; line-height: 1;
669
603
  }
670
604
  .agent-panel-close:hover { background: var(--border); color: var(--text); }
671
- .agent-panel-copy {
672
- background: none; border: none; color: var(--dim); cursor: pointer;
673
- font-size: 13px; padding: 3px 7px; border-radius: 4px; line-height: 1;
674
- }
675
- .agent-panel-copy:hover { background: var(--border); color: var(--text); }
676
- .agent-panel-copy.copied { color: var(--green); }
677
605
  .agent-panel-cancel {
678
606
  background: none; border: 1px solid var(--yellow); color: var(--yellow);
679
607
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
680
608
  }
681
609
  .agent-panel-cancel:hover { background: var(--yellow); color: #000; }
682
- .agent-queue-badge {
683
- font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
684
- border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
685
- }
686
- /* ── Console Tabs ───────────────────────────────────────────────────────── */
687
- .agent-tabs-bar {
688
- display: flex; align-items: stretch; overflow-x: auto;
689
- background: #0a0e15; border-bottom: 1px solid var(--border);
690
- flex-shrink: 0; min-height: 30px;
691
- scrollbar-width: thin; scrollbar-color: var(--border) transparent;
692
- }
693
- .agent-tabs-bar::-webkit-scrollbar { height: 3px; }
694
- .agent-tabs-bar::-webkit-scrollbar-thumb { background: var(--border); }
695
- .agent-tab {
696
- display: flex; align-items: center; gap: 6px;
697
- padding: 0 8px 0 10px; cursor: pointer;
698
- white-space: nowrap; border-right: 1px solid var(--border);
699
- font-size: 11px; color: var(--muted); background: transparent;
700
- max-width: 200px; flex-shrink: 0; user-select: none;
701
- transition: background 0.1s, color 0.1s;
702
- border-bottom: 2px solid transparent;
703
- }
704
- .agent-tab:hover { background: var(--bg-hover); color: var(--text); }
705
- .agent-tab.active { background: var(--bg-card); color: var(--text); border-bottom-color: var(--accent); }
706
- .agent-tab-title { overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
707
- .agent-tab-close {
708
- background: none; border: none; cursor: pointer; color: var(--dim);
709
- font-size: 13px; padding: 0 2px; line-height: 1; flex-shrink: 0;
710
- border-radius: 3px; margin-left: 2px; opacity: 0;
711
- }
712
- .agent-tab:hover .agent-tab-close { opacity: 1; }
713
- .agent-tab-close:hover { background: var(--border); color: var(--text); }
714
- .tab-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
715
- .tab-dot-running { background: var(--yellow); animation: tab-pulse 1s ease-in-out infinite; }
716
- .tab-dot-ok { background: var(--green); }
717
- .tab-dot-error { background: var(--red); }
718
- .tab-dot-info { background: var(--muted); }
719
- @keyframes tab-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
720
- #termBtn.term-error { color: var(--red); border-color: var(--red); }
721
- .file-row-more-btn {
722
- background: none; border: none; cursor: pointer; font-size: 14px; color: var(--dim);
723
- padding: 0 4px; line-height: 1; opacity: 0; transition: opacity 0.1s;
724
- }
725
- .file-row:hover .file-row-more-btn { opacity: 1; }
726
- .file-row-more-wrap { position: relative; display: inline-block; }
727
- .file-row-more-dropdown {
728
- display: none; position: absolute; right: 0; top: 100%; z-index: 200;
729
- background: var(--card); border: 1px solid var(--border); border-radius: 6px;
730
- min-width: 220px; padding: 4px 0; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
731
- }
732
- .file-row-more-dropdown.open { display: block; }
733
- .file-row-more-item {
734
- display: block; width: 100%; text-align: left; background: none; border: none;
735
- cursor: pointer; padding: 7px 14px; font-size: 12px; color: var(--text);
736
- white-space: nowrap;
737
- }
738
- .file-row-more-item:hover { background: var(--border); }
739
610
  .agent-terminal {
740
611
  flex: 1;
741
612
  overflow-y: auto;
@@ -748,6 +619,93 @@
748
619
  .agent-line.err { color: var(--red); }
749
620
  .agent-line.dim { color: var(--dim); font-size: 10px; }
750
621
 
622
+ /* ── E2E Plan ──────────────────────────────────────────────────────────── */
623
+ .e2e-plan-container { display: flex; flex-direction: column; gap: 12px; }
624
+ .e2e-case {
625
+ background: var(--bg-card); border: 1px solid var(--border);
626
+ border-radius: 8px; padding: 14px 16px;
627
+ transition: border-color 0.15s, background 0.15s;
628
+ }
629
+ .e2e-case.approved { border-color: var(--green); }
630
+ .e2e-case.rejected { border-color: var(--red); opacity: 0.5; }
631
+ .e2e-case.written { border-color: var(--blue); }
632
+ .e2e-case.passed { border-color: var(--green); background: rgba(63,185,80,0.06); }
633
+ .e2e-case.failed { border-color: var(--red); background: rgba(248,81,73,0.06); }
634
+ .e2e-case-header {
635
+ display: flex; align-items: center; justify-content: space-between;
636
+ margin-bottom: 6px; gap: 8px;
637
+ }
638
+ .e2e-case-title { font-size: 13px; font-weight: 600; flex: 1; }
639
+ .e2e-case-id { font-size: 11px; color: var(--dim); font-family: monospace; }
640
+ .e2e-case-status {
641
+ font-size: 11px; font-weight: 600; padding: 2px 8px;
642
+ border-radius: 4px; text-transform: uppercase;
643
+ }
644
+ .e2e-case-desc { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.4; }
645
+ .e2e-case-steps {
646
+ font-size: 12px; color: var(--text); padding-left: 18px;
647
+ margin-bottom: 6px; line-height: 1.6;
648
+ }
649
+ .e2e-case-steps li { margin-bottom: 2px; }
650
+ .e2e-case-expected {
651
+ font-size: 12px; color: var(--muted); padding-left: 18px;
652
+ margin-bottom: 8px; line-height: 1.6;
653
+ }
654
+ .e2e-case-expected li { margin-bottom: 2px; list-style: disc; }
655
+ .e2e-case-actions { display: flex; gap: 6px; margin-top: 8px; }
656
+ .e2e-btn {
657
+ padding: 4px 12px; border-radius: 4px; font-size: 11px;
658
+ cursor: pointer; font-weight: 600; border: 1px solid; background: transparent;
659
+ transition: background 0.1s, color 0.1s;
660
+ }
661
+ .e2e-btn-approve { border-color: var(--green); color: var(--green); }
662
+ .e2e-btn-approve:hover { background: var(--green); color: #000; }
663
+ .e2e-btn-reject { border-color: var(--red); color: var(--red); }
664
+ .e2e-btn-reject:hover { background: var(--red); color: #fff; }
665
+ .e2e-btn-primary {
666
+ border: none; background: var(--blue); color: #000; font-weight: 700;
667
+ }
668
+ .e2e-btn-primary:hover { filter: brightness(1.1); }
669
+ .e2e-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
670
+ .e2e-btn-secondary {
671
+ border-color: var(--border); color: var(--muted);
672
+ }
673
+ .e2e-btn-secondary:hover { background: var(--bg-hover); color: var(--text); }
674
+ .e2e-error-msg {
675
+ font-size: 11px; color: var(--red); margin-top: 6px;
676
+ padding: 6px 10px; background: rgba(248,81,73,0.08); border-radius: 4px;
677
+ font-family: monospace; white-space: pre-wrap; word-break: break-all;
678
+ }
679
+ .e2e-screenshot-strip {
680
+ display: flex; gap: 8px; overflow-x: auto; padding: 8px 0;
681
+ scrollbar-width: thin;
682
+ }
683
+ .e2e-screenshot {
684
+ width: 180px; height: 120px; object-fit: cover;
685
+ border-radius: 6px; border: 1px solid var(--border);
686
+ cursor: pointer; transition: transform 0.15s, border-color 0.15s;
687
+ flex-shrink: 0;
688
+ }
689
+ .e2e-screenshot:hover { transform: scale(1.05); border-color: var(--blue); }
690
+ .e2e-screenshot-modal {
691
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
692
+ background: rgba(0,0,0,0.85); z-index: 300;
693
+ display: flex; align-items: center; justify-content: center;
694
+ cursor: pointer;
695
+ }
696
+ .e2e-screenshot-modal img {
697
+ max-width: 90vw; max-height: 90vh; border-radius: 8px;
698
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
699
+ }
700
+ .e2e-batch-bar {
701
+ display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;
702
+ align-items: center;
703
+ }
704
+ .e2e-stats {
705
+ display: flex; gap: 12px; font-size: 12px; color: var(--muted);
706
+ margin-left: auto;
707
+ }
708
+
751
709
  /* ── Misc ────────────────────────────────────────────────────────────────── */
752
710
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
753
711
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
@@ -762,31 +720,6 @@
762
720
  <span class="header-time" id="scannedAt"></span>
763
721
  <button id="covBtn" onclick="runCoverage()" title="Запустить тесты с coverage">🧪 Coverage</button>
764
722
  <button id="termBtn" onclick="toggleAgentPanel()" title="Показать/скрыть терминал агента">📟 Terminal</button>
765
- <div style="position:relative">
766
- <button id="agentBtn" onclick="toggleAgentMenu()" title="Настройки агента">🤖 —</button>
767
- <div id="agentMenu" class="agent-menu">
768
- <div class="agent-menu-label">AI Агент</div>
769
- <button class="agent-menu-item" id="amClaude" onclick="setAgent('claude');closeAgentMenu()">
770
- <span>⚡ Claude Code</span><span class="agent-menu-check" id="amClaudeCheck">✓</span>
771
- </button>
772
- <button class="agent-menu-item" id="amCodex" onclick="setAgent('codex');closeAgentMenu()">
773
- <span>🟢 Codex</span><span class="agent-menu-check" id="amCodexCheck">✓</span>
774
- </button>
775
- <div class="agent-menu-divider" id="amModelDivider"></div>
776
- <div class="agent-menu-label" id="amModelLabel">Модель Claude</div>
777
- <button class="agent-menu-item" id="amSonnet46" onclick="setModel('claude-sonnet-4-6');closeAgentMenu()">
778
- <span>sonnet-4-6 <span style="color:var(--dim);font-size:10px">быстрый</span></span><span class="agent-menu-check" id="amSonnet46Check">✓</span>
779
- </button>
780
- <button class="agent-menu-item" id="amOpus45" onclick="setModel('claude-opus-4-5');closeAgentMenu()">
781
- <span>opus-4-5 <span style="color:var(--dim);font-size:10px">умный</span></span><span class="agent-menu-check" id="amOpus45Check">✓</span>
782
- </button>
783
- <button class="agent-menu-item" id="amHaiku35" onclick="setModel('claude-haiku-3-5');closeAgentMenu()">
784
- <span>haiku-3-5 <span style="color:var(--dim);font-size:10px">дешёвый</span></span><span class="agent-menu-check" id="amHaiku35Check">✓</span>
785
- </button>
786
- <div class="agent-menu-divider"></div>
787
- <button class="agent-menu-item" onclick="reauthAgent();closeAgentMenu()">🔑 Перелогиниться</button>
788
- </div>
789
- </div>
790
723
  <span id="liveDot" title="Connecting…" style="
791
724
  width:8px; height:8px; border-radius:50%;
792
725
  background:var(--dim); display:inline-block;
@@ -821,13 +754,9 @@
821
754
  <div class="agent-panel-header">
822
755
  <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
823
756
  <span class="agent-panel-status" id="agentPanelStatus">running…</span>
824
- <span class="agent-queue-badge" id="agentQueueBadge" style="display:none">📋 <span id="agentQueueCount">0</span> в очереди</span>
825
- <button class="agent-panel-cancel" id="agentQueueClearBtn" onclick="clearAgentQueue()" title="Очистить очередь" style="display:none">🗑 очередь</button>
826
757
  <button class="agent-panel-cancel" id="agentCancelBtn" onclick="cancelAgent()" title="Сбросить состояние агента" style="display:none">⏹ сброс</button>
827
- <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
828
758
  <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
829
759
  </div>
830
- <div class="agent-tabs-bar" id="agentTabsBar"></div>
831
760
  <div class="agent-terminal" id="agentTerminal"></div>
832
761
  </div>
833
762
 
@@ -842,208 +771,229 @@ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string
842
771
  let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
843
772
  let coverageRunning = false;
844
773
  let coverageHasError = false;
774
+ let e2ePlan = null;
775
+ let e2ePlanLoading = false;
845
776
 
846
- // ─── Coverage button ───────────────────────────────────────────────────────────
847
- function updateCovBtn() {
848
- const btn = document.getElementById('covBtn');
849
- if (!btn) return;
850
- btn.className = '';
851
- btn.disabled = coverageRunning;
852
- if (coverageRunning) {
853
- btn.className = 'cov-running';
854
- btn.textContent = '⏳ Running...';
855
- } else if (coverageHasError) {
856
- btn.className = 'cov-error';
857
- btn.textContent = '❌ Coverage failed';
858
- } else {
859
- btn.textContent = '🧪 Coverage';
860
- }
777
+ // ─── E2E Plan functions ────────────────────────────────────────────────────────
778
+
779
+ async function generateE2ePlan(featureKey) {
780
+ if (agentRunning) { appendTerminalLine('Agent already running', true); return; }
781
+ e2ePlanLoading = true;
782
+ e2ePlan = null;
783
+ renderContent();
784
+ await fetch('/api/e2e/generate-plan', {
785
+ method: 'POST',
786
+ headers: { 'Content-Type': 'application/json' },
787
+ body: JSON.stringify({ featureKey }),
788
+ });
861
789
  }
862
790
 
863
- async function runCoverage() {
864
- if (coverageRunning) return;
865
- await fetch('/api/run-coverage', { method: 'POST' });
866
- // SSE events will update state
791
+ async function loadE2ePlan(featureKey) {
792
+ try {
793
+ const res = await fetch('/api/e2e/plan/' + encodeURIComponent(featureKey));
794
+ if (res.ok) { e2ePlan = await res.json(); }
795
+ else { e2ePlan = null; }
796
+ } catch { e2ePlan = null; }
867
797
  }
868
798
 
869
- // ─── Agent ────────────────────────────────────────────────────────────────────
870
- let agentRunning = false;
871
- const agentRunningPaths = new Set(); // paths of files currently being processed by agent
872
- const agentQueuedPaths = new Set(); // paths of files waiting in queue
873
-
874
- // ─── Console Sessions ─────────────────────────────────────────────────────────
875
- const consoleSessions = []; // { id, title, lines, status, startTime }
876
- let activeSessionId = null; // currently viewed tab
877
- let runningSessionId = null; // tab that is currently receiving output
878
- const SESSION_MAX = 25;
879
- const SESSIONS_KEY = 'viberadar_sessions';
880
-
881
- function _sessionId() {
882
- return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
799
+ async function reviewE2eCase(featureKey, testCaseId, status) {
800
+ const res = await fetch('/api/e2e/review', {
801
+ method: 'POST',
802
+ headers: { 'Content-Type': 'application/json' },
803
+ body: JSON.stringify({ featureKey, testCaseId, status }),
804
+ });
805
+ if (res.ok) { e2ePlan = (await res.json()).plan; renderContent(); }
883
806
  }
884
807
 
885
- function saveSessions() {
886
- try {
887
- const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
888
- ...s, lines: s.lines.slice(-500)
889
- }));
890
- localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
891
- } catch {}
808
+ async function reviewAllE2e(featureKey, status) {
809
+ const res = await fetch('/api/e2e/review-all', {
810
+ method: 'POST',
811
+ headers: { 'Content-Type': 'application/json' },
812
+ body: JSON.stringify({ featureKey, status }),
813
+ });
814
+ if (res.ok) { e2ePlan = (await res.json()).plan; renderContent(); }
892
815
  }
893
816
 
894
- function restoreSessions() {
895
- try {
896
- const raw = localStorage.getItem(SESSIONS_KEY);
897
- if (!raw) return;
898
- const saved = JSON.parse(raw);
899
- consoleSessions.push(...saved);
900
- for (const s of consoleSessions) {
901
- if (s.status === 'running') {
902
- s.status = 'error';
903
- s.lines.push({ text: '⚡ Прервано (перезагрузка страницы)', isError: true });
904
- }
905
- }
906
- if (consoleSessions.length > 0) {
907
- activeSessionId = consoleSessions[consoleSessions.length - 1].id;
908
- renderTabs();
909
- renderActiveSession();
910
- }
911
- } catch {}
817
+ async function writeE2eTests(featureKey) {
818
+ if (agentRunning) { appendTerminalLine('Agent already running', true); return; }
819
+ await fetch('/api/e2e/write-tests', {
820
+ method: 'POST',
821
+ headers: { 'Content-Type': 'application/json' },
822
+ body: JSON.stringify({ featureKey }),
823
+ });
912
824
  }
913
825
 
914
- function createSession(title, status = 'running') {
915
- if (consoleSessions.length >= SESSION_MAX) consoleSessions.shift();
916
- const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now() };
917
- consoleSessions.push(s);
918
- activeSessionId = s.id;
919
- document.getElementById('agentPanel').classList.add('open');
920
- document.getElementById('termBtn').classList.add('term-active');
921
- renderTabs();
922
- renderActiveSession();
923
- saveSessions();
924
- return s.id;
826
+ async function runE2eTests(featureKey) {
827
+ await fetch('/api/e2e/run-tests', {
828
+ method: 'POST',
829
+ headers: { 'Content-Type': 'application/json' },
830
+ body: JSON.stringify({ featureKey }),
831
+ });
925
832
  }
926
833
 
927
- function switchSession(id) {
928
- activeSessionId = id;
929
- const s = consoleSessions.find(s => s.id === id);
930
- if (s) {
931
- const statusText = s.status === 'running' ? 'работает…'
932
- : s.status === 'ok' ? '✅ готово'
933
- : s.status === 'error' ? ' ошибка'
934
- : '';
935
- document.getElementById('agentPanelTitle').textContent = s.title;
936
- document.getElementById('agentPanelStatus').textContent = statusText;
937
- }
938
- renderTabs();
939
- renderActiveSession();
834
+ function openScreenshot(src) {
835
+ const modal = document.createElement('div');
836
+ modal.className = 'e2e-screenshot-modal';
837
+ modal.innerHTML = '<img src="' + src + '" />';
838
+ modal.onclick = () => modal.remove();
839
+ document.addEventListener('keydown', function handler(e) {
840
+ if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', handler); }
841
+ });
842
+ document.body.appendChild(modal);
940
843
  }
941
844
 
942
- function closeSession(id) {
943
- const idx = consoleSessions.findIndex(s => s.id === id);
944
- if (idx === -1) return;
945
- consoleSessions.splice(idx, 1);
946
- if (activeSessionId === id) {
947
- activeSessionId = consoleSessions.length > 0
948
- ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
949
- : null;
950
- }
951
- renderTabs();
952
- renderActiveSession();
953
- saveSessions();
845
+ function statusBadge(status) {
846
+ const colors = {
847
+ pending: 'var(--muted)', approved: 'var(--green)', rejected: 'var(--red)',
848
+ written: 'var(--blue)', passed: 'var(--green)', failed: 'var(--red)',
849
+ };
850
+ const icons = {
851
+ pending: '⏳', approved: '✅', rejected: '❌',
852
+ written: '✍', passed: '✅', failed: '❌',
853
+ };
854
+ return '<span class="e2e-case-status" style="color:' + (colors[status] || 'var(--muted)') + '">'
855
+ + (icons[status] || '') + ' ' + status + '</span>';
954
856
  }
955
857
 
956
- function appendToSession(id, lineOrNode, isError = false, isDim = false) {
957
- const s = consoleSessions.find(s => s.id === id);
958
- if (!s) return;
959
- let stored;
960
- if (typeof lineOrNode === 'string') {
961
- stored = { text: lineOrNode, isError, isDim };
962
- } else {
963
- stored = { html: lineOrNode.outerHTML };
858
+ function renderE2eTestCase(tc, featureKey) {
859
+ const screenshotHtml = (tc.screenshotPaths && tc.screenshotPaths.length > 0)
860
+ ? '<div class="e2e-screenshot-strip">' +
861
+ tc.screenshotPaths.map(p =>
862
+ '<img class="e2e-screenshot" src="/api/e2e/screenshot/' + encodeURI(p) + '" onclick="event.stopPropagation();openScreenshot(this.src)" />'
863
+ ).join('') + '</div>'
864
+ : '';
865
+
866
+ const errorHtml = tc.lastError
867
+ ? '<div class="e2e-error-msg">' + tc.lastError.replace(/</g, '&lt;').slice(0, 500) + '</div>'
868
+ : '';
869
+
870
+ const canReview = tc.status === 'pending' || tc.status === 'approved' || tc.status === 'rejected';
871
+ const actionsHtml = canReview
872
+ ? '<div class="e2e-case-actions">' +
873
+ (tc.status !== 'approved'
874
+ ? '<button class="e2e-btn e2e-btn-approve" onclick="event.stopPropagation();reviewE2eCase(\'' + featureKey + '\',\'' + tc.id + '\',\'approved\')">✅ Approve</button>'
875
+ : '') +
876
+ (tc.status !== 'rejected'
877
+ ? '<button class="e2e-btn e2e-btn-reject" onclick="event.stopPropagation();reviewE2eCase(\'' + featureKey + '\',\'' + tc.id + '\',\'rejected\')">❌ Reject</button>'
878
+ : '') +
879
+ '</div>'
880
+ : '';
881
+
882
+ return '<div class="e2e-case ' + tc.status + '">' +
883
+ '<div class="e2e-case-header">' +
884
+ '<span class="e2e-case-id">' + tc.id + '</span>' +
885
+ '<span class="e2e-case-title">' + tc.name + '</span>' +
886
+ statusBadge(tc.status) +
887
+ '</div>' +
888
+ '<div class="e2e-case-desc">' + tc.description + '</div>' +
889
+ '<div style="font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:600">Steps:</div>' +
890
+ '<ol class="e2e-case-steps">' + tc.steps.map(s => '<li>' + s + '</li>').join('') + '</ol>' +
891
+ '<div style="font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:600">Expected:</div>' +
892
+ '<ul class="e2e-case-expected">' + tc.expectedResults.map(r => '<li>' + r + '</li>').join('') + '</ul>' +
893
+ screenshotHtml +
894
+ errorHtml +
895
+ actionsHtml +
896
+ '</div>';
897
+ }
898
+
899
+ function renderE2ePlanView(c, featureKey) {
900
+ const feat = (D.features || []).find(f => f.key === featureKey);
901
+ if (!feat) return;
902
+
903
+ if (e2ePlanLoading) {
904
+ c.innerHTML =
905
+ '<div class="drill-header">' +
906
+ '<button class="back-btn" onclick="drillTestType=null;renderContent()">← ' + feat.label + '</button>' +
907
+ '<div class="drill-title"><span>🎭</span><span>E2E План</span></div>' +
908
+ '</div>' +
909
+ '<div style="padding:40px;text-align:center;color:var(--muted)">' +
910
+ '<div style="font-size:28px;margin-bottom:12px">⏳</div>' +
911
+ 'AI-агент генерирует план E2E тестов…' +
912
+ '</div>';
913
+ return;
964
914
  }
965
- s.lines.push(stored);
966
- if (activeSessionId === id) {
967
- const term = document.getElementById('agentTerminal');
968
- const el = document.createElement('div');
969
- if (stored.html) {
970
- el.innerHTML = stored.html;
971
- } else {
972
- el.className = 'agent-line' + (isError ? ' err' : isDim ? ' dim' : '');
973
- el.textContent = lineOrNode;
974
- }
975
- term.appendChild(el);
976
- term.scrollTop = term.scrollHeight;
915
+
916
+ if (!e2ePlan) {
917
+ c.innerHTML =
918
+ '<div class="drill-header">' +
919
+ '<button class="back-btn" onclick="drillTestType=null;renderContent()">← ' + feat.label + '</button>' +
920
+ '<div class="drill-title"><span>🎭</span><span>E2E План</span></div>' +
921
+ '</div>' +
922
+ '<div style="padding:40px;text-align:center;border:1px dashed var(--border);border-radius:8px">' +
923
+ '<div style="font-size:32px;margin-bottom:12px">🎭</div>' +
924
+ '<div style="font-size:15px;color:var(--text);margin-bottom:6px">E2E план ещё не создан</div>' +
925
+ '<div style="font-size:12px;color:var(--dim);margin-bottom:16px">' +
926
+ 'AI-агент проанализирует фичу и создаст план тестирования' +
927
+ '</div>' +
928
+ (!D.hasPlaywright ? '<div style="font-size:12px;color:var(--yellow);margin-bottom:16px">⚠️ @playwright/test не найден в package.json — установи перед запуском тестов</div>' : '') +
929
+ '<button class="e2e-btn e2e-btn-primary" style="padding:10px 24px;font-size:13px;border-radius:8px" ' +
930
+ 'onclick="generateE2ePlan(\'' + featureKey + '\')"' +
931
+ (!D.agent ? ' disabled title="Сначала выбери AI агента"' : '') + '>' +
932
+ '▶ Сгенерировать план' +
933
+ '</button>' +
934
+ '</div>';
935
+ return;
977
936
  }
978
- }
979
937
 
980
- function updateSessionStatus(id, status) {
981
- const s = consoleSessions.find(s => s.id === id);
982
- if (!s) return;
983
- s.status = status;
984
- renderTabs();
985
- saveSessions();
938
+ const cases = e2ePlan.testCases || [];
939
+ const counts = { pending: 0, approved: 0, rejected: 0, written: 0, passed: 0, failed: 0 };
940
+ cases.forEach(tc => { counts[tc.status] = (counts[tc.status] || 0) + 1; });
941
+ const hasApproved = counts.approved > 0;
942
+ const hasWritten = counts.written + counts.passed + counts.failed > 0;
943
+
944
+ c.innerHTML =
945
+ '<div class="drill-header">' +
946
+ '<button class="back-btn" onclick="drillTestType=null;renderContent()">← ' + feat.label + '</button>' +
947
+ '<div class="drill-title"><span>🎭</span><span>E2E План</span></div>' +
948
+ '<div class="e2e-stats">' +
949
+ '<span>' + cases.length + ' кейсов</span>' +
950
+ (counts.approved > 0 ? '<span style="color:var(--green)">' + counts.approved + ' одобрено</span>' : '') +
951
+ (counts.written > 0 ? '<span style="color:var(--blue)">' + counts.written + ' написано</span>' : '') +
952
+ (counts.passed > 0 ? '<span style="color:var(--green)">' + counts.passed + ' passed</span>' : '') +
953
+ (counts.failed > 0 ? '<span style="color:var(--red)">' + counts.failed + ' failed</span>' : '') +
954
+ '</div>' +
955
+ '</div>' +
956
+ '<div class="e2e-batch-bar">' +
957
+ '<button class="e2e-btn e2e-btn-approve" onclick="reviewAllE2e(\'' + featureKey + '\',\'approved\')">✅ Approve All</button>' +
958
+ '<button class="e2e-btn e2e-btn-reject" onclick="reviewAllE2e(\'' + featureKey + '\',\'rejected\')">❌ Reject All</button>' +
959
+ (hasApproved && D.agent
960
+ ? '<button class="e2e-btn e2e-btn-primary" onclick="writeE2eTests(\'' + featureKey + '\')">✍ Написать тесты (' + counts.approved + ')</button>'
961
+ : '') +
962
+ (hasWritten
963
+ ? '<button class="e2e-btn e2e-btn-primary" style="background:var(--green)" onclick="runE2eTests(\'' + featureKey + '\')">▶ Запустить E2E</button>'
964
+ : '') +
965
+ '<button class="e2e-btn e2e-btn-secondary" onclick="generateE2ePlan(\'' + featureKey + '\')">🔄 Перегенерировать</button>' +
966
+ '</div>' +
967
+ '<div class="e2e-plan-container">' +
968
+ cases.map(tc => renderE2eTestCase(tc, featureKey)).join('') +
969
+ '</div>';
986
970
  }
987
971
 
988
- function renderTabs() {
989
- const bar = document.getElementById('agentTabsBar');
990
- if (!bar) return;
991
- bar.innerHTML = consoleSessions.map(s => {
992
- const isActive = s.id === activeSessionId;
993
- const safe = s.title.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
994
- return `<div class="agent-tab${isActive ? ' active' : ''}"
995
- onclick="switchSession('${s.id}')"
996
- onmousedown="if(event.button===1){event.preventDefault();event.stopPropagation();closeSession('${s.id}')}">
997
- <span class="tab-dot tab-dot-${s.status}"></span>
998
- <span class="agent-tab-title" title="${safe}">${safe}</span>
999
- <button class="agent-tab-close" onclick="event.stopPropagation();closeSession('${s.id}')" title="Закрыть вкладку">×</button>
1000
- </div>`;
1001
- }).join('');
1002
- const active = bar.querySelector('.agent-tab.active');
1003
- if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
972
+ // ─── Coverage button ───────────────────────────────────────────────────────────
973
+ function updateCovBtn() {
974
+ const btn = document.getElementById('covBtn');
975
+ if (!btn) return;
976
+ btn.className = '';
977
+ btn.disabled = coverageRunning;
978
+ if (coverageRunning) {
979
+ btn.className = 'cov-running';
980
+ btn.textContent = '⏳ Running...';
981
+ } else if (coverageHasError) {
982
+ btn.className = 'cov-error';
983
+ btn.textContent = ' Coverage failed';
984
+ } else {
985
+ btn.textContent = '🧪 Coverage';
986
+ }
1004
987
  }
1005
988
 
1006
- function copyTerminalContent() {
1007
- const s = consoleSessions.find(s => s.id === activeSessionId);
1008
- if (!s || s.lines.length === 0) return;
1009
- // Extract plain text from each line (strip HTML for rich nodes)
1010
- const text = s.lines.map(l => {
1011
- if (l.text !== undefined) return l.text;
1012
- if (l.html) {
1013
- const tmp = document.createElement('div');
1014
- tmp.innerHTML = l.html;
1015
- return tmp.innerText;
1016
- }
1017
- return '';
1018
- }).filter(Boolean).join('\n');
1019
- navigator.clipboard.writeText(text).then(() => {
1020
- const btn = document.getElementById('agentCopyBtn');
1021
- if (!btn) return;
1022
- const prev = btn.textContent;
1023
- btn.textContent = '✓';
1024
- btn.classList.add('copied');
1025
- setTimeout(() => { btn.textContent = prev; btn.classList.remove('copied'); }, 1500);
1026
- });
989
+ async function runCoverage() {
990
+ if (coverageRunning) return;
991
+ await fetch('/api/run-coverage', { method: 'POST' });
992
+ // SSE events will update state
1027
993
  }
1028
994
 
1029
- function renderActiveSession() {
1030
- const term = document.getElementById('agentTerminal');
1031
- if (!term) return;
1032
- term.innerHTML = '';
1033
- const s = consoleSessions.find(s => s.id === activeSessionId);
1034
- if (!s) return;
1035
- for (const ln of s.lines) {
1036
- const el = document.createElement('div');
1037
- if (ln.html) {
1038
- el.innerHTML = ln.html;
1039
- } else {
1040
- el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1041
- el.textContent = ln.text;
1042
- }
1043
- term.appendChild(el);
1044
- }
1045
- term.scrollTop = term.scrollHeight;
1046
- }
995
+ // ─── Agent ────────────────────────────────────────────────────────────────────
996
+ let agentRunning = false;
1047
997
 
1048
998
  async function setAgent(agent) {
1049
999
  await fetch('/api/set-agent', {
@@ -1054,182 +1004,30 @@ async function setAgent(agent) {
1054
1004
  // scheduleRescan will fire → data-updated → D.agent updates
1055
1005
  }
1056
1006
 
1057
- async function setModel(model) {
1058
- await fetch('/api/set-model', {
1059
- method: 'POST',
1060
- headers: { 'Content-Type': 'application/json' },
1061
- body: JSON.stringify({ model }),
1062
- });
1063
- // scheduleRescan will fire → data-updated → D.model updates
1064
- }
1065
-
1066
1007
  function setAgentRunning(val) {
1067
1008
  agentRunning = val;
1068
1009
  document.getElementById('agentCancelBtn').style.display = val ? 'inline-block' : 'none';
1069
1010
  }
1070
1011
 
1071
- // Returns array of normalized relative paths affected by a given task
1072
- function getTaskFilePaths(task, featureKey, filePath) {
1073
- if ((task === 'write-tests-file' || task === 'fix-tests') && filePath) {
1074
- return [filePath.replace(/\\/g, '/')];
1075
- }
1076
- if ((task === 'write-tests' || task === 'fix-tests-all') && featureKey && D?.modules) {
1077
- return D.modules
1078
- .filter(m => m.featureKeys?.includes(featureKey) && m.type !== 'test' && (!m.hasTests || m.testStale))
1079
- .map(m => m.relativePath.replace(/\\/g, '/'));
1080
- }
1081
- return [];
1082
- }
1083
-
1084
- // Returns 'running', 'queued', or null for a given relative path
1085
- function isFileAgentActive(relPath) {
1086
- const n = relPath.replace(/\\/g, '/');
1087
- if (agentRunningPaths.has(n)) return 'running';
1088
- if (agentQueuedPaths.has(n)) return 'queued';
1089
- return null;
1090
- }
1091
-
1092
- function updateQueueBadge(n) {
1093
- document.getElementById('agentQueueCount').textContent = n;
1094
- document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1095
- document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1096
- }
1097
-
1098
1012
  async function cancelAgent() {
1099
1013
  await fetch('/api/cancel-agent', { method: 'POST' });
1100
1014
  setAgentRunning(false);
1101
- updateQueueBadge(0);
1102
1015
  document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1103
- if (runningSessionId) {
1104
- appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1105
- updateSessionStatus(runningSessionId, 'error');
1106
- runningSessionId = null;
1107
- } else {
1108
- appendTerminalLine('⏹ Состояние агента сброшено (очередь очищена)', false);
1109
- }
1110
- }
1111
-
1112
- async function clearAgentQueue() {
1113
- await fetch('/api/clear-queue', { method: 'POST' });
1114
- updateQueueBadge(0);
1115
- appendTerminalLine('🗑 Очередь очищена', false);
1116
- }
1117
-
1118
- // ─── File row more menu ──────────────────────────────────────────────────────
1119
- let _openFileMenu = null;
1120
-
1121
- function toggleFileMenu(btn, featureKey, relPath) {
1122
- const dropdown = btn.nextElementSibling;
1123
- const isOpen = dropdown.classList.contains('open');
1124
- // Close any open menu first
1125
- if (_openFileMenu && _openFileMenu !== dropdown) {
1126
- _openFileMenu.classList.remove('open');
1127
- }
1128
- dropdown.classList.toggle('open', !isOpen);
1129
- _openFileMenu = isOpen ? null : dropdown;
1130
- }
1131
-
1132
- // Close menu on outside click
1133
- document.addEventListener('click', () => {
1134
- if (_openFileMenu) { _openFileMenu.classList.remove('open'); _openFileMenu = null; }
1135
- });
1136
-
1137
- async function copyPromptForFile(featureKey, relPath, dropdown) {
1138
- if (dropdown) { dropdown.classList.remove('open'); _openFileMenu = null; }
1139
- try {
1140
- const res = await fetch('/api/get-prompt', {
1141
- method: 'POST',
1142
- headers: { 'Content-Type': 'application/json' },
1143
- body: JSON.stringify({ task: 'write-tests-file', featureKey, filePath: relPath }),
1144
- });
1145
- const { prompt, error } = await res.json();
1146
- if (error || !prompt) { alert('Не удалось получить промпт: ' + (error || 'пустой')); return; }
1147
- await navigator.clipboard.writeText(prompt);
1148
- // Brief visual feedback in terminal
1149
- document.getElementById('agentPanel').classList.add('open');
1150
- document.getElementById('termBtn').classList.add('term-active');
1151
- appendTerminalLine(`📋 Промпт для "${relPath.split('/').pop()}" скопирован в буфер обмена`, false);
1152
- appendTerminalLine(' Вставь его в другой агент (Claude, Codex, ChatGPT) и запусти вручную.', false);
1153
- } catch (e) {
1154
- alert('Ошибка копирования: ' + e.message);
1155
- }
1156
- }
1157
-
1158
- // ─── Agent menu ─────────────────────────────────────────────────────────────
1159
- const CLAUDE_MODELS = [
1160
- { id: 'claude-sonnet-4-6', checkId: 'amSonnet46Check' },
1161
- { id: 'claude-opus-4-5', checkId: 'amOpus45Check' },
1162
- { id: 'claude-haiku-3-5', checkId: 'amHaiku35Check' },
1163
- ];
1164
-
1165
- function updateAgentBtn() {
1166
- const btn = document.getElementById('agentBtn');
1167
- if (!D) return;
1168
- const a = D.agent;
1169
- const m = D.model;
1170
- // Button label: agent + model shortname
1171
- let label = a === 'claude' ? '🤖 Claude' : a === 'codex' ? '🤖 Codex' : '🤖 не выбран';
1172
- if (a === 'claude' && m) {
1173
- // Show short model name: "sonnet-4-6", "opus-4-5", "haiku-3-5"
1174
- const short = m.replace('claude-', '');
1175
- label += ` · ${short}`;
1176
- }
1177
- btn.textContent = label;
1178
- // Update agent checkmarks
1179
- document.getElementById('amClaudeCheck').classList.toggle('active', a === 'claude');
1180
- document.getElementById('amCodexCheck').classList.toggle('active', a === 'codex');
1181
- // Show/hide model section (only for claude)
1182
- const isClaude = a === 'claude';
1183
- document.getElementById('amModelDivider').style.display = isClaude ? '' : 'none';
1184
- document.getElementById('amModelLabel').style.display = isClaude ? '' : 'none';
1185
- CLAUDE_MODELS.forEach(({ id, checkId }) => {
1186
- const el = document.getElementById(id);
1187
- if (el) el.style.display = isClaude ? '' : 'none';
1188
- document.getElementById(checkId).classList.toggle('active', m === id);
1189
- });
1190
- }
1191
-
1192
- function toggleAgentMenu() {
1193
- const menu = document.getElementById('agentMenu');
1194
- const isOpen = menu.classList.contains('open');
1195
- menu.classList.toggle('open');
1196
- if (!isOpen) {
1197
- // Close on click outside
1198
- setTimeout(() => {
1199
- document.addEventListener('click', closeAgentMenuOnOutside, { once: true });
1200
- }, 0);
1201
- }
1202
- }
1203
- function closeAgentMenu() {
1204
- document.getElementById('agentMenu').classList.remove('open');
1205
- }
1206
- function closeAgentMenuOnOutside(e) {
1207
- if (!e.target.closest('#agentMenu') && !e.target.closest('#agentBtn')) {
1208
- closeAgentMenu();
1209
- }
1016
+ appendTerminalLine('⏹ Состояние агента сброшено', false);
1210
1017
  }
1211
1018
 
1212
- async function reauthAgent() {
1213
- const agent = D?.agent;
1214
- if (!agent) {
1215
- appendTerminalLine('⚠ Сначала выбери агента (Claude или Codex)', true);
1019
+ async function runAgentTask(task, featureKey, filePath) {
1020
+ if (agentRunning) {
1021
+ // Show panel so user sees cancel button
1216
1022
  document.getElementById('agentPanel').classList.add('open');
1217
1023
  document.getElementById('termBtn').classList.add('term-active');
1024
+ appendTerminalLine('⚠️ Агент уже запущен. Нажми ⏹ сброс чтобы отменить.', true);
1218
1025
  return;
1219
1026
  }
1220
- const id = createSession('🔑 Перелогинивание');
1221
- runningSessionId = id;
1222
- document.getElementById('agentPanelTitle').textContent = '🔑 Перелогинивание';
1223
- document.getElementById('agentPanelStatus').textContent = 'выполняю…';
1224
- await fetch('/api/agent-reauth', { method: 'POST' });
1225
- }
1226
-
1227
- async function runAgentTask(task, featureKey, filePath) {
1027
+ document.getElementById('agentTerminal').innerHTML = '';
1028
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1228
1029
  document.getElementById('agentPanel').classList.add('open');
1229
1030
  document.getElementById('termBtn').classList.add('term-active');
1230
- if (!agentRunning) {
1231
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1232
- }
1233
1031
  await fetch('/api/run-agent', {
1234
1032
  method: 'POST',
1235
1033
  headers: { 'Content-Type': 'application/json' },
@@ -1238,9 +1036,11 @@ async function runAgentTask(task, featureKey, filePath) {
1238
1036
  }
1239
1037
 
1240
1038
  async function runTests(featureKey, testType) {
1039
+ document.getElementById('agentTerminal').innerHTML = '';
1040
+ document.getElementById('agentPanelTitle').textContent = `🧪 ${testType} тесты`;
1041
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1241
1042
  document.getElementById('agentPanel').classList.add('open');
1242
1043
  document.getElementById('termBtn').classList.add('term-active');
1243
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1244
1044
  await fetch('/api/run-tests', {
1245
1045
  method: 'POST',
1246
1046
  headers: { 'Content-Type': 'application/json' },
@@ -1261,11 +1061,12 @@ function toggleAgentPanel() {
1261
1061
  }
1262
1062
 
1263
1063
  function appendTerminalLine(line, isError, isDim) {
1264
- if (!activeSessionId) {
1265
- const id = createSession('Консоль', 'info');
1266
- activeSessionId = id;
1267
- }
1268
- appendToSession(activeSessionId, line, !!isError, !!isDim);
1064
+ const term = document.getElementById('agentTerminal');
1065
+ const el = document.createElement('div');
1066
+ el.className = 'agent-line' + (isError ? ' err' : isDim ? ' dim' : '');
1067
+ el.textContent = line;
1068
+ term.appendChild(el);
1069
+ term.scrollTop = term.scrollHeight;
1269
1070
  }
1270
1071
 
1271
1072
  // ─── Color helpers ────────────────────────────────────────────────────────────
@@ -1312,7 +1113,6 @@ async function init() {
1312
1113
  coverageHasError = status.coverageError ?? false;
1313
1114
  }
1314
1115
  updateCovBtn();
1315
- updateAgentBtn();
1316
1116
 
1317
1117
  document.getElementById('projectName').textContent = D.projectName;
1318
1118
  document.getElementById('scannedAt').textContent =
@@ -1567,29 +1367,29 @@ function renderFeatureCards(c) {
1567
1367
  function testTypeCard(type, label, icon, color, count, active, featureKey, failedCount = 0) {
1568
1368
  const empty = count === 0 && type !== 'source';
1569
1369
  const hasFailed = failedCount > 0 && type !== 'source';
1570
- const subLabel = type === 'source' ? 'файлов' : (empty ? 'нет тестов' : pluralFiles(count));
1571
- const accentColor = hasFailed ? 'var(--red)' : color;
1572
- const countColor = hasFailed ? 'var(--red)' : (active || !empty ? color : 'var(--dim)');
1573
-
1370
+ const hasE2ePlan = type === 'e2e' && D.e2ePlansExist && D.e2ePlansExist.includes(featureKey);
1371
+ const subLabel = empty
1372
+ ? (type === 'e2e' && hasE2ePlan ? '📋 план создан' : 'нет тестов')
1373
+ : (type === 'source' ? 'код приложения' : pluralFiles(count));
1574
1374
  const runBtn = !empty && type !== 'source' && featureKey
1575
- ? `<button class="tt-run-btn" onclick="event.stopPropagation();runTests('${featureKey}','${type}')">▶ запустить</button>`
1576
- : '';
1577
- const fixAllBtn = hasFailed && D.agent && featureKey
1578
- ? `<button class="tt-run-btn tt-fix-btn" onclick="event.stopPropagation();runAgentTask('fix-tests-all','${featureKey}','${type}')">🔧 починить</button>`
1375
+ ? `<button class="tt-run-btn" onclick="event.stopPropagation();${type === 'e2e' ? "runE2eTests('" + featureKey + "')" : "runTests('" + featureKey + "','" + type + "')"}">▶ запустить</button>`
1579
1376
  : '';
1580
- const failedBadge = hasFailed
1581
- ? `<span style="font-size:10px;color:var(--red);font-weight:700;white-space:nowrap">❌ ${failedCount}</span>`
1377
+ const accentColor = hasFailed && !active ? 'var(--red)' : color;
1378
+ const countColor = hasFailed ? 'var(--red)' : (active || !empty ? color : 'var(--dim)');
1379
+ const failedNote = hasFailed
1380
+ ? `<div style="font-size:11px;color:var(--red);margin-top:2px;font-weight:600">❌ ${failedCount} упало</div>`
1582
1381
  : '';
1583
- const actions = (runBtn || fixAllBtn) ? `<div class="tt-chip-actions">${runBtn}${fixAllBtn}</div>` : '';
1584
-
1382
+ // E2E cards should always be clickable (to see/create plan)
1383
+ const forceClickable = type === 'e2e';
1585
1384
  return `
1586
- <div class="test-type-card${empty ? ' tt-empty' : ''}${active ? ' tt-active' : ''}${hasFailed ? ' tt-failed' : ''}"
1587
- data-testtype="${type}" style="border-left-color:${accentColor}${active ? ';border-color:' + color : ''}">
1588
- <span class="tt-label">${icon} ${label}</span>
1589
- <span class="tt-count" style="color:${countColor}">${count}</span>
1590
- <span class="tt-sub">${subLabel}</span>
1591
- ${failedBadge}
1592
- ${actions}
1385
+ <div class="test-type-card${empty && !forceClickable ? ' tt-empty' : ''}${active ? ' tt-active' : ''}${hasFailed ? ' tt-failed' : ''}"
1386
+ data-testtype="${type}" style="${active ? 'border-color:' + color : ''}${forceClickable && empty ? ';cursor:pointer;opacity:1' : ''}">
1387
+ <div class="tt-accent" style="background:${accentColor}"></div>
1388
+ <div class="tt-label">${icon} ${label}</div>
1389
+ <div class="tt-count" style="color:${countColor}">${count}</div>
1390
+ <div class="tt-sub">${subLabel}</div>
1391
+ ${failedNote}
1392
+ ${runBtn}
1593
1393
  </div>`;
1594
1394
  }
1595
1395
 
@@ -1597,6 +1397,12 @@ function renderFeatureDetail(c) {
1597
1397
  const feat = D.features.find(f => f.key === drillFeatureKey);
1598
1398
  if (!feat) { backToFeatures(); return; }
1599
1399
 
1400
+ // E2E plan view override
1401
+ if (drillTestType === 'e2e') {
1402
+ renderE2ePlanView(c, drillFeatureKey);
1403
+ return;
1404
+ }
1405
+
1600
1406
  const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
1601
1407
  const src = mods.filter(m => m.type !== 'test');
1602
1408
  const tst = mods.filter(m => m.type === 'test');
@@ -1664,11 +1470,14 @@ function renderFeatureDetail(c) {
1664
1470
  </div>`;
1665
1471
 
1666
1472
  c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
1667
- card.onclick = () => {
1473
+ card.onclick = async () => {
1668
1474
  const type = card.dataset.testtype;
1669
1475
  drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
1670
1476
  activePanelKey = null;
1671
1477
  document.getElementById('panel').classList.remove('open');
1478
+ if (type === 'e2e') {
1479
+ await loadE2ePlan(drillFeatureKey);
1480
+ }
1672
1481
  renderContent();
1673
1482
  };
1674
1483
  });
@@ -1823,16 +1632,12 @@ function fileRow(m, isTest = false, featureKey = null) {
1823
1632
  const dir = parts.slice(0, -1).join('/');
1824
1633
 
1825
1634
  // Test errors from last run
1826
- const testErr = isTest && D.testErrors && D.testErrors[relPath];
1827
- const agentState = !isTest ? isFileAgentActive(relPath) : null;
1828
- const icon = agentState
1829
- ? `<span class="file-agent-spinner ${agentState}" title="${agentState === 'running' ? 'Агент работает…' : 'В очереди…'}"></span>`
1830
- : (testErr ? '❌' : isTest ? '🧪' : (m.testStale ? '⚠️' : m.hasTests ? '✅' : '⬜'));
1635
+ const testErr = isTest && D.testErrors && D.testErrors[relPath];
1636
+ const icon = testErr ? '❌' : isTest ? '🧪' : (m.testStale ? '⚠️' : m.hasTests ? '✅' : '⬜');
1831
1637
  const isActive = activePanelKey === m.id;
1832
1638
 
1833
1639
  // Write-test button for source files
1834
- // In feature drill-down (featureKey set), show button even for isInfra files user explicitly added them to the feature
1835
- const showAgentBtn = D.agent && !isTest && featureKey && (!m.hasTests || m.testStale);
1640
+ const showAgentBtn = D.agent && !isTest && !m.isInfra && featureKey && (!m.hasTests || m.testStale);
1836
1641
  const agentBtnLabel = m.testStale ? '↻ обновить тест' : '✍ написать тест';
1837
1642
  const agentBtnTitle = m.testStale ? 'Файл изменился — обновить тесты' : 'Написать тест для этого файла';
1838
1643
  const agentBtn = showAgentBtn
@@ -1863,20 +1668,6 @@ function fileRow(m, isTest = false, featureKey = null) {
1863
1668
  </div>`
1864
1669
  : '';
1865
1670
 
1866
- // "⋯" more menu — always show for source files in feature context
1867
- const moreBtn = !isTest && featureKey
1868
- ? `<div class="file-row-more-wrap">
1869
- <button class="file-row-more-btn" title="Ещё"
1870
- onclick="event.stopPropagation();toggleFileMenu(this,'${featureKey}','${relPath}')">⋯</button>
1871
- <div class="file-row-more-dropdown">
1872
- <button class="file-row-more-item"
1873
- onclick="event.stopPropagation();copyPromptForFile('${featureKey}','${relPath}',this.closest('.file-row-more-dropdown'))">
1874
- 📋 Скопировать промпт для агента
1875
- </button>
1876
- </div>
1877
- </div>`
1878
- : '';
1879
-
1880
1671
  return `
1881
1672
  <div class="file-row${isActive ? ' active' : ''}${testErr ? ' has-errors' : ''}" data-id="${m.id}">
1882
1673
  <span class="file-row-icon">${icon}</span>
@@ -1884,7 +1675,6 @@ function fileRow(m, isTest = false, featureKey = null) {
1884
1675
  ${errBadge}
1885
1676
  ${agentBtn}
1886
1677
  ${fixBtn}
1887
- ${moreBtn}
1888
1678
  <span class="file-row-dir">${dir}</span>
1889
1679
  </div>
1890
1680
  ${errHtml}`;
@@ -1980,11 +1770,7 @@ function fileItem(m, isTest = false) {
1980
1770
  const parts = m.relativePath.replace(/\\/g, '/').split('/');
1981
1771
  const name = parts[parts.length - 1];
1982
1772
  const dir = parts.slice(0, -1).join('/');
1983
- const relPathNorm = m.relativePath.replace(/\\/g, '/');
1984
- const agentStateItem = !isTest ? isFileAgentActive(relPathNorm) : null;
1985
- const icon = agentStateItem
1986
- ? `<span class="file-agent-spinner ${agentStateItem}" title="${agentStateItem === 'running' ? 'Агент работает…' : 'В очереди…'}"></span>`
1987
- : (isTest ? '🧪' : (m.hasTests ? '✅' : '⬜'));
1773
+ const icon = isTest ? '🧪' : (m.hasTests ? '' : '⬜');
1988
1774
  return `
1989
1775
  <div class="file-item">
1990
1776
  <span class="file-item-icon">${icon}</span>
@@ -2182,7 +1968,6 @@ async function refreshData() {
2182
1968
  renderStats();
2183
1969
  renderSidebar();
2184
1970
  renderContent();
2185
- updateAgentBtn();
2186
1971
 
2187
1972
  // Re-render drill-down or re-open panel
2188
1973
  const panelOpen = document.getElementById('panel').classList.contains('open');
@@ -2236,69 +2021,31 @@ function connectSSE() {
2236
2021
  setTimeout(() => { coverageHasError = false; updateCovBtn(); }, 8000);
2237
2022
  });
2238
2023
 
2239
- es.addEventListener('agent-queued', (e) => {
2240
- const { queueLength, title, task, featureKey, filePath } = JSON.parse(e.data);
2241
- updateQueueBadge(queueLength);
2242
- document.getElementById('agentPanel').classList.add('open');
2243
- document.getElementById('termBtn').classList.add('term-active');
2244
- // Track queued paths for spinner
2245
- getTaskFilePaths(task, featureKey, filePath).forEach(p => agentQueuedPaths.add(p));
2246
- renderContent();
2247
- // Append queue notification to the currently running session (or active)
2248
- const targetId = runningSessionId || activeSessionId;
2249
- if (targetId) {
2250
- appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
2251
- }
2252
- });
2253
-
2254
2024
  es.addEventListener('agent-started', (e) => {
2255
2025
  setAgentRunning(true);
2256
- const { title, queueLength = 0, task, featureKey, filePath } = JSON.parse(e.data);
2257
- updateQueueBadge(queueLength);
2258
- // Move paths from queued → running (current task)
2259
- const startedPaths = getTaskFilePaths(task, featureKey, filePath);
2260
- startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
2261
- renderContent();
2262
- // Close previous session (queue case: agent-done not fired between tasks)
2263
- if (runningSessionId) {
2264
- const prev = consoleSessions.find(s => s.id === runningSessionId);
2265
- if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
2266
- }
2267
- const id = createSession(title);
2268
- runningSessionId = id;
2026
+ const { title } = JSON.parse(e.data);
2269
2027
  document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
2270
2028
  document.getElementById('agentPanelStatus').textContent = 'запускаю…';
2029
+ document.getElementById('agentPanel').classList.add('open');
2030
+ document.getElementById('termBtn').classList.add('term-active');
2031
+ document.getElementById('agentTerminal').innerHTML = '';
2271
2032
  });
2272
2033
 
2273
2034
  es.addEventListener('agent-output', (e) => {
2274
2035
  const { line, isError, isDim } = JSON.parse(e.data);
2275
- if (runningSessionId) {
2276
- appendToSession(runningSessionId, line, !!isError, !!isDim);
2277
- if (activeSessionId === runningSessionId) {
2278
- document.getElementById('agentPanelStatus').textContent = 'работает…';
2279
- }
2280
- } else {
2281
- appendTerminalLine(line, !!isError, !!isDim);
2282
- }
2036
+ appendTerminalLine(line, !!isError, !!isDim);
2037
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
2283
2038
  });
2284
2039
 
2285
2040
  es.addEventListener('agent-done', () => {
2286
2041
  setAgentRunning(false);
2287
- updateQueueBadge(0);
2288
- agentRunningPaths.clear();
2289
- agentQueuedPaths.clear();
2290
- if (runningSessionId) {
2291
- updateSessionStatus(runningSessionId, 'ok');
2292
- if (activeSessionId === runningSessionId) {
2293
- document.getElementById('agentPanelStatus').textContent = '✅ готово';
2294
- }
2295
- runningSessionId = null;
2296
- }
2042
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
2297
2043
  renderContent();
2298
2044
  });
2299
2045
 
2300
2046
  es.addEventListener('agent-summary', (e) => {
2301
2047
  const { passed, failed, files } = JSON.parse(e.data);
2048
+ const term = document.getElementById('agentTerminal');
2302
2049
  const allOk = failed === 0;
2303
2050
  const box = document.createElement('div');
2304
2051
  box.style.cssText = `
@@ -2315,82 +2062,66 @@ function connectSSE() {
2315
2062
  </div>
2316
2063
  ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
2317
2064
  `;
2318
- const targetId = runningSessionId || activeSessionId;
2319
- if (targetId) {
2320
- appendToSession(targetId, box);
2321
- // Mark session status from test results immediately
2322
- if (runningSessionId === targetId) {
2323
- updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
2324
- }
2325
- }
2065
+ term.appendChild(box);
2066
+ term.scrollTop = term.scrollHeight;
2326
2067
  });
2327
2068
 
2328
2069
  es.addEventListener('agent-error', (e) => {
2329
2070
  setAgentRunning(false);
2330
- agentRunningPaths.clear();
2331
- agentQueuedPaths.clear();
2332
- renderContent();
2333
- const { message, authRequired, notInstalled } = JSON.parse(e.data);
2334
-
2335
- // Build error node (plain text or auth/install box)
2336
- let node = null;
2337
- if (authRequired || notInstalled) {
2338
- node = document.createElement('div');
2339
- node.style.cssText = 'margin:10px 0 4px;padding:10px 14px;border-radius:8px;border:1px solid var(--red);background:#2a0d0d;font-family:inherit;';
2340
- node.innerHTML = `
2341
- <div style="font-size:13px;font-weight:700;color:var(--red)">❌ ${message || 'Ошибка агента'}</div>
2342
- ${authRequired ? `<button onclick="reauthAgent()" style="margin-top:8px;padding:4px 12px;font-size:12px;background:none;border:1px solid var(--yellow);color:var(--yellow);border-radius:4px;cursor:pointer;">🔑 Перелогиниться</button>` : ''}
2343
- ${notInstalled ? `<div style="margin-top:6px;font-size:11px;color:var(--dim)">После установки перезапусти viberadar</div>` : ''}
2344
- `;
2345
- }
2346
-
2347
- // If no session exists yet (startup check fires before any run), create one
2348
- const targetId = runningSessionId || (() => {
2349
- if (authRequired || notInstalled) {
2350
- const id = createSession('⚠️ Проверка агента', 'error');
2351
- return id;
2352
- }
2353
- return activeSessionId;
2354
- })();
2355
-
2356
- if (targetId) {
2357
- if (node) {
2358
- appendToSession(targetId, node);
2359
- } else {
2360
- appendToSession(targetId, '❌ ' + (message || 'Ошибка агента'), true);
2361
- }
2362
- updateSessionStatus(targetId, 'error');
2363
- if (activeSessionId === targetId) {
2364
- document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
2365
- }
2366
- }
2367
- if (runningSessionId) runningSessionId = null;
2071
+ const { message } = JSON.parse(e.data);
2072
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
2073
+ appendTerminalLine('❌ ' + (message || 'Ошибка агента'), true);
2368
2074
  });
2369
2075
 
2370
2076
  es.addEventListener('tests-started', (e) => {
2371
2077
  const { testType, count } = JSON.parse(e.data);
2372
- const id = createSession(`🧪 ${testType}`);
2373
- runningSessionId = id;
2374
- appendToSession(id, `Запускаю ${testType} тесты (${count} файлов)…`);
2375
2078
  document.getElementById('agentPanelTitle').textContent = `🧪 ${testType} тесты`;
2376
2079
  document.getElementById('agentPanelStatus').textContent = `запускаю ${count} файлов…`;
2377
2080
  });
2378
2081
 
2379
2082
  es.addEventListener('tests-done', (e) => {
2380
2083
  const { passed, failed, testErrors } = JSON.parse(e.data);
2381
- const status = failed === 0 ? 'ok' : 'error';
2382
- if (runningSessionId) {
2383
- updateSessionStatus(runningSessionId, status);
2384
- if (activeSessionId === runningSessionId) {
2385
- document.getElementById('agentPanelStatus').textContent =
2386
- failed === 0 ? `✅ ${passed} passed` : `⚠️ ${passed} passed, ${failed} failed`;
2387
- }
2388
- runningSessionId = null;
2389
- }
2084
+ document.getElementById('agentPanelStatus').textContent =
2085
+ failed === 0 ? `✅ ${passed} passed` : `⚠️ ${passed} passed, ${failed} failed`;
2086
+ // Apply testErrors directly from event — no separate fetch needed
2390
2087
  D.testErrors = testErrors || {};
2391
2088
  renderContent();
2392
2089
  });
2393
2090
 
2091
+ // ── E2E plan events ──────────────────────────────────────────────────────
2092
+ es.addEventListener('e2e-plan-generating', () => {
2093
+ e2ePlanLoading = true;
2094
+ renderContent();
2095
+ });
2096
+
2097
+ es.addEventListener('e2e-plan-ready', (e) => {
2098
+ const data = JSON.parse(e.data);
2099
+ e2ePlanLoading = false;
2100
+ if (drillFeatureKey === data.featureKey) {
2101
+ e2ePlan = data.plan;
2102
+ renderContent();
2103
+ }
2104
+ });
2105
+
2106
+ es.addEventListener('e2e-plan-error', (e) => {
2107
+ e2ePlanLoading = false;
2108
+ const data = JSON.parse(e.data);
2109
+ if (drillFeatureKey === data.featureKey) {
2110
+ e2ePlan = null;
2111
+ renderContent();
2112
+ }
2113
+ });
2114
+
2115
+ es.addEventListener('e2e-tests-done', (e) => {
2116
+ const data = JSON.parse(e.data);
2117
+ if (drillFeatureKey === data.featureKey) {
2118
+ e2ePlan = data.plan;
2119
+ renderContent();
2120
+ }
2121
+ document.getElementById('agentPanelStatus').textContent =
2122
+ data.failed === 0 ? `✅ E2E: ${data.passed} passed` : `⚠️ E2E: ${data.passed}/${data.failed}`;
2123
+ });
2124
+
2394
2125
  es.onerror = () => {
2395
2126
  setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
2396
2127
  es.close();
@@ -2398,15 +2129,7 @@ function connectSSE() {
2398
2129
  };
2399
2130
  }
2400
2131
 
2401
- init().then(() => {
2402
- restoreSessions();
2403
- connectSSE();
2404
- // Sync content padding with terminal panel open state automatically
2405
- new MutationObserver(() => {
2406
- const isOpen = document.getElementById('agentPanel').classList.contains('open');
2407
- document.getElementById('content').classList.toggle('panel-open', isOpen);
2408
- }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
2409
- });
2132
+ init().then(() => connectSSE());
2410
2133
  </script>
2411
2134
  </body>
2412
2135
  </html>