/ tests / web_terminal_console / ElapsedTime.test.tsx
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  });