diff --git a/.changeset/reverse-span-links.md b/.changeset/reverse-span-links.md new file mode 100644 index 0000000000..6b942228ff --- /dev/null +++ b/.changeset/reverse-span-links.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +Show reverse span links and resolved span-link details in the span detail Overview panel. diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 31fc1906c6..47d4eda692 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -7,6 +7,7 @@ import { Accordion, Box, Flex, Text } from '@mantine/core'; import { WithClause } from '@/hooks/useRowWhere'; import { getEventBody } from '@/source'; import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes'; +import { resolveRowTimestampAnchor } from '@/utils/rowTimestamps'; import { getJSONColumnNames, @@ -18,8 +19,10 @@ import { RowSidePanelContext } from './DBRowSidePanel'; import DBRowSidePanelHeader from './DBRowSidePanelHeader'; import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; +import { useLinkedSpanDetails, useReverseSpanLinks } from './linkedSpans'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; +import { SpanLinkedFromSubpanel } from './SpanLinkedFromSubpanel'; import { getValidSpanLinks, SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; @@ -191,9 +194,37 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_events]); - const hasSpanLinks = useMemo(() => { - return getValidSpanLinks(firstRow?.__hdx_span_links).length > 0; + const validSpanLinks = useMemo(() => { + return getValidSpanLinks(firstRow?.__hdx_span_links); }, [firstRow?.__hdx_span_links]); + const hasSpanLinks = validSpanLinks.length > 0; + + const rowMeta = data?.meta; + const linkAnchorDate = useMemo( + () => + resolveRowTimestampAnchor({ + timestampValueExpression: source.timestampValueExpression, + row: firstRow, + meta: rowMeta, + }), + [source.timestampValueExpression, firstRow, rowMeta], + ); + + const rowTraceId = firstRow?.__hdx_trace_id; + const rowSpanId = firstRow?.__hdx_span_id; + + const { links: reverseSpanLinks } = useReverseSpanLinks({ + source, + traceId: typeof rowTraceId === 'string' ? rowTraceId : undefined, + spanId: typeof rowSpanId === 'string' ? rowSpanId : undefined, + anchorDate: linkAnchorDate, + }); + + const { details: linkedSpanDetails } = useLinkedSpanDetails({ + source, + links: validSpanLinks, + anchorDate: linkAnchorDate, + }); const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) @@ -226,6 +257,7 @@ export function RowOverviewPanel({ 'exception', 'spanEvents', 'spanLinks', + 'linkedFrom', 'network', 'resourceAttributes', 'eventAttributes', @@ -328,14 +360,33 @@ export function RowOverviewPanel({ {hasSpanLinks && ( - + Span Links - + + + + + )} + + {reverseSpanLinks.length > 0 && ( + + + + Linked from + + + + + diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 55ad59fa5a..36b7d4340a 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -625,6 +625,20 @@ export const DBRowSidePanelInner = ({ ]), SqlString.format('?=?', [SqlString.raw(spanIdExpression), link.SpanId]), ].join(' AND '); + // A link often points right back at the row one level up (forward and + // reverse span links come in pairs). Pop back to that breadcrumb instead + // of pushing an endless A <-> B trail. Only when no nav drilldown sits + // on top: popOne would pop the nav entry, not the source frame. + const topFrame = + sourceStack.length > 0 ? sourceStack[sourceStack.length - 1] : null; + if ( + navStack.length === 0 && + topFrame?.originRowId != null && + topFrame.originRowId === rowId + ) { + popOne(); + return; + } // Mark this frame so its breadcrumb switches from the `Trace ` // fallback below to the landed span name once the row loads. spanLinkFrameRowIdsRef.current.add(rowId); @@ -634,6 +648,10 @@ export const DBRowSidePanelInner = ({ label: `Trace ${link.TraceId.slice(0, 8)}`, sourceKind: traceSourceData.kind as SourceKind, aliasWith: [], + // The current row's canonical trace/span row id: the exact string a + // link hop targeting this row would compute, so the pop-back check + // above can compare by equality. + originRowId: traceSpanRowId, }); }, [ @@ -641,6 +659,10 @@ export const DBRowSidePanelInner = ({ traceIdExpression, spanIdExpression, handleSourceStackPush, + sourceStack, + navStack, + popOne, + traceSpanRowId, ], ); diff --git a/packages/app/src/components/DBRowSidePanel.types.ts b/packages/app/src/components/DBRowSidePanel.types.ts index 0d7723559a..78bdc2942a 100644 --- a/packages/app/src/components/DBRowSidePanel.types.ts +++ b/packages/app/src/components/DBRowSidePanel.types.ts @@ -38,6 +38,12 @@ const SourceFrameSchema = z.object({ * trace) leave it unset. */ focusTimestamp: z.string().optional(), + /** + * Row id of the row displayed when this frame was pushed, in the same + * canonical form a navigation back to that row would use. Rows one hop + * apart somtimes reference each other (e.g. span links). + */ + originRowId: z.string().optional(), }); export type SourceFrame = z.infer; diff --git a/packages/app/src/components/SpanLinkedFromSubpanel.tsx b/packages/app/src/components/SpanLinkedFromSubpanel.tsx new file mode 100644 index 0000000000..ac4e4e7722 --- /dev/null +++ b/packages/app/src/components/SpanLinkedFromSubpanel.tsx @@ -0,0 +1,130 @@ +import { ErrorBoundary } from 'react-error-boundary'; +import { Anchor, Button, Stack, Tooltip } from '@mantine/core'; +import { + IconArrowUpRight, + IconChevronDown, + IconChevronUp, +} from '@tabler/icons-react'; + +import { SectionWrapper, useShowMoreRows } from './ExceptionSubpanel'; +import { + LinkedSpanDetails, + linkedSpanKey, + LinkedSpanMetaLine, +} from './linkedSpans'; +import { SpanLinkData } from './SpanLinksSubpanel'; + +function LinkedSpanRow({ + link, + onOpenTrace, +}: { + link: LinkedSpanDetails; + onOpenTrace?: (link: SpanLinkData) => void; +}) { + return ( + + +
Trace: {link.TraceId}
+
Span: {link.SpanId}
+ + } + > + + onOpenTrace?.({ + TraceId: link.TraceId, + SpanId: link.SpanId, + TraceState: '', + Attributes: {}, + }) + } + size="sm" + fw={500} + className="d-inline-flex align-items-center" + > + + {link.spanName || 'Open span'} + +
+ +
+ ); +} + +export const SpanLinkedFromSubpanel = ({ + links, + onOpenTrace, +}: { + links: LinkedSpanDetails[]; + onOpenTrace?: (link: SpanLinkData) => void; +}) => { + const { handleToggleMoreRows, hiddenRowsCount, visibleRows, isExpanded } = + useShowMoreRows({ + rows: links, + maxRows: 5, + }); + + if (links.length === 0) { + return null; + } + + return ( +
+ + { + console.error(err); + }} + fallbackRender={() => ( +
+ An error occurred while rendering linked spans +
+ )} + > + + {visibleRows.map((link, index) => ( +
0 ? 'pt-2 border-top border-dark' : undefined + } + > + +
+ ))} +
+
+ + {hiddenRowsCount ? ( + + ) : null} +
+
+ ); +}; diff --git a/packages/app/src/components/SpanLinksSubpanel.tsx b/packages/app/src/components/SpanLinksSubpanel.tsx index e1d2a54b30..f688ad6ddb 100644 --- a/packages/app/src/components/SpanLinksSubpanel.tsx +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -9,6 +9,11 @@ import { import EventTag from './EventTag'; import { SectionWrapper, useShowMoreRows } from './ExceptionSubpanel'; +import { + LinkedSpanDetails, + linkedSpanKey, + LinkedSpanMetaLine, +} from './linkedSpans'; // Make sure SpanLinkData implements Record export interface SpanLinkData extends Record { @@ -37,9 +42,11 @@ export function getValidSpanLinks( function SpanLinkRow({ link, + details, onOpenTrace, }: { link: SpanLinkData; + details?: LinkedSpanDetails; onOpenTrace?: (link: SpanLinkData) => void; }) { const attributeEntries = Object.entries(link.Attributes ?? {}); @@ -71,10 +78,12 @@ function SpanLinkRow({ className="d-inline-flex align-items-center" > - Open trace + {details?.spanName || 'Open trace'} + {details ? : null} + {hasChips ? ( {hasTraceState ? ( @@ -104,9 +113,14 @@ function SpanLinkRow({ export const SpanLinksSubpanel = ({ spanLinks, + linkedSpanDetails, onOpenTrace, }: { spanLinks?: Record[] | null; + // Resolved details for the spans the links point to, keyed by + // `linkedSpanKey`. Links without an entry render with the bare + // "Open trace" fallback. + linkedSpanDetails?: Map; onOpenTrace?: (link: SpanLinkData) => void; }) => { const links = useMemo(() => getValidSpanLinks(spanLinks), [spanLinks]); @@ -148,7 +162,13 @@ export const SpanLinksSubpanel = ({ index > 0 ? 'pt-2 border-top border-dark' : undefined } > - + ))} diff --git a/packages/app/src/components/__tests__/DBRowOverviewPanel.linkedFrom.test.tsx b/packages/app/src/components/__tests__/DBRowOverviewPanel.linkedFrom.test.tsx new file mode 100644 index 0000000000..148661f125 --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowOverviewPanel.linkedFrom.test.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + useRowData: (args: unknown) => mockUseRowData(args), + getJSONColumnNames: () => [], + getMapColumnNames: () => [], +})); + +jest.mock('../DBRowSidePanel', () => { + const { createContext } = jest.requireActual('react'); + return { + __esModule: true, + RowSidePanelContext: createContext({}), + }; +}); + +jest.mock('../DBRowJsonViewer', () => ({ + __esModule: true, + DBRowJsonViewer: () => null, +})); +jest.mock('../DBRowSidePanelHeader', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../EventTag', () => ({ __esModule: true, default: () => null })); +jest.mock('../ExceptionSubpanel', () => ({ + __esModule: true, + ExceptionSubpanel: () => null, +})); +jest.mock('../NetworkPropertyPanel', () => ({ + __esModule: true, + NetworkPropertySubpanel: () => null, +})); +jest.mock('../SpanEventsSubpanel', () => ({ + __esModule: true, + SpanEventsSubpanel: () => null, +})); +const mockSpanLinksSubpanel = jest.fn(); +jest.mock('../SpanLinksSubpanel', () => ({ + __esModule: true, + getValidSpanLinks: jest.requireActual('../SpanLinksSubpanel') + .getValidSpanLinks, + SpanLinksSubpanel: (props: unknown) => { + mockSpanLinksSubpanel(props); + return null; + }, +})); + +const mockUseReverseSpanLinks = jest.fn(); +const mockUseLinkedSpanDetails = jest.fn(); +jest.mock('../linkedSpans', () => ({ + __esModule: true, + useReverseSpanLinks: (args: unknown) => mockUseReverseSpanLinks(args), + useLinkedSpanDetails: (args: unknown) => mockUseLinkedSpanDetails(args), + linkedSpanKey: (traceId: string, spanId: string) => `${traceId}:${spanId}`, + LinkedSpanMetaLine: () => null, +})); +jest.mock('../SpanLinkedFromSubpanel', () => ({ + __esModule: true, + SpanLinkedFromSubpanel: () =>
, +})); + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => undefined, +})); +jest.mock('@/utils/highlightedAttributes', () => ({ + __esModule: true, + getHighlightedAttributesFromData: () => [], +})); + +const ANCHOR = new Date('2024-01-02T12:00:00.000Z'); +jest.mock('@/utils/rowTimestamps', () => ({ + __esModule: true, + resolveRowTimestampAnchor: () => new Date('2024-01-02T12:00:00.000Z'), +})); + +import { RowOverviewPanel } from '@/components/DBRowOverviewPanel'; + +const TRACE_SOURCE: TSource = { + id: 'trace-src', + name: 'Traces', + kind: SourceKind.Trace, + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, SpanName', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + durationExpression: 'Duration', + durationPrecision: 9, + spanLinksValueExpression: 'Links', +}; + +const ROW = { + __hdx_trace_id: 'trace-1', + __hdx_span_id: 'span-1', +}; + +const LINK = { + TraceId: 'other-trace', + SpanId: 'other-span', + spanName: 'consume message', +}; + +describe('RowOverviewPanel linked-from section', () => { + beforeEach(() => { + mockUseRowData.mockReset(); + mockUseReverseSpanLinks.mockReset(); + mockUseLinkedSpanDetails.mockReset(); + mockUseLinkedSpanDetails.mockReturnValue({ details: new Map() }); + mockUseRowData.mockReturnValue({ data: { data: [ROW], meta: [] } }); + }); + + it('shows the section when reverse links exist', () => { + mockUseReverseSpanLinks.mockReturnValue({ links: [LINK] }); + + renderWithMantine( + , + ); + + expect(screen.getByText('Linked from')).toBeInTheDocument(); + expect(screen.getByTestId('linked-from-subpanel')).toBeInTheDocument(); + + expect(mockUseReverseSpanLinks).toHaveBeenCalledWith( + expect.objectContaining({ + source: TRACE_SOURCE, + traceId: 'trace-1', + spanId: 'span-1', + anchorDate: ANCHOR, + }), + ); + }); + + it('hides the section when there are no reverse links', () => { + mockUseReverseSpanLinks.mockReturnValue({ links: [] }); + + renderWithMantine( + , + ); + + expect(screen.queryByText('Linked from')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('linked-from-subpanel'), + ).not.toBeInTheDocument(); + }); + + it('passes undefined ids when the row lacks trace/span context', () => { + mockUseRowData.mockReturnValue({ data: { data: [{}], meta: [] } }); + mockUseReverseSpanLinks.mockReturnValue({ links: [] }); + + renderWithMantine( + , + ); + + expect(mockUseReverseSpanLinks).toHaveBeenCalledWith( + expect.objectContaining({ + traceId: undefined, + spanId: undefined, + }), + ); + }); + + it("resolves the row's forward links and passes details to the span links panel", () => { + const spanLink = { + TraceId: 'linked-trace', + SpanId: 'linked-span', + TraceState: '', + Attributes: {}, + }; + mockUseRowData.mockReturnValue({ + data: { data: [{ ...ROW, __hdx_span_links: [spanLink] }], meta: [] }, + }); + mockUseReverseSpanLinks.mockReturnValue({ links: [] }); + const details = new Map([ + ['linked-trace:linked-span', { TraceId: 'linked-trace' }], + ]); + mockUseLinkedSpanDetails.mockReturnValue({ details }); + + renderWithMantine( + , + ); + + expect(mockUseLinkedSpanDetails).toHaveBeenCalledWith( + expect.objectContaining({ + source: TRACE_SOURCE, + links: [spanLink], + anchorDate: ANCHOR, + }), + ); + expect(mockSpanLinksSubpanel).toHaveBeenCalledWith( + expect.objectContaining({ linkedSpanDetails: details }), + ); + }); +}); diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx index 346e29b1e9..91d7f2bc8b 100644 --- a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx @@ -79,12 +79,27 @@ const TRACE_SOURCE = { spanIdExpression: 'SpanId', }; +// A log source stack frames can resolve to. Unlike a trace source, its +// Overview (and thus the span-link rows) renders as a top-level side panel +// tab, so pop-back tests can click a link while a frame is active without +// going through the mocked-away trace waterfall. +const STACK_LOG_SOURCE = { + id: 'stack-log-src', + kind: 'log', + traceSourceId: 'trace-src', + resourceAttributesExpression: 'ResourceAttributes', +}; + jest.mock('@/source', () => ({ __esModule: true, getEventBody: () => undefined, useSource: ({ id }: { id: string | null }) => - id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, + id === 'trace-src' + ? { data: TRACE_SOURCE } + : id === 'stack-log-src' + ? { data: STACK_LOG_SOURCE } + : { data: undefined }, })); jest.mock('../DBSessionPanel', () => ({ @@ -128,6 +143,23 @@ jest.mock('../TimelineChart/utils', () => ({ __esModule: true, renderMs: () => '', })); +// The linked-span lookups issue real queries through useQueriedChartConfig, +// whose providers this harness doesn't set up. +jest.mock('../linkedSpans', () => ({ + __esModule: true, + useReverseSpanLinks: () => ({ links: [], isLoading: false, error: null }), + useLinkedSpanDetails: () => ({ + details: new Map(), + isLoading: false, + error: null, + }), + linkedSpanKey: (traceId: string, spanId: string) => `${traceId}:${spanId}`, + LinkedSpanMetaLine: () => null, +})); +jest.mock('../SpanLinkedFromSubpanel', () => ({ + __esModule: true, + SpanLinkedFromSubpanel: () => null, +})); jest.mock('../DrawerUtils', () => ({ __esModule: true, DrawerFullWidthToggle: () => null, @@ -224,6 +256,88 @@ describe('DBRowSidePanelInner, span link "Open trace" push wiring (HDX-3191)', ( expect(setterFor('sidePanelTab')).toHaveBeenCalledWith('trace'); }); + it('stamps the canonical origin row id onto the pushed frame', () => { + mockUseRowData.mockReturnValue({ + data: { + data: [ + { + __hdx_span_links: [LINK], + __hdx_trace_id: 'origin-trace', + __hdx_span_id: 'origin-span', + }, + ], + meta: [], + }, + isLoading: false, + isSuccess: true, + isError: false, + error: null, + }); + + renderInner('row-1'); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + const pushedStack = setterFor('sidePanelSourceStack').mock.calls[0][0]; + expect(pushedStack[0]).toMatchObject({ + originRowId: [ + SqlString.format('?=?', [SqlString.raw('TraceId'), 'origin-trace']), + SqlString.format('?=?', [SqlString.raw('SpanId'), 'origin-span']), + ].join(' AND '), + }); + }); + + it('pops back to the previous breadcrumb when the link targets the span it was pushed from', () => { + // One frame deep, and that frame remembers it was entered from the exact + // span this link points at: A -> B, now B links back to A. + mockQueryStore['sidePanelSourceStack'] = [ + { + sourceId: 'stack-log-src', + rowId: 'TraceId=whatever', + aliasWith: [], + label: 'Trace bbbb', + sourceKind: 'log', + // The canonical row id a hop targeting LINK's span would compute. + originRowId: [ + SqlString.format('?=?', [SqlString.raw('TraceId'), LINK.TraceId]), + SqlString.format('?=?', [SqlString.raw('SpanId'), LINK.SpanId]), + ].join(' AND '), + originTab: 'overview', + }, + ]; + mockQueryStore['sidePanelStackRoot'] = 'row-1'; + + renderInner('row-1'); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledTimes(1); + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledWith([]); + // The level we return to gets its tab back. + expect(setterFor('sidePanelTab')).toHaveBeenCalledWith('overview'); + }); + + it('pushes normally when the link targets a span other than the previous breadcrumb', () => { + mockQueryStore['sidePanelSourceStack'] = [ + { + sourceId: 'stack-log-src', + rowId: 'TraceId=whatever', + aliasWith: [], + label: 'Trace bbbb', + sourceKind: 'log', + originRowId: "TraceId='some-other-trace' AND SpanId='some-other-span'", + }, + ]; + mockQueryStore['sidePanelStackRoot'] = 'row-1'; + + renderInner('row-1'); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + const pushedStack = setterFor('sidePanelSourceStack').mock.calls[0][0]; + expect(pushedStack).toHaveLength(2); + }); + it('does not push when the current row has no resolvable trace source', () => { // Root source with no traceSourceId: traceSourceData never resolves, so // the guard in handleOpenLinkedTrace should no-op instead of throwing. diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx index c6f2a8b421..33d546bfbc 100644 --- a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx @@ -105,6 +105,23 @@ jest.mock('../TimelineChart/utils', () => ({ __esModule: true, renderMs: () => '', })); +// The linked-span lookups issue real queries through useQueriedChartConfig, +// whose providers this harness doesn't set up. +jest.mock('../linkedSpans', () => ({ + __esModule: true, + useReverseSpanLinks: () => ({ links: [], isLoading: false, error: null }), + useLinkedSpanDetails: () => ({ + details: new Map(), + isLoading: false, + error: null, + }), + linkedSpanKey: (traceId: string, spanId: string) => `${traceId}:${spanId}`, + LinkedSpanMetaLine: () => null, +})); +jest.mock('../SpanLinkedFromSubpanel', () => ({ + __esModule: true, + SpanLinkedFromSubpanel: () => null, +})); jest.mock('../DrawerUtils', () => ({ __esModule: true, DrawerFullWidthToggle: () => null, diff --git a/packages/app/src/components/__tests__/SpanLinkedFromSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinkedFromSubpanel.test.tsx new file mode 100644 index 0000000000..6fd72b7fef --- /dev/null +++ b/packages/app/src/components/__tests__/SpanLinkedFromSubpanel.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { LinkedSpanDetails } from '@/components/linkedSpans'; +import { SpanLinkedFromSubpanel } from '@/components/SpanLinkedFromSubpanel'; + +describe('SpanLinkedFromSubpanel', () => { + const LINK: LinkedSpanDetails = { + TraceId: 'eeee5555ffff6666aaaa7777bbbb8888', + SpanId: '5555666677778888', + spanName: 'consume message', + serviceName: 'consumer-svc', + timestamp: '2024-01-02 12:00:01', + durationMs: 42, + }; + + it('renders nothing when there are no links', () => { + const { container } = renderWithMantine( + , + ); + // The Mantine provider injects