GitNexus/gitnexus-web/test/unit/graph-load-decision.test.ts
Gergő Magyar 6dc6544365
fix(web): chat-only mode for large projects to prevent WebUI hang (#2178) (#2185)
* feat(web): add graph-load skip decision helper and node threshold (#2178)

* feat(web): skip graph download in connectToServer for chat-only mode (#2178)

* feat(web): add graphMode state and empty-graph chat-only handling (#2178)

* feat(web): read and thread ?skipGraph URL param through connect flow (#2178)

* feat(web): chat-only empty state with load-graph-anyway escape hatch (#2178)

* style(web): apply prettier formatting to graph-load files (#2178)

* fix(review): apply autofix feedback

- Fail-safe confirm + authoritative node count (P1: prevent re-triggering the hang via Load-graph-anyway when count unknown)
- In-flight guard on loadGraphAnyway (P1: double-fire)
- Honor explicit ?skipGraph in onAnalyzeComplete and DropZone (R6/U4)
- Extract buildGraphFromConnectResult shared helper (DRY across 3 connect sites)
- Add tests: switchRepo skip path, threshold config override, loadGraphAnyway error path, confirm fail-safe, in-flight guard

* fix(review): address tri-review findings

- P1 (correctness+adversarial+risk): stop the cross-repo / F5 chat-only leak.
  loadGraphAnyway no longer persists ?skipGraph=0, and onAnalyzeComplete +
  DropZone no longer inherit a stale ?skipGraph for a different repo — both
  could bypass auto-detect and re-trigger the #2178 hang. ?skipGraph is now a
  bookmark hint honored only by the initial auto-connect; in-session repo
  changes auto-detect.
- P2 (performance): auto-detect now also skips on edge count (edge-driven
  force-layout cliff), not just nodes; LARGE_GRAPH_EDGE_THRESHOLD default 50K.
- P2 (julik): reset graphMode/chatOnlyNodeCount at the top of switchRepo so a
  failed switch can't leave a stale chat-only overlay.
- P2 (julik): set serverBaseUrl before awaiting handleServerConnect in
  auto-connect so the Load-graph-anyway button isn't briefly a no-op.
- P2 (risk): hide the misleading '0 nodes / 0 edges' stats in chat-only mode
  (Header + StatusBar).
- P2 (performance): guard the GraphCanvas layout effect against the empty
  chat-only graph.
- Tests: edge-threshold decision + connectToServer edge-trigger; load-anyway
  no longer asserts URL persistence.

* fix(web): make Load-graph-anyway cancellable, unmount-safe, fail-safe confirm (#2178)

- AbortController + mountedRef: cancel the in-flight download on unmount and
  guard every post-await setState by the mounted ref (an abort surfaces as a
  BackendError, not a DOMException AbortError, so name-checks would miss it)
- Stale-result guard: a load-anyway that resolves after a concurrent switchRepo
  no longer clobbers the new repo's graph/mode/count
- GraphCanvas confirm fails SAFE (treat as declined) when window.confirm is
  unavailable or throws, instead of silently proceeding into a large download

* fix(web): make the AI agent and chat surface aware of chat-only mode (#2178)

- buildDynamicSystemPrompt + createGraphRAGAgent take a chatOnly flag and append
  a note (both prompt branches) that supersedes VISUAL GROUNDING: the graph isn't
  loaded, [[Type:Name]] node citations won't highlight, prefer [[path:START-END]]
- initializeAgent resolves chatOnly = opts ?? graphModeRef.current==='chatOnly':
  connect-flow callers (handleServerConnect, switchRepo, loadGraphAnyway re-init)
  pass it explicitly; lazy/settings re-inits fall back to live mode via the ref
- loadGraphAnyway re-inits the agent (chatOnly:false) after a full load so the
  prompt drops the note
- RightPanel shows a chat-only banner so the degradation is visible where AI
  output renders (en + zh-CN)

* fix(web): streaming circuit breaker for graphs with missing size stats (#2178)

- GraphTooLargeError + a mid-stream breaker in parseNdjsonGraphResponse: count
  nodes/relationships as they arrive and abort (cancel reader in try/finally,
  then throw) the moment either crosses its limit — reusing the existing node/
  edge thresholds, no new magic constant. Throwing right after the offending
  push means a later error record in the same chunk can't pre-empt it.
- fetchGraph gains optional maxNodes/maxEdges (off by default → existing callers
  unchanged). connectToServer arms them only for auto-detect downloads
  (skipGraph !== false) and catches GraphTooLargeError → chat-only, re-throwing
  every other error. This backstops the no-stats fail-open path that could
  otherwise re-trigger the original hang.

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-13 11:07:58 +01:00

145 lines
4.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
decideSkipGraph,
parseSkipGraphParam,
shouldConfirmGraphLoad,
} from '../../src/lib/graph-load-decision';
const THRESHOLD = 25_000;
const EDGE_THRESHOLD = 50_000;
describe('decideSkipGraph', () => {
it('auto-detects: skips when node count exceeds the threshold', () => {
expect(decideSkipGraph({ explicit: undefined, nodeCount: 300_000, threshold: THRESHOLD })).toBe(
true,
);
});
it('auto-detects: keeps the full graph for small projects', () => {
expect(decideSkipGraph({ explicit: undefined, nodeCount: 500, threshold: THRESHOLD })).toBe(
false,
);
});
it('explicit choice overrides auto-detection in both directions', () => {
// Force chat-only even for a tiny repo.
expect(decideSkipGraph({ explicit: true, nodeCount: 10, threshold: THRESHOLD })).toBe(true);
// Force a full graph even for a huge repo.
expect(decideSkipGraph({ explicit: false, nodeCount: 999_999, threshold: THRESHOLD })).toBe(
false,
);
});
it('uses strictly-greater comparison at the threshold boundary', () => {
expect(
decideSkipGraph({ explicit: undefined, nodeCount: THRESHOLD, threshold: THRESHOLD }),
).toBe(false);
expect(
decideSkipGraph({ explicit: undefined, nodeCount: THRESHOLD + 1, threshold: THRESHOLD }),
).toBe(true);
});
it('fails open to a full download when the node count is unknown', () => {
expect(
decideSkipGraph({ explicit: undefined, nodeCount: undefined, threshold: THRESHOLD }),
).toBe(false);
expect(decideSkipGraph({ explicit: undefined, nodeCount: null, threshold: THRESHOLD })).toBe(
false,
);
expect(decideSkipGraph({ explicit: undefined, nodeCount: NaN, threshold: THRESHOLD })).toBe(
false,
);
});
it('skips on the edge count even when nodes are under the node threshold', () => {
// Edge-heavy, node-light repo: 20K nodes (< 25K) but 80K edges (> 50K).
expect(
decideSkipGraph({
explicit: undefined,
nodeCount: 20_000,
threshold: THRESHOLD,
edgeCount: 80_000,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(true);
});
it('does not skip when both node and edge counts are under their thresholds', () => {
expect(
decideSkipGraph({
explicit: undefined,
nodeCount: 5_000,
threshold: THRESHOLD,
edgeCount: 10_000,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(false);
});
it('explicit choice overrides the edge auto-detect too', () => {
expect(
decideSkipGraph({
explicit: false,
nodeCount: 1,
threshold: THRESHOLD,
edgeCount: 999_999,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(false);
});
it('fails open when edge count is unknown and nodes are under threshold', () => {
expect(
decideSkipGraph({
explicit: undefined,
nodeCount: 5_000,
threshold: THRESHOLD,
edgeCount: undefined,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(false);
});
});
describe('parseSkipGraphParam', () => {
it('parses affirmative values to true', () => {
expect(parseSkipGraphParam('1')).toBe(true);
expect(parseSkipGraphParam('true')).toBe(true);
expect(parseSkipGraphParam('TRUE')).toBe(true);
expect(parseSkipGraphParam(' true ')).toBe(true);
});
it('parses negative values to false', () => {
expect(parseSkipGraphParam('0')).toBe(false);
expect(parseSkipGraphParam('false')).toBe(false);
expect(parseSkipGraphParam('False')).toBe(false);
});
it('returns undefined for missing or unrecognized values', () => {
expect(parseSkipGraphParam(null)).toBeUndefined();
expect(parseSkipGraphParam(undefined)).toBeUndefined();
expect(parseSkipGraphParam('')).toBeUndefined();
expect(parseSkipGraphParam('yes')).toBeUndefined();
expect(parseSkipGraphParam('2')).toBeUndefined();
});
});
describe('shouldConfirmGraphLoad', () => {
it('confirms for a large repo', () => {
expect(shouldConfirmGraphLoad(300_000, THRESHOLD)).toBe(true);
expect(shouldConfirmGraphLoad(THRESHOLD + 1, THRESHOLD)).toBe(true);
});
it('does NOT confirm for a small repo at or below the threshold', () => {
expect(shouldConfirmGraphLoad(500, THRESHOLD)).toBe(false);
expect(shouldConfirmGraphLoad(THRESHOLD, THRESHOLD)).toBe(false);
});
it('confirms (fail-safe) when the node count is unknown', () => {
// The key regression guard: an unknown count must NOT silently re-load,
// which would risk re-introducing the #2178 hang.
expect(shouldConfirmGraphLoad(null, THRESHOLD)).toBe(true);
expect(shouldConfirmGraphLoad(undefined, THRESHOLD)).toBe(true);
expect(shouldConfirmGraphLoad(NaN, THRESHOLD)).toBe(true);
});
});