import { ApprovalRequestId, CheckpointRef, EnvironmentId, MessageId, TurnId, } from "@t3tools/contracts"; import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; const LegendList = (props: { data: Array<{ id: string }>; keyExtractor: (item: { id: string }) => string; renderItem: (args: { item: { id: string } }) => ReactNode; ListHeaderComponent?: ReactNode; ListFooterComponent?: ReactNode; anchoredEndSpace?: { anchorIndex: number; anchorMaxSize?: number; anchorOffset?: number; onReady?: (info: { anchorIndex: number }) => void; }; contentInsetEndAdjustment?: number; className?: string; maintainScrollAtEnd?: boolean | MaintainScrollAtEndOptions; maintainVisibleContentPosition?: | boolean | { data?: boolean; size?: boolean; shouldRestorePosition?: (item: { id: string }) => boolean; }; ref?: Ref; }) => { if (props.anchoredEndSpace) { props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); } return (
{props.ListHeaderComponent} {props.data.map((item) => (
{props.renderItem({ item })}
))} {props.ListFooterComponent}
); }; return { LegendList }; }); function MockFileDiff(props: { fileDiff: { name?: string | null; prevName?: string | null }; renderCustomHeader?: (fileDiff: { name?: string | null; prevName?: string | null; }) => React.ReactNode; }) { return (
{props.renderCustomHeader?.(props.fileDiff)} {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"}
); } vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); vi.mock("../DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, })); function matchMedia() { return { matches: false, addEventListener: () => {}, removeEventListener: () => {}, }; } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; beforeAll(async () => { const classList = { add: () => {}, remove: () => {}, toggle: () => {}, contains: () => false, }; vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, }); vi.stubGlobal("window", { matchMedia, addEventListener: () => {}, removeEventListener: () => {}, requestAnimationFrame: (callback: FrameRequestCallback) => { callback(0); return 0; }, cancelAnimationFrame: () => {}, desktopBridge: undefined, }); vi.stubGlobal("document", { documentElement: { classList, offsetHeight: 0, }, }); ({ MessagesTimeline } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; function buildProps() { return { isWorking: false, activeTurnStartedAt: null, listRef: createRef(), latestTurn: null, runningTurnId: null, turnDiffSummaries: [], routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, supportsConversationRollback: false, onRevertToTurnCount: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, markdownCwd: undefined, resolvedTheme: "light" as const, timestampFormat: "locale" as const, workspaceRoot: undefined, anchorMessageId: null, onAnchorReady: () => {}, contentInsetEndAdjustment: 0, liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; } function buildLongUserMessageText(tail = "deep hidden detail only after expand") { return Array.from({ length: 9 }, (_, index) => index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`, ).join("\n"); } function buildUserTimelineEntry(text: string) { return { id: "entry-1", kind: "message" as const, createdAt: MESSAGE_CREATED_AT, message: { id: MessageId.make("message-1"), role: "user" as const, text, turnId: null, createdAt: MESSAGE_CREATED_AT, updatedAt: MESSAGE_CREATED_AT, streaming: false, }, }; } function buildAssistantTimelineEntry(text: string) { const entry = buildUserTimelineEntry(text); return { ...entry, message: { ...entry.message, role: "assistant" as const, }, }; } function buildSnapShotTimelineEntry(previewUrl?: string) { const entry = buildUserTimelineEntry("First prompt."); return { ...entry, message: { ...entry.message, attachments: [ { type: "image" as const, id: "attachment-1", name: "screenshot.png", mimeType: "image/png", sizeBytes: 1, ...(previewUrl ? { previewUrl } : {}), source: { kind: "snap-shot" as const, capturedAt: "2026-03-17T19:12:28.000Z", appName: "Terminal", windowTitle: "t3code — Tests", appIconDataUrl: "data:image/png;base64,aWNvbg==", }, }, ], }, }; } describe("MessagesTimeline", () => { it("renders previous and next controls with the minimap", () => { const first = buildUserTimelineEntry("First turn"); const secondBase = buildUserTimelineEntry("Second turn"); const second = { ...secondBase, id: "entry-2", message: { ...secondBase.message, id: MessageId.make("message-2"), }, }; const markup = renderToStaticMarkup( , ); expect(markup).toContain('aria-label="Previous turn"'); expect(markup).toContain('aria-label="Next turn"'); }); // Expanding history uses this suite's existing test renderer, deprecated in // React 19. Migrate these interaction tests together when a DOM test setup is added. it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( "renders attachment-only question history alongside text answers: %j", async (answers) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", () => 0); vi.stubGlobal("cancelAnimationFrame", () => {}); let renderer: ReactTestRenderer | undefined; try { await act(() => { renderer = create( , ); }); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); expect(markup.match(/Provide a spec/g)).toHaveLength(1); expect(markup.match(/spec\.txt/g)).toHaveLength(1); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); } finally { await act(() => renderer?.unmount()); } }, ); it.each([ { toolLifecycleStatus: "inProgress", isAtEnd: true }, { toolLifecycleStatus: "inProgress", isAtEnd: false }, { toolLifecycleStatus: "completed", isAtEnd: true }, { toolLifecycleStatus: "completed", isAtEnd: false }, ] as const)( "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd", async ({ toolLifecycleStatus, isAtEnd }) => { const frames = new Map(); let nextFrame = 0; vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++nextFrame, callback); return nextFrame; }); vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const flushFrame = () => act(() => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach((callback) => callback(0)); }); const props = buildProps(); let timelineIsAtEnd = isAtEnd; props.listRef.current = { getState: () => ({ isAtEnd: timelineIsAtEnd }), getScrollableNode: () => null, } as unknown as LegendListRef; let isResting = false; let composerState: ReturnType | undefined; function ThreadProbe() { const composer = useComposerFocusState(); useLayoutEffect(() => { composerState = composer; isResting = shouldUseRestingComposerLayout({ isExistingThread: true, isMobileViewport: false, isScrollCollapsed: composer.isComposerScrollCollapsed, hasExpandedChrome: false, hasMultilinePrompt: false, timelineOverflows: true, }); }); return ( ); } let renderer: ReactTestRenderer | undefined; try { await act(() => { renderer = create(); }); // The user scrolled up to read, so the composer is resting. await act(() => composerState!.setIsComposerScrollCollapsed(true)); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); await flushFrame(); await flushFrame(); expect(isResting).toBe(true); timelineIsAtEnd = false; await act(() => toggle.props.onClick()); await flushFrame(); timelineIsAtEnd = isAtEnd; await flushFrame(); expect(isResting).toBe(!isAtEnd); } finally { await act(() => renderer?.unmount()); } }, ); it("renders elapsed time for a completed turn", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); const markup = renderToStaticMarkup( , ); expect(markup).toContain("Worked for 8.0s"); }); it("keeps assistant changed-files headers sticky below the thread header", () => { const assistantMessageId = MessageId.make("message-assistant-with-files"); const turnId = TurnId.make("turn-with-files"); const markup = renderToStaticMarkup( , ); expect(markup).toContain("sticky top-2 z-10"); expect(markup).not.toContain("self-start"); expect(markup).toContain("whitespace-nowrap"); expect(markup).toContain("size-3"); expect(markup).not.toContain('aria-label="Collapse all folders"'); expect(markup).toContain('aria-label="Open diff"'); expect(markup).toContain("1 changed file"); }); it("treats only the strict list end as the live edge", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapCurrentIndex, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); // Within the pixel band above the content bottom counts as the end... expect( resolveTimelineIsAtEnd({ isAtEnd: false, contentLength: 2000, scroll: 1170, scrollLength: 800, }), ).toBe(true); // ...but half a viewport up (LegendList's isNearEnd territory) does not. expect( resolveTimelineIsAtEnd({ isAtEnd: false, contentLength: 2000, scroll: 900, scrollLength: 800, }), ).toBe(false); // LegendList's isAtEnd is true anywhere within the composer-height band // (it subtracts the inset); the last row is still hidden under the // composer there, so the flag must not short-circuit the geometry. expect( resolveTimelineIsAtEnd({ isAtEnd: true, contentLength: 2000, scroll: 1100, scrollLength: 800, }), ).toBe(false); // Geometry missing (older state shape): fall back to the strict flag. expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); expect( resolveTimelineMinimapIndexFromPointer({ itemCount: 101, railTop: 100, railHeight: 500, pointerY: 350, }), ).toBe(50); expect( resolveTimelineMinimapIndexFromPointer({ itemCount: 101, railTop: 100, railHeight: 500, pointerY: 999, }), ).toBe(100); expect( resolveTimelineMinimapCurrentIndex({ scrollTop: 100, scrollBottom: 500, itemBounds: [ { top: 80, height: 20 }, { top: 120, height: 20 }, { top: 220, height: 20 }, ], }), ).toBe(1); expect( resolveTimelineMinimapCurrentIndex({ scrollTop: 150, scrollBottom: 200, itemBounds: [ { top: 80, height: 20 }, { top: 120, height: 20 }, { top: 220, height: 20 }, ], }), ).toBe(1); expect( resolveTimelineMinimapCurrentIndex({ scrollTop: 0, scrollBottom: 50, itemBounds: [{ top: 80, height: 20 }], }), ).toBeNull(); expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); // No usable gutter (zoomed in / narrow pane): the strip must go inert // instead of overlaying the centered content column. expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0); expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0); // Partial gutter: strip shrinks to what fits between the viewport edge // and the content column. expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14); // Full gutter: unchanged 40px-wide strip. expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40); expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40); expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0); expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0); // The collapsed target stays narrow, but an open preview keeps its full // 20rem width plus the 2rem offset from the minimap rail interactive. expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0); expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14); expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40); expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem"); expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem"); expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); it("anchors the first user message using its measured height", () => { const onAnchorReady = vi.fn(); const firstEntry = buildSnapShotTimelineEntry("data:image/png;base64,iVBORw0KGgo="); const markup = renderToStaticMarkup( , ); expect(markup).toContain('data-anchor-index="0"'); expect(markup).toContain('data-anchor-offset="24"'); expect(markup).not.toContain("data-anchor-max-size="); expect(markup).toContain('data-content-inset-end="144"'); expect(markup).toContain("[overflow-anchor:none]"); expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); expect(markup).toContain('data-maintain-visible-content-position="object"'); expect(markup).toContain('data-maintain-visible-content-position-data="true"'); expect(markup).toContain('data-maintain-visible-content-position-size="true"'); expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); expect(markup).toContain("Terminal"); expect(markup).toContain("t3code — Tests"); expect(markup).toContain('src="data:image/png;base64,aWNvbg=="'); expect(markup).toContain("h-28 w-52 max-w-full"); expect(markup).not.toContain("col-span-2"); expect(onAnchorReady).toHaveBeenCalledOnce(); expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0); }); it("does not render window details before the preview URL resolves", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("screenshot.png"); expect(markup).not.toContain("Terminal"); expect(markup).not.toContain("t3code — Tests"); expect(markup).not.toContain('src="data:image/png;base64,aWNvbg=="'); expect(markup).not.toContain("h-28 w-52 max-w-full"); }); it("does not reserve end space for a follow-up user message", () => { const onAnchorReady = vi.fn(); const firstEntry = buildUserTimelineEntry("First prompt."); const secondEntry = { ...buildUserTimelineEntry("Newest prompt."), id: "entry-2", message: { ...buildUserTimelineEntry("Newest prompt.").message, id: MessageId.make("message-2"), }, }; const markup = renderToStaticMarkup( , ); expect(markup).not.toContain("data-anchor-index="); expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); expect(onAnchorReady).not.toHaveBeenCalled(); }); it("gives browser documents separate preview and download controls", () => { const entry = { ...buildUserTimelineEntry("Read the report."), message: { ...buildUserTimelineEntry("Read the report.").message, attachments: [ { type: "file" as const, id: "attachment-report-pdf", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, previewUrl: "https://environment.test/api/assets/report.pdf", }, ], }, }; const markup = renderToStaticMarkup( , ); expect(markup).toContain('aria-label="Preview report.pdf"'); expect(markup).toContain('aria-label="Download report.pdf"'); expect(markup).not.toContain('download="report.pdf"'); expect(markup).not.toContain('alt="report.pdf"'); }); it("renders video attachments with the shared video player", () => { const entry = { ...buildUserTimelineEntry("Watch the demo."), message: { ...buildUserTimelineEntry("Watch the demo.").message, attachments: [ { type: "file" as const, id: "attachment-demo-mp4", name: "demo.mp4", mimeType: "video/mp4", sizeBytes: 42, previewUrl: "https://environment.test/api/assets/demo.mp4", }, ], }, }; const markup = renderToStaticMarkup( , ); expect(markup).toContain(" { const entry = { ...buildUserTimelineEntry("Uploading the demo."), message: { ...buildUserTimelineEntry("Uploading the demo.").message, attachments: [ { type: "file" as const, id: "optimistic-demo-mp4", name: "pending-demo.mp4", mimeType: "video/mp4", sizeBytes: 42, downloadable: false, }, ], }, }; const markup = renderToStaticMarkup( , ); expect(markup).not.toContain("pending-demo.mp4"); }); it("renders an ordinary file download button without creating its URL in advance", () => { const entry = { ...buildUserTimelineEntry("Read the report."), message: { ...buildUserTimelineEntry("Read the report.").message, attachments: [ { type: "file" as const, id: "attachment-report-pdf", name: "archive.zip", mimeType: "application/zip", sizeBytes: 42, }, ], }, }; const markup = renderToStaticMarkup( , ); expect(markup).toContain( '', "", ].join("\n"), ), ]} />, ); expect(markup).toContain("SubmitButton"); expect(markup).not.toContain("<element_context"); expect(markup).not.toContain(" { const markup = renderToStaticMarkup( , ); expect(markup).toContain('aria-label="Copy link"'); expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-footer="true"'); }); it("renders context compaction entries in the normal work log", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("Compacted context 899K → 19K tokens"); }); it("summarizes changed files in one line", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("Changed 1 file"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); it("keeps mixed-success tool groups neutral", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("Ran 2 commands"); expect(markup).not.toContain('aria-label="Tool call failed"'); }); it("keeps the collapsed summary icon neutral when the group ends in a failure", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("Ran 2 commands"); expect(markup).toContain("lucide-terminal"); expect(markup).not.toContain("lucide-x"); expect(markup).not.toContain("text-destructive"); // The failure stays discoverable for screen readers. expect(markup).toContain("tool call failed"); }); it("renders trailing tool calls as part of the terminal assistant block", () => { const turnId = TurnId.make("turn-trailing-tools"); const assistantMessageId = MessageId.make("assistant-trailing-tools"); const markup = renderToStaticMarkup( , ); const messageIndex = markup.indexOf('data-timeline-row-id="assistant-entry"'); const toolIndex = markup.indexOf('data-timeline-row-id="trailing-work-entry"'); const metaIndex = markup.indexOf( 'data-timeline-row-id="assistant-meta:assistant-trailing-tools"', ); expect(messageIndex).toBeGreaterThanOrEqual(0); expect(toolIndex).toBeGreaterThan(messageIndex); expect(metaIndex).toBeGreaterThan(toolIndex); expect(markup.match(/I’ll search for it now\./gu)).toHaveLength(1); }); it("keeps mixed work logs neutral after a later tool call succeeds", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("Ran 2 commands and received 1 update"); expect(markup).not.toContain('aria-label="Hidden work includes a failure"'); }); it("shows the one-line label for a live tool group", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( , ); expect(markup).toContain("Working for"); expect(markup).toContain("Running pnpm"); }); it("scopes a live row failure to the tool named by the row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( , ); expect(markup).toContain("Running pnpm"); expect(markup).not.toContain("tool call failed"); }); it("renders initial thinking as the shared live activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( , ); expect(markup).toContain("Thinking"); expect(markup).toContain("lucide-brain"); expect(markup).toContain('data-timeline-row-id="live-activity-row"'); }); it("keeps the completed command in the shared activity row with a present-tense label", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( , ); expect(markup).toContain("Running pnpm"); expect(markup).toContain("lucide-terminal"); expect(markup).not.toContain("Ran pnpm"); expect(markup).not.toContain("Thinking"); expect(markup).not.toContain('data-timeline-row-kind="thinking"'); }); it("renders review comment contexts as structured cards instead of raw tags", () => { const markup = renderToStaticMarkup( ', "Wadduo", "```diff", "@@ -0,0 +47,2 @@", '+ it("keeps valid zero-usage snapshots", () => {', "+ expect(snapshot).not.toBeNull();", "```", "", ].join("\n"), turnId: null, createdAt: "2026-03-17T19:12:28.000Z", updatedAt: "2026-03-17T19:12:28.000Z", streaming: false, }, }, ]} />, ); expect(markup).toContain("contextWindow.test.ts"); expect(markup).toContain("Wadduo"); expect(markup).toContain('data-testid="file-diff"'); expect(markup).not.toContain(">Review comment<"); expect(markup).not.toContain("<review_comment"); expect(markup).not.toContain("</review_comment>"); }); it("renders file review comments as source code instead of diffs", () => { const markup = renderToStaticMarkup( ', "Clarify this.", "```md", "# Plan", "- Step one", "```", "", ].join("\n"), turnId: null, createdAt: "2026-03-17T19:12:28.000Z", updatedAt: "2026-03-17T19:12:28.000Z", streaming: false, }, }, ]} />, ); expect(markup).toContain("plan.md"); expect(markup).toContain("Clarify this."); expect(markup).toContain("# Plan"); expect(markup).not.toContain('data-testid="file-diff"'); }); it("keeps failed lifecycle entries discoverable in mixed activity summaries", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain('aria-label="Received 1 update and used 1 tool, tool call failed"'); // Ordinary tool failures do not use destructive row styling. expect(markup).not.toContain("text-destructive"); }); it("keeps the red treatment for severe orchestration failures", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("lucide-circle-alert"); expect(markup).toContain("text-destructive"); }); it("only withholds an expanded tool-call label click while text is selected", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", () => 0); vi.stubGlobal("cancelAnimationFrame", () => {}); let renderer: ReactTestRenderer | undefined; try { await act(() => { renderer = create( , ); }); await act(() => renderer!.root.findByProps({ "aria-expanded": false }).props.onClick()); const label = renderer!.root.findAll( (node) => node.type === "span" && String(node.props.className).includes("select-text"), )[0]; const stopPropagation = vi.fn(); // Only the click that ends a selection may be withheld from the row // toggle; the plain click has to reach it so the label can collapse. for (const isCollapsed of [false, true]) { label!.props.onClick({ currentTarget: { ownerDocument: { getSelection: () => ({ isCollapsed }) } }, stopPropagation, }); } expect(stopPropagation).toHaveBeenCalledTimes(1); } finally { await act(() => renderer?.unmount()); } }); });