overrideInputFocus.test.tsx3.2 KBView on GitHub
import { render, act, waitFor } from '@testing-library/react';
import { EditorContent } from '@tiptap/react';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { useCedarEditor } from '@/modules/cedar-os/src/components/chatInput/useCedarEditor';

/**
 * A button that hands a prompt to the chat (the event popover's "Draft reschedule email",
 * a suggestion tile, the queue card's Edit) must leave the caret IN the composer — otherwise
 * the next keystroke goes to whatever was focused before, usually the button itself.
 *
 * The second case is the one that actually bit: those buttons sit in popovers and menus, and
 * Radix's focus scope re-focuses the trigger from a `setTimeout(…, 0)` as the overlay unmounts.
 */

// The composer's `[[` file search hits tRPC; irrelevant here and unavailable in jsdom.
jest.mock('@/modules/documents/file-link', () => ({
  useCachedFileLinkSearch: () => async () => [],
}));

// jsdom has no layout engine, and ProseMirror measures on every focus (it scrolls the caret
// into view). Stub the three geometry calls it makes so the view can mount and focus.
beforeAll(() => {
  document.elementFromPoint = () => null;
  const emptyRect = { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 };
  for (const proto of [Text.prototype, Element.prototype, Range.prototype]) {
    // defineProperty rather than assignment: jsdom's real signatures return DOMRect(List),
    // and a stub that shape-matches them is more code than the measurement it stands in for.
    Object.defineProperty(proto, 'getClientRects', { value: () => [], configurable: true });
    Object.defineProperty(proto, 'getBoundingClientRect', {
      value: () => emptyRect,
      configurable: true,
    });
  }
});

function Composer() {
  const { editor } = useCedarEditor();
  return <EditorContent editor={editor} />;
}

describe('useCedarEditor — override content takes focus', () => {
  beforeEach(() => {
    useCedarStore.getState().setOverrideInputContent(null);
  });

  it('focuses the composer when a prompt is dropped into it', async () => {
    render(<Composer />);
    const trigger = document.createElement('button');
    document.body.appendChild(trigger);
    trigger.focus();

    act(() => {
      useCedarStore.getState().setOverrideInputContent('I need to reschedule "Kickoff".');
    });

    await waitFor(() => {
      expect(document.activeElement).toHaveClass('ProseMirror');
    });
  });

  it('takes focus back from an overlay that restores it to its trigger on close', async () => {
    render(<Composer />);
    const trigger = document.createElement('button');
    document.body.appendChild(trigger);
    trigger.focus();

    jest.useFakeTimers();
    try {
      act(() => {
        // Exactly what @radix-ui/react-focus-scope does on unmount: restore focus to the
        // element that was focused before the overlay opened, one macrotask later.
        setTimeout(() => trigger.focus(), 0);
        useCedarStore.getState().setOverrideInputContent('I need to reschedule "Kickoff".');
      });
      act(() => {
        jest.runOnlyPendingTimers();
      });
    } finally {
      jest.useRealTimers();
    }

    expect(document.activeElement).toHaveClass('ProseMirror');
  });
});