|
451 | 451 | <div id="refreshBanner" class="refresh-banner hidden"> |
452 | 452 | <span class="banner-icon">ℹ</span> |
453 | 453 | <span class="banner-msg"> |
454 | | - New execution detected: <strong id="newRunTitle"></strong> |
| 454 | + New execution started: <strong id="newRunTitle"></strong> |
455 | 455 | </span> |
456 | | - <button class="btn-reload" onclick="window.location.reload()">Reload page</button> |
457 | | - <button class="btn-dismiss" onclick="dismissBanner()">Dismiss</button> |
| 456 | + <button class="btn-reload" onclick="switchToNewExecution()">Switch to new execution</button> |
| 457 | + <button class="btn-dismiss" onclick="dismissBanner()">Stay here</button> |
458 | 458 | </div> |
459 | 459 |
|
460 | 460 | <!-- ── Main area ───────────────────────────────────────────────────── --> |
461 | 461 | <div class="main-content"> |
462 | 462 |
|
463 | 463 | <!-- Tree sidebar --> |
464 | 464 | <div class="tree-panel"> |
465 | | - <div class="tree-panel-header">🗎 Execution Tree</div> |
| 465 | + <div class="tree-panel-header">☰ Execution Tree</div> |
466 | 466 | <div class="tree-empty" id="treeEmpty"> |
467 | 467 | <div> |
468 | 468 | <div style="font-size:28px;margin-bottom:8px;">⏳</div> |
|
529 | 529 | let wasDisconnected = false; |
530 | 530 | let currentRunTitle = '{test_run_title}'; // initial title injected by server |
531 | 531 |
|
| 532 | + // When true, all incoming SSE events from a new execution are ignored so the |
| 533 | + // current page content (old execution) stays untouched until the user chooses. |
| 534 | + let newExecutionPending = false; |
| 535 | + |
| 536 | + // Interval handle for /api/status polling after a clean stream end. |
| 537 | + let statusPollInterval = null; |
| 538 | + |
532 | 539 | // Maps stepId → log-entry index at the moment the step became 'executing'. |
533 | | - // Used by scrollToStep() to jump without server-side stream markers. |
534 | 540 | const stepLogIndex = {{}}; |
535 | 541 |
|
536 | 542 | // Maps step title → ordered list of stepIds built from the tree snapshot. |
537 | | - // Used to resolve "Executing Test Step: <title>" log entries to their DOM anchor. |
538 | 543 | let stepTitleToIds = {{}}; |
539 | 544 |
|
540 | 545 | const BATCH_INTERVAL_MS = 50; |
541 | 546 | const MAX_BATCH_SIZE = 50; |
542 | 547 |
|
| 548 | + // ── /api/status polling ────────────────────────────────────────── |
| 549 | + // Used after a clean stream end so we never auto-connect a new SSE session |
| 550 | + // (which would overwrite the page before the user has seen the banner). |
| 551 | + function startStatusPolling() {{ |
| 552 | + if (statusPollInterval) return; |
| 553 | + const indicator = document.getElementById('statusIndicator'); |
| 554 | + const statusTxt = document.getElementById('statusText'); |
| 555 | + statusTxt.textContent = 'Stream Ended — watching for next execution…'; |
| 556 | + statusPollInterval = setInterval(() => {{ |
| 557 | + fetch('/api/status') |
| 558 | + .then(r => r.json()) |
| 559 | + .then(s => {{ |
| 560 | + if (s.run_title && s.run_title !== currentRunTitle) {{ |
| 561 | + stopStatusPolling(); |
| 562 | + newExecutionPending = true; |
| 563 | + showRefreshBanner(s.run_title); |
| 564 | + }} |
| 565 | + }}) |
| 566 | + .catch(() => {{}}); // server not up yet — keep polling |
| 567 | + }}, 3000); |
| 568 | + }} |
| 569 | + |
| 570 | + function stopStatusPolling() {{ |
| 571 | + if (statusPollInterval) {{ |
| 572 | + clearInterval(statusPollInterval); |
| 573 | + statusPollInterval = null; |
| 574 | + }} |
| 575 | + }} |
| 576 | + |
543 | 577 | // ── SSE connection ─────────────────────────────────────────────── |
544 | 578 | function connectToLogStream() {{ |
545 | 579 | const indicator = document.getElementById('statusIndicator'); |
|
552 | 586 | statusTxt.textContent = 'Connected'; |
553 | 587 |
|
554 | 588 | if (wasDisconnected) {{ |
555 | | - // Check whether a new execution has started. |
| 589 | + // Preemptively block incoming events until the status check resolves. |
| 590 | + // This prevents tree_init / log events from rewriting the page in the |
| 591 | + // microseconds before the async fetch completes. |
| 592 | + newExecutionPending = true; |
556 | 593 | fetch('/api/status') |
557 | 594 | .then(r => r.json()) |
558 | 595 | .then(s => {{ |
559 | 596 | if (s.run_title && s.run_title !== currentRunTitle) {{ |
560 | 597 | showRefreshBanner(s.run_title); |
| 598 | + // newExecutionPending stays true — banner is the gate |
| 599 | + }} else {{ |
| 600 | + // Same execution reconnected (e.g. network hiccup) — resume normally |
| 601 | + newExecutionPending = false; |
561 | 602 | }} |
562 | 603 | }}) |
563 | | - .catch(() => {{}}); |
| 604 | + .catch(() => {{ newExecutionPending = false; }}); |
564 | 605 | }} |
565 | 606 | wasDisconnected = false; |
566 | 607 | }}); |
567 | 608 |
|
568 | 609 | eventSource.addEventListener('log', function(e) {{ |
| 610 | + if (newExecutionPending) return; |
569 | 611 | logBatchQueue.push(JSON.parse(e.data)); |
570 | 612 | scheduleBatchProcessing(); |
571 | 613 | }}); |
572 | 614 |
|
573 | 615 | eventSource.addEventListener('tree_init', function(e) {{ |
| 616 | + if (newExecutionPending) return; |
574 | 617 | renderTree(JSON.parse(e.data)); |
575 | 618 | }}); |
576 | 619 |
|
577 | 620 | eventSource.addEventListener('tree_update', function(e) {{ |
| 621 | + if (newExecutionPending) return; |
578 | 622 | const update = JSON.parse(e.data); |
579 | | - // Record the current log-entry count when a step starts executing. |
580 | | - // scrollToStep() uses this index to find the approximate position in the |
581 | | - // DOM without needing server-injected markers in the log stream. |
582 | 623 | if (update.node_type === 'step' && update.state === 'executing') {{ |
583 | 624 | const stepId = `step-${{update.suite_idx}}-${{update.case_idx}}-${{update.step_idx}}`; |
584 | 625 | stepLogIndex[stepId] = logCount; |
|
595 | 636 | }}); |
596 | 637 |
|
597 | 638 | eventSource.addEventListener('end', function(e) {{ |
| 639 | + // Execution finished cleanly. Close the SSE connection and switch to |
| 640 | + // polling so we never auto-flow new-execution data into the current page. |
598 | 641 | indicator.className = 'status-indicator disconnected'; |
599 | | - statusTxt.textContent = 'Stream Ended'; |
600 | 642 | eventSource.close(); |
601 | 643 | processBatchImmediately(); |
| 644 | + wasDisconnected = true; |
| 645 | + startStatusPolling(); |
602 | 646 | }}); |
603 | 647 |
|
604 | 648 | eventSource.onerror = function(e) {{ |
| 649 | + // Unexpected connection drop (e.g. network hiccup mid-execution). |
| 650 | + // Reconnect normally; the connected handler will verify the title. |
605 | 651 | indicator.className = 'status-indicator disconnected'; |
606 | 652 | statusTxt.textContent = 'Disconnected'; |
607 | 653 | wasDisconnected = true; |
|
622 | 668 | document.getElementById('refreshBanner').classList.remove('hidden'); |
623 | 669 | }} |
624 | 670 |
|
| 671 | + function switchToNewExecution() {{ |
| 672 | + window.location.reload(); |
| 673 | + }} |
| 674 | + |
625 | 675 | function dismissBanner() {{ |
| 676 | + // User wants to stay on the current execution's data. |
| 677 | + // Stop polling and close any open SSE connection so nothing overwrites the page. |
| 678 | + stopStatusPolling(); |
| 679 | + newExecutionPending = false; |
| 680 | + if (eventSource && eventSource.readyState !== EventSource.CLOSED) {{ |
| 681 | + eventSource.close(); |
| 682 | + }} |
626 | 683 | document.getElementById('refreshBanner').classList.add('hidden'); |
| 684 | + document.getElementById('statusIndicator').className = 'status-indicator disconnected'; |
| 685 | + document.getElementById('statusText').textContent = 'Stream Ended'; |
627 | 686 | }} |
628 | 687 |
|
629 | 688 | // ── Tree rendering ─────────────────────────────────────────────── |
|
728 | 787 |
|
729 | 788 | // Toggle arrow (for collapsible nodes) |
730 | 789 | const toggle = document.createElement('span'); |
731 | | - toggle.className = 'tree-toggle'; |
732 | | - toggle.textContent = hasChildren ? '▶' : ''; // ▶ |
| 790 | + toggle.className = hasChildren ? 'tree-toggle expanded' : 'tree-toggle'; |
| 791 | + toggle.textContent = hasChildren ? '▶' : ''; |
733 | 792 | if (!hasChildren) toggle.style.visibility = 'hidden'; |
734 | 793 |
|
735 | 794 | // State icon |
|
800 | 859 | // Highlight executing node, remove from others at the same level |
801 | 860 | if (state === 'executing') {{ |
802 | 861 | el.classList.add('executing'); |
803 | | - // Scroll the tree so this node is visible |
804 | | - el.scrollIntoView({{ block: 'nearest', behavior: 'smooth' }}); |
805 | 862 | }} else {{ |
806 | 863 | el.classList.remove('executing'); |
807 | 864 | }} |
|
0 commit comments