mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(extractors): resolve 3 silent contract mis-resolution bugs (#793) Addresses Codex adversarial review findings for extractor contract resolution on the new group extractor surface. F1 (manifest-extractor): resolveSymbol passed the full "METHOD::path" contract string through normalizeRoutePath, producing "/GET::/api/orders" which never matches Route.name. Adds parseHttpContract() helper that strips the METHOD:: prefix before path normalization. Contract ID construction (buildContractId) is unchanged. F2 (http-route-extractor): graph-assisted backfill used path-only detections.find(), so multi-verb same-URL files attached the wrong verb/handler to provider rows and inferred the wrong verb on FETCHES consumer edges. Now requires path+method match when method is known, and skips backfill when method is unknown and multiple detections tie on path. F3 (grpc-extractor): resolveProtoConflict seeded bestScore=-1 and only replaced on strict >, so all-zero-score ties silently selected candidates[0]. Now computes all scores, counts ties at the top score, and returns null on ambiguity (caller skips contract emission and warns with service name + candidate paths). All three fixes are test-first; 73 tests pass across the three suites. No schema changes, no new dependencies, contract ID wire format (http::METHOD::path, grpc::pkg.Service/Method, http::*::path) preserved. * fix(extractors): address PR #817 review — ambiguous symbol pick + contract id casing Copilot + Claude review on PR #817 flagged two follow-up bugs on top of the F1/F2/F3 fixes: 1. http-route-extractor: ambiguous multi-verb case left handlerName null but still ran the CONTAINS DB query. pickSymbolUid(syms, null) then silently picked pool[0] — reintroducing handler mis-attribution via a different route than the .find() bug F2 fixed. Now gates symbol enrichment on an ambiguousCandidates flag so the file-basename fallback wins instead. 2. manifest-extractor: buildContractId passed raw user casing through for the explicit-method form, so get::/api/orders and GET::/api/orders produced different contract ids even though parseHttpContract upper-cases during lookup. Now reuses parseHttpContract + normalizeRoutePath to canonicalize both method and path, so logically equivalent manifest inputs share a contract id (and share a manifestSymbolUid fallback). Adds one regression test per bug: lowercase vs uppercase manifest contract ids must match, and ambiguous multi-verb with CONTAINS rows must not silently attach a real handler or call the CONTAINS query at all. 75 tests pass across the three extractor suites. * chore: prettier formatting
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useAutoScroll } from '../../src/hooks/useAutoScroll';
|
|
|
|
interface HarnessProps {
|
|
messages: unknown[];
|
|
isChatLoading: boolean;
|
|
}
|
|
|
|
function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) {
|
|
const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll(
|
|
messages,
|
|
isChatLoading,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div data-testid="is-at-bottom">{String(isAtBottom)}</div>
|
|
<div data-testid="container" ref={scrollContainerRef}>
|
|
{messages.length > 0 ? (
|
|
<div data-testid="messages-container" ref={messagesContainerRef}>
|
|
{messages.map((message, index) => (
|
|
<div key={index}>{String(message)}</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<button type="button" onClick={() => scrollToBottom()}>
|
|
Scroll to bottom
|
|
</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function setScrollMetrics(
|
|
element: HTMLDivElement,
|
|
metrics: { scrollTop?: number; scrollHeight?: number; clientHeight?: number },
|
|
) {
|
|
if (metrics.scrollTop !== undefined) {
|
|
Object.defineProperty(element, 'scrollTop', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: metrics.scrollTop,
|
|
});
|
|
}
|
|
|
|
if (metrics.scrollHeight !== undefined) {
|
|
Object.defineProperty(element, 'scrollHeight', {
|
|
configurable: true,
|
|
value: metrics.scrollHeight,
|
|
});
|
|
}
|
|
|
|
if (metrics.clientHeight !== undefined) {
|
|
Object.defineProperty(element, 'clientHeight', {
|
|
configurable: true,
|
|
value: metrics.clientHeight,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function flushAnimationFrame() {
|
|
await act(async () => {
|
|
vi.runAllTimers();
|
|
});
|
|
}
|
|
|
|
async function scrollContainer(element: HTMLDivElement, scrollTop: number) {
|
|
setScrollMetrics(element, { scrollTop });
|
|
fireEvent.scroll(element);
|
|
await flushAnimationFrame();
|
|
}
|
|
|
|
const resizeObserverInstances: ResizeObserverMock[] = [];
|
|
|
|
class ResizeObserverMock {
|
|
callback: ResizeObserverCallback;
|
|
observedElements: Element[] = [];
|
|
observe = vi.fn((element: Element) => {
|
|
this.observedElements.push(element);
|
|
});
|
|
unobserve = vi.fn();
|
|
disconnect = vi.fn();
|
|
|
|
constructor(callback: ResizeObserverCallback) {
|
|
this.callback = callback;
|
|
resizeObserverInstances.push(this);
|
|
}
|
|
}
|
|
|
|
async function triggerResize(instance: ResizeObserverMock) {
|
|
await act(async () => {
|
|
instance.callback([], instance as unknown as ResizeObserver);
|
|
});
|
|
await flushAnimationFrame();
|
|
}
|
|
|
|
describe('useAutoScroll', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
resizeObserverInstances.length = 0;
|
|
vi.stubGlobal(
|
|
'requestAnimationFrame',
|
|
vi.fn((callback: FrameRequestCallback) => {
|
|
return window.setTimeout(() => callback(performance.now()), 0);
|
|
}),
|
|
);
|
|
vi.stubGlobal(
|
|
'cancelAnimationFrame',
|
|
vi.fn((frameId: number) => {
|
|
clearTimeout(frameId);
|
|
}),
|
|
);
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
|
configurable: true,
|
|
value: function (options: ScrollToOptions) {
|
|
if (options.top !== undefined) {
|
|
Object.defineProperty(this, 'scrollTop', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: options.top,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('starts with isAtBottom true and auto-scrolls the very first message', () => {
|
|
const { rerender } = render(<AutoScrollHarness messages={[]} isChatLoading={false} />);
|
|
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
|
|
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
setScrollMetrics(container, { scrollTop: 0, scrollHeight: 500, clientHeight: 200 });
|
|
|
|
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
|
|
expect(container.scrollTop).toBe(500);
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
|
|
});
|
|
|
|
it('follows streaming updates while the view stays pinned to the bottom', () => {
|
|
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
|
|
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
|
|
|
|
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={true} />);
|
|
|
|
expect(container.scrollTop).toBe(1000);
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
|
|
});
|
|
|
|
it('stops auto-scroll after the user scrolls up', async () => {
|
|
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
|
|
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
|
|
await scrollContainer(container, 700);
|
|
await scrollContainer(container, 250);
|
|
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
|
|
|
|
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 });
|
|
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
|
|
|
|
expect(container.scrollTop).toBe(250);
|
|
});
|
|
|
|
it('re-enables auto-scroll once the user returns near the bottom', async () => {
|
|
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
|
|
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
|
|
await scrollContainer(container, 700);
|
|
await scrollContainer(container, 250);
|
|
|
|
setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1400, clientHeight: 200 });
|
|
await scrollContainer(container, 1120);
|
|
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
|
|
|
|
setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1800, clientHeight: 200 });
|
|
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
|
|
|
|
expect(container.scrollTop).toBe(1800);
|
|
});
|
|
|
|
it('scrollToBottom re-engages auto-scroll and scrolls to the container bottom', async () => {
|
|
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
const scrollTo = vi.spyOn(container, 'scrollTo');
|
|
|
|
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
|
|
await scrollContainer(container, 700);
|
|
await scrollContainer(container, 250);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Scroll to bottom' }));
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
|
|
|
|
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1600, clientHeight: 200 });
|
|
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
|
|
|
|
expect(container.scrollTop).toBe(1600);
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
|
|
});
|
|
|
|
it('re-pins to the latest bottom when inner content grows asynchronously', async () => {
|
|
render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
|
|
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
|
|
await scrollContainer(container, 700);
|
|
|
|
setScrollMetrics(container, { scrollTop: 1000, scrollHeight: 1450, clientHeight: 200 });
|
|
await triggerResize(resizeObserverInstances[0]);
|
|
|
|
expect(container.scrollTop).toBe(1450);
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
|
|
});
|
|
|
|
it('does not auto-scroll on async growth after user intentionally scrolls away', async () => {
|
|
render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
|
|
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
|
|
await scrollContainer(container, 700);
|
|
await scrollContainer(container, 250);
|
|
|
|
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 });
|
|
await triggerResize(resizeObserverInstances[0]);
|
|
|
|
expect(container.scrollTop).toBe(250);
|
|
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
|
|
});
|
|
|
|
it('cancels the pending ResizeObserver rAF when the component unmounts', () => {
|
|
const cancelRAF = vi.mocked(cancelAnimationFrame);
|
|
|
|
const { unmount } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
const container = screen.getByTestId('container') as HTMLDivElement;
|
|
|
|
setScrollMetrics(container, { scrollTop: 950, scrollHeight: 1000, clientHeight: 200 });
|
|
|
|
const callsBefore = cancelRAF.mock.calls.length;
|
|
|
|
act(() => {
|
|
resizeObserverInstances[0].callback(
|
|
[],
|
|
resizeObserverInstances[0] as unknown as ResizeObserver,
|
|
);
|
|
});
|
|
|
|
unmount();
|
|
|
|
expect(cancelRAF.mock.calls.length).toBeGreaterThan(callsBefore);
|
|
|
|
expect(() => vi.runAllTimers()).not.toThrow();
|
|
});
|
|
|
|
it('attaches the observer when the messages wrapper first appears and disconnects on unmount', () => {
|
|
const { rerender, unmount } = render(<AutoScrollHarness messages={[]} isChatLoading={false} />);
|
|
|
|
expect(screen.queryByTestId('messages-container')).toBeNull();
|
|
expect(resizeObserverInstances).toHaveLength(0);
|
|
|
|
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
|
|
|
|
const messagesContainer = screen.getByTestId('messages-container');
|
|
const resizeObserver = resizeObserverInstances[0];
|
|
|
|
expect(resizeObserverInstances).toHaveLength(1);
|
|
expect(resizeObserver.observe).toHaveBeenCalledWith(messagesContainer);
|
|
|
|
unmount();
|
|
|
|
expect(resizeObserver.disconnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|