// @vitest-environment jsdom import { act, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' // The splash captures `codeburn` at import; mock it so a test can drive the // progress callback the splash subscribes with. let progressCb: ((event: unknown) => void) | undefined vi.mock('../lib/ipc', () => ({ codeburn: { onProgress: (cb: (event: unknown) => void) => { progressCb = cb; return () => { progressCb = undefined } } }, normalizeCliError: (err: unknown) => err, })) import { Splash } from './Splash' import { mockMatchMedia as mockReducedMotion } from '../lib/testMatchMedia' function splashEl(): HTMLElement | null { return document.querySelector('.splash') } afterEach(() => { vi.useRealTimers() Reflect.deleteProperty(window, 'matchMedia') }) describe('Splash', () => { it('stays up while the first overview fetch has neither data nor error', () => { render() const el = splashEl() expect(el).toBeInTheDocument() // Static under vitest / the closed motion gate: no ignite/pulse class, // the static mark instead of the loader video. expect(el).not.toHaveClass('splash-lit') expect(el?.querySelector('video')).toBeNull() expect(el?.querySelector('.flamemark')).not.toBeNull() }) it('holds the min on-screen time, then crossfades away once data lands', () => { vi.useFakeTimers() const { rerender } = render() expect(splashEl()).toBeInTheDocument() // First data lands immediately (warm cache): the floor must keep it up. rerender() act(() => { vi.advanceTimersByTime(599) }) expect(splashEl()).toBeInTheDocument() expect(splashEl()).not.toHaveClass('splash-out') // Floor reached: begin the crossfade (still on screen during it). act(() => { vi.advanceTimersByTime(1) }) expect(splashEl()).toHaveClass('splash-out') // Crossfade complete: gone. act(() => { vi.advanceTimersByTime(250) }) expect(splashEl()).not.toBeInTheDocument() }) it('yields immediately when the first fetch errors, with no min-time', () => { vi.useFakeTimers() const { rerender } = render() expect(splashEl()).toBeInTheDocument() rerender() expect(splashEl()).not.toBeInTheDocument() }) it('never reappears on a later loading state after it has dismissed', () => { vi.useFakeTimers() const { rerender } = render() rerender() act(() => { vi.advanceTimersByTime(600) }) act(() => { vi.advanceTimersByTime(250) }) expect(splashEl()).not.toBeInTheDocument() // A filter change re-enters loading and can clear last-good data; the splash // must not come back. rerender() expect(splashEl()).not.toBeInTheDocument() rerender() expect(splashEl()).not.toBeInTheDocument() }) it('reveals the compact indexing status on real cold-scan progress', () => { render() expect(splashEl()).toBeInTheDocument() // No detail before any progress arrives. expect(document.querySelector('.splash-status')).toBeNull() act(() => { // The leading `providers` event carries cold:true only on a genuine full // hydration — that, and only that, reveals the indexing detail. progressCb?.({ kind: 'providers', cold: true, providers: ['claude', 'codex'] }) progressCb?.({ kind: 'provider', provider: 'claude', state: 'start' }) progressCb?.({ kind: 'tick', provider: 'claude', done: 120, total: 480 }) }) const status = document.querySelector('.splash-status') expect(status).toBeInTheDocument() // One compact line: active provider + live counter. No per-provider text rows. expect(status?.querySelector('.splash-status-line')?.textContent).toBe('Indexing Claude · 120/480') expect(status?.textContent).toContain('One-time scan') // Both detected providers render as small logos in the strip; claude active. expect(document.querySelectorAll('.splash-prov').length).toBe(2) expect(document.querySelector('.splash-prov.active')?.getAttribute('title')).toBe('Claude') act(() => { progressCb?.({ kind: 'provider', provider: 'claude', state: 'done' }) }) expect(document.querySelector('.splash-prov.done')?.getAttribute('title')).toBe('Claude') // No active provider left: the line falls back to the generic copy. expect(document.querySelector('.splash-status-line')?.textContent).toBe('Indexing your usage history…') }) it('never reveals the indexing status on a warm launch (providers/ticks without cold)', () => { render() expect(splashEl()).toBeInTheDocument() act(() => { // A warm launch's incremental re-parse streams the same providers/provider/ // tick events, but the providers event has NO cold flag. The strip must stay // hidden — this is the "indexing on every launch" bug fix. progressCb?.({ kind: 'providers', cold: false, providers: ['claude', 'codex'] }) progressCb?.({ kind: 'provider', provider: 'claude', state: 'start' }) progressCb?.({ kind: 'tick', provider: 'claude', done: 40, total: 60 }) progressCb?.({ kind: 'provider', provider: 'claude', state: 'done' }) }) expect(document.querySelector('.splash-status')).toBeNull() }) it('swaps instantly under reduced motion (no fade, no min-time)', () => { mockReducedMotion(true) const { rerender } = render() const el = splashEl() expect(el).toBeInTheDocument() expect(el).not.toHaveClass('splash-lit') // No timers advanced: data lands and the overlay is gone at once. rerender() expect(splashEl()).not.toBeInTheDocument() }) })