ElapsedTime.test.tsx
1 /** 2 * Tests for ElapsedTime component and time formatting. 3 * 4 * Covers: 5 * - Elapsed time formatting 6 * - Timer behavior for running/queued sessions 7 * - Timer stopping for completed/failed sessions 8 * - Timer updates every 2 seconds 9 */ 10 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 11 import { render, screen, act } from '@testing-library/react'; 12 import { SessionListTab, SessionBadges } from '../../src/web_terminal_client/src/components/SessionListTab'; 13 import type { SessionResponse } from '../../src/web_terminal_client/src/types'; 14 15 // Helper to create mock session 16 function createMockSession(overrides: Partial<SessionResponse> = {}): SessionResponse { 17 const now = new Date(); 18 return { 19 id: 'test_session_' + Date.now(), 20 status: 'complete', 21 task: 'Test task', 22 model: 'claude-sonnet-4', 23 created_at: now.toISOString(), 24 updated_at: now.toISOString(), 25 completed_at: now.toISOString(), 26 num_turns: 5, 27 duration_ms: 10000, 28 total_cost_usd: 0.05, 29 cancel_requested: false, 30 resumable: true, 31 queue_position: null, 32 queued_at: null, 33 is_auto_resume: false, 34 ...overrides, 35 }; 36 } 37 38 function createEmptyBadges(): SessionBadges { 39 return { 40 completed: new Set<string>(), 41 failed: new Set<string>(), 42 waiting: new Set<string>(), 43 }; 44 } 45 46 describe('Elapsed Time Display', () => { 47 const mockOnSelectSession = vi.fn(); 48 const mockOnClearBadge = vi.fn(); 49 50 beforeEach(() => { 51 vi.useFakeTimers(); 52 vi.clearAllMocks(); 53 }); 54 55 afterEach(() => { 56 vi.useRealTimers(); 57 }); 58 59 describe('Time Formatting', () => { 60 it('displays seconds for short elapsed times', () => { 61 // Session created 30 seconds ago 62 const createdAt = new Date(Date.now() - 30 * 1000); 63 const session = createMockSession({ 64 id: 'short_elapsed', 65 status: 'complete', 66 created_at: createdAt.toISOString(), 67 updated_at: createdAt.toISOString(), 68 completed_at: createdAt.toISOString(), 69 }); 70 71 render( 72 <SessionListTab 73 sessions={[session]} 74 currentSessionId={null} 75 onSelectSession={mockOnSelectSession} 76 badges={createEmptyBadges()} 77 onClearBadge={mockOnClearBadge} 78 /> 79 ); 80 81 // Should show something like "0s" since created_at == completed_at 82 const elapsedElement = document.querySelector('.session-elapsed-time'); 83 expect(elapsedElement).toBeInTheDocument(); 84 expect(elapsedElement?.textContent).toMatch(/^\d+s$/); 85 }); 86 87 it('displays minutes and seconds for medium elapsed times', () => { 88 // Session that ran for 5 minutes 30 seconds 89 const createdAt = new Date(Date.now() - 6 * 60 * 1000); 90 const completedAt = new Date(Date.now() - 30 * 1000); 91 const session = createMockSession({ 92 id: 'medium_elapsed', 93 status: 'complete', 94 created_at: createdAt.toISOString(), 95 updated_at: completedAt.toISOString(), 96 completed_at: completedAt.toISOString(), 97 duration_ms: 5 * 60 * 1000 + 30 * 1000, // 5 minutes 30 seconds 98 }); 99 100 render( 101 <SessionListTab 102 sessions={[session]} 103 currentSessionId={null} 104 onSelectSession={mockOnSelectSession} 105 badges={createEmptyBadges()} 106 onClearBadge={mockOnClearBadge} 107 /> 108 ); 109 110 const elapsedElement = document.querySelector('.session-elapsed-time'); 111 expect(elapsedElement).toBeInTheDocument(); 112 // Should match pattern like "5m 30s" 113 expect(elapsedElement?.textContent).toMatch(/^\d+m \d+s$/); 114 }); 115 116 it('displays hours, minutes and seconds for long elapsed times', () => { 117 // Session that ran for 2 hours 15 minutes 30 seconds 118 const createdAt = new Date(Date.now() - (2 * 3600 + 15 * 60 + 30) * 1000); 119 const completedAt = new Date(); 120 const session = createMockSession({ 121 id: 'long_elapsed', 122 status: 'complete', 123 created_at: createdAt.toISOString(), 124 updated_at: completedAt.toISOString(), 125 completed_at: completedAt.toISOString(), 126 duration_ms: (2 * 3600 + 15 * 60 + 30) * 1000, // 2 hours 15 minutes 30 seconds 127 }); 128 129 render( 130 <SessionListTab 131 sessions={[session]} 132 currentSessionId={null} 133 onSelectSession={mockOnSelectSession} 134 badges={createEmptyBadges()} 135 onClearBadge={mockOnClearBadge} 136 /> 137 ); 138 139 const elapsedElement = document.querySelector('.session-elapsed-time'); 140 expect(elapsedElement).toBeInTheDocument(); 141 // Should match pattern like "2h 15m 30s" 142 expect(elapsedElement?.textContent).toMatch(/^\d+h \d+m \d+s$/); 143 }); 144 }); 145 146 describe('Timer Behavior for Running Sessions', () => { 147 it('shows active class for running session', () => { 148 const createdAt = new Date(Date.now() - 60 * 1000); // Started 1 minute ago 149 const session = createMockSession({ 150 id: 'running_session', 151 status: 'running', 152 created_at: createdAt.toISOString(), 153 updated_at: createdAt.toISOString(), 154 completed_at: null, 155 }); 156 157 render( 158 <SessionListTab 159 sessions={[session]} 160 currentSessionId={null} 161 onSelectSession={mockOnSelectSession} 162 badges={createEmptyBadges()} 163 onClearBadge={mockOnClearBadge} 164 /> 165 ); 166 167 const elapsedElement = document.querySelector('.session-elapsed-time.active'); 168 expect(elapsedElement).toBeInTheDocument(); 169 }); 170 171 it('updates elapsed time every 2 seconds for running session', () => { 172 const createdAt = new Date(Date.now() - 60 * 1000); // Started 1 minute ago 173 const session = createMockSession({ 174 id: 'updating_session', 175 status: 'running', 176 created_at: createdAt.toISOString(), 177 updated_at: createdAt.toISOString(), 178 completed_at: null, 179 }); 180 181 render( 182 <SessionListTab 183 sessions={[session]} 184 currentSessionId={null} 185 onSelectSession={mockOnSelectSession} 186 badges={createEmptyBadges()} 187 onClearBadge={mockOnClearBadge} 188 /> 189 ); 190 191 const elapsedElement = document.querySelector('.session-elapsed-time'); 192 const initialText = elapsedElement?.textContent; 193 194 // Advance time by 4 seconds (should trigger 2 updates) 195 act(() => { 196 vi.advanceTimersByTime(4000); 197 }); 198 199 // The time should have increased 200 const updatedText = elapsedElement?.textContent; 201 // We cannot guarantee exact values due to timing, but we can verify it changed 202 // or stayed the same pattern (both are valid behaviors) 203 expect(updatedText).toMatch(/^\d+[hms]/); 204 }); 205 206 it('uses currentRunStartTime for current running session', () => { 207 // Session was created 2 days ago, but current run started 30 seconds ago 208 const createdAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2 days ago 209 const runStartTime = new Date(Date.now() - 30 * 1000); // 30 seconds ago 210 const session = createMockSession({ 211 id: 'current_running_session', 212 status: 'running', 213 created_at: createdAt.toISOString(), 214 updated_at: createdAt.toISOString(), // Old updated_at 215 completed_at: null, 216 }); 217 218 render( 219 <SessionListTab 220 sessions={[session]} 221 currentSessionId="current_running_session" 222 onSelectSession={mockOnSelectSession} 223 badges={createEmptyBadges()} 224 onClearBadge={mockOnClearBadge} 225 currentRunStartTime={runStartTime.toISOString()} 226 /> 227 ); 228 229 const elapsedElement = document.querySelector('.session-elapsed-time'); 230 expect(elapsedElement).toBeInTheDocument(); 231 // Should show ~30 seconds (from currentRunStartTime), NOT 2 days (from created_at) 232 expect(elapsedElement?.textContent).toMatch(/^\d+s$/); 233 }); 234 235 it('uses updated_at for non-current running sessions', () => { 236 // Session was created 2 days ago, but updated_at is recent (simulating run start) 237 const createdAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2 days ago 238 const updatedAt = new Date(Date.now() - 45 * 1000); // 45 seconds ago (when run started) 239 const session = createMockSession({ 240 id: 'other_running_session', 241 status: 'running', 242 created_at: createdAt.toISOString(), 243 updated_at: updatedAt.toISOString(), 244 completed_at: null, 245 }); 246 247 render( 248 <SessionListTab 249 sessions={[session]} 250 currentSessionId={null} // Not the current session 251 onSelectSession={mockOnSelectSession} 252 badges={createEmptyBadges()} 253 onClearBadge={mockOnClearBadge} 254 /> 255 ); 256 257 const elapsedElement = document.querySelector('.session-elapsed-time'); 258 expect(elapsedElement).toBeInTheDocument(); 259 // Should show ~45 seconds (from updated_at), NOT 2 days (from created_at) 260 expect(elapsedElement?.textContent).toMatch(/^\d+s$/); 261 }); 262 }); 263 264 describe('Timer Behavior for Queued Sessions', () => { 265 it('shows elapsed time from queued_at for queued sessions', () => { 266 const queuedAt = new Date(Date.now() - 30 * 1000); // Queued 30 seconds ago 267 const session = createMockSession({ 268 id: 'queued_session', 269 status: 'queued', 270 created_at: new Date(Date.now() - 60 * 1000).toISOString(), // Created 1 minute ago 271 queued_at: queuedAt.toISOString(), 272 updated_at: queuedAt.toISOString(), 273 completed_at: null, 274 queue_position: 2, 275 }); 276 277 render( 278 <SessionListTab 279 sessions={[session]} 280 currentSessionId={null} 281 onSelectSession={mockOnSelectSession} 282 badges={createEmptyBadges()} 283 onClearBadge={mockOnClearBadge} 284 /> 285 ); 286 287 const elapsedElement = document.querySelector('.session-elapsed-time.active'); 288 expect(elapsedElement).toBeInTheDocument(); 289 // Should show time since queued (around 30s), not time since created (1m) 290 expect(elapsedElement?.textContent).toMatch(/^\d+s$/); 291 }); 292 293 it('shows queue position for queued sessions', () => { 294 const session = createMockSession({ 295 id: 'queued_with_position', 296 status: 'queued', 297 completed_at: null, 298 queue_position: 5, 299 queued_at: new Date().toISOString(), 300 }); 301 302 render( 303 <SessionListTab 304 sessions={[session]} 305 currentSessionId={null} 306 onSelectSession={mockOnSelectSession} 307 badges={createEmptyBadges()} 308 onClearBadge={mockOnClearBadge} 309 /> 310 ); 311 312 expect(screen.getByText('#5')).toBeInTheDocument(); 313 }); 314 }); 315 316 describe('Timer Stops for Completed Sessions', () => { 317 it('does not show active class for completed session', () => { 318 const session = createMockSession({ 319 id: 'completed_session', 320 status: 'complete', 321 }); 322 323 render( 324 <SessionListTab 325 sessions={[session]} 326 currentSessionId={null} 327 onSelectSession={mockOnSelectSession} 328 badges={createEmptyBadges()} 329 onClearBadge={mockOnClearBadge} 330 /> 331 ); 332 333 const activeElement = document.querySelector('.session-elapsed-time.active'); 334 expect(activeElement).not.toBeInTheDocument(); 335 }); 336 337 it('shows run duration for completed session using duration_ms', () => { 338 const createdAt = new Date(Date.now() - 5 * 60 * 1000); // Created 5 minutes ago 339 const completedAt = new Date(Date.now() - 2 * 60 * 1000); // Completed 2 minutes ago 340 const session = createMockSession({ 341 id: 'finished_session', 342 status: 'complete', 343 created_at: createdAt.toISOString(), 344 updated_at: completedAt.toISOString(), 345 completed_at: completedAt.toISOString(), 346 duration_ms: 180000, // 3 minutes - the actual run duration 347 }); 348 349 render( 350 <SessionListTab 351 sessions={[session]} 352 currentSessionId={null} 353 onSelectSession={mockOnSelectSession} 354 badges={createEmptyBadges()} 355 onClearBadge={mockOnClearBadge} 356 /> 357 ); 358 359 const elapsedElement = document.querySelector('.session-elapsed-time'); 360 expect(elapsedElement).toBeInTheDocument(); 361 // Should show duration_ms (3 minutes) 362 expect(elapsedElement?.textContent).toBe('3m 0s'); 363 }); 364 365 it('does not update elapsed time for completed session', () => { 366 const createdAt = new Date(Date.now() - 5 * 60 * 1000); 367 const completedAt = new Date(Date.now() - 2 * 60 * 1000); 368 const session = createMockSession({ 369 id: 'static_session', 370 status: 'complete', 371 created_at: createdAt.toISOString(), 372 updated_at: completedAt.toISOString(), 373 completed_at: completedAt.toISOString(), 374 }); 375 376 render( 377 <SessionListTab 378 sessions={[session]} 379 currentSessionId={null} 380 onSelectSession={mockOnSelectSession} 381 badges={createEmptyBadges()} 382 onClearBadge={mockOnClearBadge} 383 /> 384 ); 385 386 const elapsedElement = document.querySelector('.session-elapsed-time'); 387 const initialText = elapsedElement?.textContent; 388 389 // Advance time significantly 390 act(() => { 391 vi.advanceTimersByTime(10000); 392 }); 393 394 // Time should not have changed 395 expect(elapsedElement?.textContent).toBe(initialText); 396 }); 397 }); 398 399 describe('Timer Stops for Failed Sessions', () => { 400 it('does not show active class for failed session', () => { 401 const session = createMockSession({ 402 id: 'failed_session', 403 status: 'failed', 404 }); 405 406 render( 407 <SessionListTab 408 sessions={[session]} 409 currentSessionId={null} 410 onSelectSession={mockOnSelectSession} 411 badges={createEmptyBadges()} 412 onClearBadge={mockOnClearBadge} 413 /> 414 ); 415 416 const activeElement = document.querySelector('.session-elapsed-time.active'); 417 expect(activeElement).not.toBeInTheDocument(); 418 }); 419 420 it('shows run duration at point of failure using duration_ms', () => { 421 const createdAt = new Date(Date.now() - 10 * 60 * 1000); // Created 10 minutes ago 422 const failedAt = new Date(Date.now() - 5 * 60 * 1000); // Failed 5 minutes ago 423 const session = createMockSession({ 424 id: 'failed_session', 425 status: 'failed', 426 created_at: createdAt.toISOString(), 427 updated_at: failedAt.toISOString(), 428 completed_at: failedAt.toISOString(), 429 duration_ms: 300000, // 5 minutes - the actual run duration before failure 430 }); 431 432 render( 433 <SessionListTab 434 sessions={[session]} 435 currentSessionId={null} 436 onSelectSession={mockOnSelectSession} 437 badges={createEmptyBadges()} 438 onClearBadge={mockOnClearBadge} 439 /> 440 ); 441 442 const elapsedElement = document.querySelector('.session-elapsed-time'); 443 expect(elapsedElement).toBeInTheDocument(); 444 // Should show duration_ms (5 minutes) 445 expect(elapsedElement?.textContent).toBe('5m 0s'); 446 }); 447 }); 448 449 describe('Timer Stops for Cancelled Sessions', () => { 450 it('does not show active class for cancelled session', () => { 451 const session = createMockSession({ 452 id: 'cancelled_session', 453 status: 'cancelled', 454 }); 455 456 render( 457 <SessionListTab 458 sessions={[session]} 459 currentSessionId={null} 460 onSelectSession={mockOnSelectSession} 461 badges={createEmptyBadges()} 462 onClearBadge={mockOnClearBadge} 463 /> 464 ); 465 466 const activeElement = document.querySelector('.session-elapsed-time.active'); 467 expect(activeElement).not.toBeInTheDocument(); 468 }); 469 }); 470 471 describe('Timer Stops for Partial Sessions', () => { 472 it('does not show active class for partial session', () => { 473 const session = createMockSession({ 474 id: 'partial_session', 475 status: 'partial', 476 }); 477 478 render( 479 <SessionListTab 480 sessions={[session]} 481 currentSessionId={null} 482 onSelectSession={mockOnSelectSession} 483 badges={createEmptyBadges()} 484 onClearBadge={mockOnClearBadge} 485 /> 486 ); 487 488 const activeElement = document.querySelector('.session-elapsed-time.active'); 489 expect(activeElement).not.toBeInTheDocument(); 490 }); 491 492 it('shows run duration for partial session using duration_ms', () => { 493 const session = createMockSession({ 494 id: 'partial_session', 495 status: 'partial', 496 duration_ms: 120000, // 2 minutes 497 }); 498 499 render( 500 <SessionListTab 501 sessions={[session]} 502 currentSessionId={null} 503 onSelectSession={mockOnSelectSession} 504 badges={createEmptyBadges()} 505 onClearBadge={mockOnClearBadge} 506 /> 507 ); 508 509 const elapsedElement = document.querySelector('.session-elapsed-time'); 510 expect(elapsedElement).toBeInTheDocument(); 511 // Should show duration_ms (2 minutes) 512 expect(elapsedElement?.textContent).toBe('2m 0s'); 513 }); 514 515 it('shows half-circle icon for partial status', () => { 516 const session = createMockSession({ 517 id: 'partial_icon_session', 518 status: 'partial', 519 }); 520 521 render( 522 <SessionListTab 523 sessions={[session]} 524 currentSessionId={null} 525 onSelectSession={mockOnSelectSession} 526 badges={createEmptyBadges()} 527 onClearBadge={mockOnClearBadge} 528 /> 529 ); 530 531 const statusIcon = document.querySelector('.status-partial'); 532 expect(statusIcon).toBeInTheDocument(); 533 }); 534 }); 535 536 describe('Status Transitions', () => { 537 it('stops timer when session transitions from running to complete', () => { 538 const createdAt = new Date(Date.now() - 60 * 1000); 539 const runningSession = createMockSession({ 540 id: 'transition_session', 541 status: 'running', 542 created_at: createdAt.toISOString(), 543 updated_at: createdAt.toISOString(), 544 completed_at: null, 545 }); 546 547 const { rerender } = render( 548 <SessionListTab 549 sessions={[runningSession]} 550 currentSessionId={null} 551 onSelectSession={mockOnSelectSession} 552 badges={createEmptyBadges()} 553 onClearBadge={mockOnClearBadge} 554 /> 555 ); 556 557 // Verify timer is active 558 let activeElement = document.querySelector('.session-elapsed-time.active'); 559 expect(activeElement).toBeInTheDocument(); 560 561 // Transition to complete 562 const completedAt = new Date(); 563 const completedSession = createMockSession({ 564 id: 'transition_session', 565 status: 'complete', 566 created_at: createdAt.toISOString(), 567 updated_at: completedAt.toISOString(), 568 completed_at: completedAt.toISOString(), 569 }); 570 571 rerender( 572 <SessionListTab 573 sessions={[completedSession]} 574 currentSessionId={null} 575 onSelectSession={mockOnSelectSession} 576 badges={createEmptyBadges()} 577 onClearBadge={mockOnClearBadge} 578 /> 579 ); 580 581 // Verify timer is no longer active 582 activeElement = document.querySelector('.session-elapsed-time.active'); 583 expect(activeElement).not.toBeInTheDocument(); 584 }); 585 586 it('stops timer when session transitions from running to failed', () => { 587 const createdAt = new Date(Date.now() - 60 * 1000); 588 const runningSession = createMockSession({ 589 id: 'fail_transition_session', 590 status: 'running', 591 created_at: createdAt.toISOString(), 592 updated_at: createdAt.toISOString(), 593 completed_at: null, 594 }); 595 596 const { rerender } = render( 597 <SessionListTab 598 sessions={[runningSession]} 599 currentSessionId={null} 600 onSelectSession={mockOnSelectSession} 601 badges={createEmptyBadges()} 602 onClearBadge={mockOnClearBadge} 603 /> 604 ); 605 606 // Verify timer is active 607 let activeElement = document.querySelector('.session-elapsed-time.active'); 608 expect(activeElement).toBeInTheDocument(); 609 610 // Transition to failed 611 const failedAt = new Date(); 612 const failedSession = createMockSession({ 613 id: 'fail_transition_session', 614 status: 'failed', 615 created_at: createdAt.toISOString(), 616 updated_at: failedAt.toISOString(), 617 completed_at: failedAt.toISOString(), 618 }); 619 620 rerender( 621 <SessionListTab 622 sessions={[failedSession]} 623 currentSessionId={null} 624 onSelectSession={mockOnSelectSession} 625 badges={createEmptyBadges()} 626 onClearBadge={mockOnClearBadge} 627 /> 628 ); 629 630 // Verify timer is no longer active 631 activeElement = document.querySelector('.session-elapsed-time.active'); 632 expect(activeElement).not.toBeInTheDocument(); 633 }); 634 635 it('starts timer when session transitions from queued to running', () => { 636 const createdAt = new Date(Date.now() - 120 * 1000); 637 const queuedAt = new Date(Date.now() - 60 * 1000); 638 const queuedSession = createMockSession({ 639 id: 'queue_to_run_session', 640 status: 'queued', 641 created_at: createdAt.toISOString(), 642 queued_at: queuedAt.toISOString(), 643 updated_at: queuedAt.toISOString(), 644 completed_at: null, 645 queue_position: 1, 646 }); 647 648 const { rerender } = render( 649 <SessionListTab 650 sessions={[queuedSession]} 651 currentSessionId={null} 652 onSelectSession={mockOnSelectSession} 653 badges={createEmptyBadges()} 654 onClearBadge={mockOnClearBadge} 655 /> 656 ); 657 658 // Verify timer is active (queued also has active timer) 659 let activeElement = document.querySelector('.session-elapsed-time.active'); 660 expect(activeElement).toBeInTheDocument(); 661 662 // Transition to running 663 const runningSession = createMockSession({ 664 id: 'queue_to_run_session', 665 status: 'running', 666 created_at: createdAt.toISOString(), 667 queued_at: null, 668 updated_at: new Date().toISOString(), 669 completed_at: null, 670 queue_position: null, 671 }); 672 673 rerender( 674 <SessionListTab 675 sessions={[runningSession]} 676 currentSessionId={null} 677 onSelectSession={mockOnSelectSession} 678 badges={createEmptyBadges()} 679 onClearBadge={mockOnClearBadge} 680 /> 681 ); 682 683 // Verify timer is still active 684 activeElement = document.querySelector('.session-elapsed-time.active'); 685 expect(activeElement).toBeInTheDocument(); 686 }); 687 }); 688 });