// @vitest-environment jsdom import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { HighlightOverlay } from '../HighlightOverlay'; import type { RefResolver } from '../resolveRef'; import type { CSTRefId, PointDirective } from '../types'; (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; // jsdom has no ResizeObserver — the overlay observes its targets, so // provide a no-op stub for the test environment. if (typeof globalThis.ResizeObserver === 'undefined') { (globalThis as Record).ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; } // jsdom has no IntersectionObserver — the overlay uses it to wait for // an off-screen target to become visible. Stub it to report the // observed element as immediately intersecting so the wait resolves. if (typeof globalThis.IntersectionObserver === 'undefined') { (globalThis as Record).IntersectionObserver = class { private cb: (entries: unknown[]) => void; constructor(cb: (entries: unknown[]) => void) { this.cb = cb; } observe(target: Element) { this.cb([ { target, isIntersecting: true, intersectionRatio: 1 }, ]); } unobserve() {} disconnect() {} }; } let container: HTMLDivElement; let root: Root; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); document.body.innerHTML = ''; }); /** Resolver over a fixed map. */ function makeResolver(map: Record): RefResolver { return { resolve: (ref: CSTRefId) => map[ref] ?? null }; } async function render( directives: PointDirective[], resolver: RefResolver | null, ) { await act(async () => { root.render( createElement(HighlightOverlay, { directives, resolver, ttlMs: 0 }), ); }); } describe('HighlightOverlay', () => { it('renders nothing with no directives', async () => { await render([], null); expect(document.querySelector('[data-chat-highlight-overlay]')).toBeNull(); }); it('renders nothing when refs do not resolve', async () => { await render([{ type: 'point', ref: '@e9' }], makeResolver({})); expect(document.querySelector('[data-chat-highlight-overlay]')).toBeNull(); }); it('draws the overlay for a resolved ref', async () => { const el = document.createElement('button'); el.textContent = 'Save'; document.body.appendChild(el); await render( [{ type: 'point', ref: '@e1' }], makeResolver({ '@e1': el }), ); expect( document.querySelector('[data-chat-highlight-overlay]'), ).not.toBeNull(); // The SVG spotlight canvas is mounted. expect(document.querySelector('svg')).not.toBeNull(); }); it('renders a caption when the directive has a label', async () => { const el = document.createElement('input'); document.body.appendChild(el); await render( [{ type: 'point', ref: '@e1', label: 'Type your key here' }], makeResolver({ '@e1': el }), ); expect(document.body.textContent).toContain('Type your key here'); }); it('focuses the element when the directive asks for focus', async () => { const el = document.createElement('input'); document.body.appendChild(el); await render( [{ type: 'point', ref: '@e1', focus: true }], makeResolver({ '@e1': el }), ); expect(document.activeElement).toBe(el); }); it('does not focus when focus is not requested', async () => { const el = document.createElement('input'); document.body.appendChild(el); await render( [{ type: 'point', ref: '@e1' }], makeResolver({ '@e1': el }), ); expect(document.activeElement).not.toBe(el); }); it('scrolls an off-screen target into view before drawing it', async () => { const el = document.createElement('button'); document.body.appendChild(el); // Report the element below the viewport so it counts as off-screen. el.getBoundingClientRect = () => ({ top: 5000, left: 0, bottom: 5040, right: 80, width: 80, height: 40, x: 0, y: 5000 }) as DOMRect; let scrolled = false; el.scrollIntoView = () => { scrolled = true; }; await render( [{ type: 'point', ref: '@e1' }], makeResolver({ '@e1': el }), ); // Let the visibility wait resolve and its rAF-scheduled re-measure // run, so the now-ready target is drawn. await act(async () => { await new Promise((r) => requestAnimationFrame(() => r())); }); // The off-screen target was scrolled into view, and once the // (stubbed) IntersectionObserver reports it visible the overlay // draws the spotlight. expect(scrolled).toBe(true); expect( document.querySelector('[data-chat-highlight-overlay]'), ).not.toBeNull(); }); });