mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
fix(ui): load deep Galaxy layers in large pages, show progress, allow cancel
Hand test K8. Expanding JSONBAgg to three layers ran for minutes: the server re-executes a query for every continuation page and returns only about 30 rows of scope columns per page under its default output budget, so one layer became more than 500 sequential calls. The scope loader now asks for pages of up to 5000 rows. Layer three of JSONBAgg finishes in about 8 s with 11 calls. While a layer loads, the toolbar names it with the nodes and edges loaded so far, the canvas says the picture is a partial preview, and the minus button cancels the load and returns at once to the previous, complete layer. A layer that grows past the render limits stops there, is marked partial with the limit in its tooltip and is never the base of a further layer. The Expand tooltip states the frontier and the growth of the last layer, without promising a count. Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
1 parent
80b8f7965f
commit
a436eacf43
11 files changed
+378
-29
No files matched your search
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Handtest K8: eine tiefe Ebene haengt nicht mehr minutenlang. Die Leiste
|
||||
* zeigt, was schon geladen ist, "−" bricht das Laden ab und kehrt sofort zur
|
||||
* vorigen Ebene zurueck, und eine Ebene ueber dem Render-Limit stoppt dort und
|
||||
* sagt, dass sie unvollstaendig ist.
|
||||
*/
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import GalaxyPanel from './GalaxyPanel';
|
||||
import { viewPreferencesKey } from '../settings/view-preferences';
|
||||
import { scopeFetch, scopeNode } from './test-scope-fetch';
|
||||
import type { GraphEdge } from './types';
|
||||
|
||||
vi.mock('./GraphScene', async importOriginal => ({
|
||||
...await importOriginal<typeof import('./GraphScene')>(),
|
||||
GraphScene: () => <output data-testid="scene" />,
|
||||
}));
|
||||
|
||||
let host: HTMLDivElement, root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
const seam = () => globalThis.__atlasGalaxy!;
|
||||
const settle = (check: () => void) => vi.waitFor(async () => {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
check();
|
||||
});
|
||||
const status = () => host.querySelector('.atlas-graph-scope-count');
|
||||
const button = (label: string) => [...host.querySelectorAll('button')].find(entry => entry.textContent === label)!;
|
||||
const minus = () => host.querySelector<HTMLButtonElement>('button[aria-label="Remove graph layer"]')!;
|
||||
const layers = () => [...host.querySelectorAll('.atlas-graph-exploration span')].map(entry => entry.textContent ?? '').find(text => /^\d+ layers?$/.test(text));
|
||||
|
||||
it('K8: shows loaded nodes and edges while a layer loads, and "−" cancels back to the previous layer at once', async () => {
|
||||
const nodes = [1, 2, 3, 4, 5, 6].map(id => scopeNode(id));
|
||||
const edges: GraphEdge[] = [{ id: 1, source: 1, target: 2, type: 'CALLS' }, { id: 2, source: 2, target: 3, type: 'CALLS' },
|
||||
{ id: 3, source: 4, target: 2, type: 'CALLS' }, { id: 4, source: 3, target: 5, type: 'CALLS' }, { id: 5, source: 6, target: 1, type: 'TESTS' }];
|
||||
let block = false, release: () => void = () => {};
|
||||
let edgeQueries = 0;
|
||||
const { fetch } = scopeFetch({ nodes, edges, gate: async () => {
|
||||
edgeQueries += 1;
|
||||
// Layer 2: the outbound batch arrives, the inbound one hangs like the slow server.
|
||||
if (block && edgeQueries > 1) await new Promise<void>(resolve => { release = resolve; });
|
||||
} });
|
||||
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={fetch} />));
|
||||
await settle(() => expect(seam().nodes).toBe(6));
|
||||
await act(async () => { seam().clickNode('sample.n1'); });
|
||||
await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges'));
|
||||
expect(minus().disabled).toBe(true);
|
||||
|
||||
block = true; edgeQueries = 0;
|
||||
await act(async () => button('Expand +1').click());
|
||||
await settle(() => expect(status()?.textContent).toBe('Loading layer 2: 4 nodes, 3 edges so far'));
|
||||
expect(status()?.getAttribute('data-state')).toBe('loading');
|
||||
// While loading, "−" is the way out, and it says so.
|
||||
expect(minus().disabled).toBe(false);
|
||||
expect(minus().title).toBe('Cancel loading layer 2 and return to 1 layer');
|
||||
await act(async () => minus().click());
|
||||
// At once: the previous layer is complete again, nothing waits for the hanging request.
|
||||
expect(layers()).toBe('1 layer');
|
||||
expect(status()?.getAttribute('data-state')).not.toBe('loading');
|
||||
expect(minus().disabled).toBe(true);
|
||||
await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges'));
|
||||
await act(async () => { release(); });
|
||||
await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges'));
|
||||
});
|
||||
|
||||
it('K8: a layer past the render limit stops there, says it is partial and cannot be expanded further', async () => {
|
||||
window.localStorage.setItem(viewPreferencesKey('sample'), JSON.stringify({ version: 1, preferences: { galaxyNodes: 500, galaxyEdges: 1000 } }));
|
||||
const fan = Array.from({ length: 600 }, (_, at) => scopeNode(100 + at));
|
||||
const nodes = [scopeNode(1), scopeNode(2), ...fan];
|
||||
const edges: GraphEdge[] = [{ id: 1, source: 1, target: 2, type: 'CALLS' },
|
||||
...fan.map((node, at) => ({ id: 10 + at, source: 2, target: node.id, type: 'CALLS' }))];
|
||||
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={scopeFetch({ nodes, edges }).fetch} />));
|
||||
await settle(() => expect(seam().nodes).toBeGreaterThan(0));
|
||||
await act(async () => { seam().clickNode('sample.n1'); });
|
||||
await settle(() => expect(status()?.textContent).toBe('2 nodes · 1 edge'));
|
||||
expect(button('Expand +1').title).toContain('Load layer 2: 1 node to expand');
|
||||
|
||||
await act(async () => button('Expand +1').click());
|
||||
await settle(() => expect(status()?.textContent).toBe('602 nodes · 601 edges · partial'));
|
||||
expect(status()?.getAttribute('data-state')).toBe('partial');
|
||||
expect(status()?.getAttribute('title')).toBe('Layer 2 stopped at the render limit of 500 nodes. Raise the limit under Limits or trace fewer edge types to load all of it.');
|
||||
expect(button('Expand +1').disabled).toBe(true);
|
||||
expect(button('Expand +1').title).toContain('stopped at the render limit');
|
||||
// The partial layer can still be left the normal way.
|
||||
await act(async () => minus().click());
|
||||
await settle(() => expect(status()?.textContent).toBe('2 nodes · 1 edge'));
|
||||
});
|
||||
@@ -114,7 +114,8 @@ it('does not report a file missing from a capped layout while its indexed scope
|
||||
return source(url, init);
|
||||
});
|
||||
await act(async () => root.render(<GalaxyPanel project="sample" visible focusFilePath="src/a.ts" onOpenNode={vi.fn()} fetch={fetchImpl} />));
|
||||
expect(host.textContent).toContain('Loading relationships');
|
||||
// Hand test K8: the status names the layer that is loading instead of an endless "Loading relationships".
|
||||
expect(host.textContent).toContain('Loading layer 1…');
|
||||
expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull();
|
||||
await act(async () => { release(); });
|
||||
expect(host.textContent).toContain('All indexed direct dependencies included.');
|
||||
|
||||
@@ -139,13 +139,13 @@ import GalaxyNavigator from './GalaxyNavigator';
|
||||
import { TraceEdgeFilter } from './TraceEdgeFilter';
|
||||
import { PathPicker, PathSteps } from './ScopePathControls';
|
||||
import { callOrder, pathNodes, shortestScopePath, type ScopePathStep } from './scope-path';
|
||||
import { galaxyHistoryText, galaxyPathText, galaxyToolbarText } from './galaxy-strings';
|
||||
import { galaxyHistoryText, galaxyLayerText, galaxyPathText, galaxyToolbarText } from './galaxy-strings';
|
||||
import { emptyNavigationHistory, moveNavigation, peekNavigation, pushNavigation } from '../graph/navigation-history';
|
||||
import { galaxyHistoryOptions, historyEntryDetail, historyEntryLabel, scopeIdentity, type GalaxyHistoryEntry, type ScopeTrail } from './scope-history';
|
||||
import { useOrganicLayout } from './use-organic-layout';
|
||||
import RenderProgress from './RenderProgress';
|
||||
import { useGraphScope } from './use-graph-scope';
|
||||
import { limitGraphRender, scenePictureFor, scopedHierarchy } from './graph-scope';
|
||||
import { limitGraphRender, nextLayerEstimate, scenePictureFor, scopedHierarchy } from './graph-scope';
|
||||
import './graph-exploration.css';
|
||||
import { layoutNodeForSelection } from './selected-node';
|
||||
import { galaxyScopeEvidence, useSelectionEvidence, type SelectionEvidenceListener } from './selection-evidence';
|
||||
@@ -769,7 +769,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
const changeTraceTypes = useCallback((types: string[] | undefined) => setTraceFilter({ project, types }), [project]);
|
||||
const scope = useGraphScope({ project, layout,
|
||||
filePath: props.workspaceExpanded ? undefined : props.focusFilePath,
|
||||
range: props.focusSourceRange, fetch: props.fetch, edgeTypes: traceTypes });
|
||||
range: props.focusSourceRange, fetch: props.fetch, edgeTypes: traceTypes,
|
||||
// Handtest K8: eine Ebene ueber dem Render-Limit haelt dort an, statt minutenlang weiterzuladen.
|
||||
...(props.workspaceExpanded ? { limits: { nodes: nodeBudget, edges: edgeBudget } } : {}) });
|
||||
const organicHistory = useRef<{ key: string; depth: number; data: GraphData } | undefined>(undefined);
|
||||
const organicKey = JSON.stringify([project, scope.scope, scope.direction, traceTypes]);
|
||||
const organicOptions = useMemo(() => {
|
||||
@@ -1985,6 +1987,16 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
</details>}
|
||||
</span> : null;
|
||||
|
||||
/* Was die Leiste ueber das Laden der Ebenen sagt (Handtest K8). */
|
||||
const partial = scope.complete ? scope.result?.partial : undefined;
|
||||
const scopeState = scope.validating ? 'checking' : scope.loading ? 'loading' : organicTask.loading ? 'arranging'
|
||||
: partial ? 'partial' : scope.complete ? 'complete' : 'preview';
|
||||
const estimate = scope.complete ? nextLayerEstimate(scope.result) : undefined;
|
||||
const expandWarning = Boolean(estimate && props.workspaceExpanded && (data?.nodes.length ?? 0) + estimate.estimate > nodeBudget);
|
||||
const expandTitle = partial ? galaxyLayerText.expandPartial : scope.result?.exhausted ? galaxyLayerText.expandEnd
|
||||
: estimate ? `${expandWarning ? `${galaxyLayerText.expandOverLimit} ` : ''}${galaxyLayerText.expandTitle(estimate.layer, estimate.frontier, estimate.estimate, nodeBudget)}`
|
||||
: undefined;
|
||||
|
||||
/*
|
||||
* Die Vorgabe der Ansicht, mit der Wahl des Lesers darauf.
|
||||
*
|
||||
@@ -2474,10 +2486,17 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
<option value="both">Both directions</option><option value="inbound">Incoming</option><option value="outbound">Outgoing</option>
|
||||
</select></label>}
|
||||
{props.workspaceExpanded && <TraceEdgeFilter kinds={traceKinds} availableTypes={kinds.map(kind => kind.type)} selected={traceTypes} onChange={changeTraceTypes} />}
|
||||
<button type="button" disabled={scope.depth <= scope.minDepth || scope.loading}
|
||||
{/*
|
||||
* Handtest K8: waehrend eine Ebene laedt, ist "−" der Weg
|
||||
* hinaus. Es bricht das Laden ab und steht sofort wieder auf
|
||||
* der vorigen, schon vollstaendigen Ebene.
|
||||
*/}
|
||||
<button type="button" disabled={scope.depth <= scope.minDepth}
|
||||
title={scope.loading ? galaxyLayerText.cancelLoading(scope.depth) : galaxyLayerText.removeLayer}
|
||||
onClick={() => scope.setDepth(scope.depth - 1)} aria-label="Remove graph layer">−</button>
|
||||
<span>{scope.depth} {scope.depth === 1 ? 'layer' : 'layers'}</span>
|
||||
<button type="button" disabled={scope.loading || scope.result?.exhausted}
|
||||
<button type="button" disabled={scope.loading || scope.result?.exhausted || Boolean(scope.result?.partial)}
|
||||
data-warning={expandWarning || undefined} title={expandTitle}
|
||||
onClick={() => scope.setDepth(scope.depth + 1)}>Expand +1</button>
|
||||
{props.workspaceExpanded && mode === 'galaxy' && <>
|
||||
<PathPicker nodes={pathCandidates} onPick={node => { setTrail({ key: organicKey, kind: 'path', target: node.id, name: node.name }); setTrailStep(0); }} />
|
||||
@@ -2486,9 +2505,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
onClick={() => { if (trail?.key === organicKey && trail.kind === 'calls') clearTrail(); else { setTrail({ key: organicKey, kind: 'calls' }); setTrailStep(0); } }}>
|
||||
{galaxyPathText.callOrder}</button>
|
||||
</>}
|
||||
<span className="atlas-graph-scope-count" role="status">{scope.validating ? 'Checking index…' : scope.loading ? 'Loading relationships…' : organicTask.loading ? 'Arranging nodes…'
|
||||
: scope.complete ? `${data?.nodes.length ?? 0} ${data?.nodes.length === 1 ? 'node' : 'nodes'} · ${data?.edges.length ?? 0} ${data?.edges.length === 1 ? 'edge' : 'edges'}${scope.result?.exhausted ? ' · end of trace' : ''}`
|
||||
: 'Partial preview'}</span>
|
||||
<span className="atlas-graph-scope-count" role="status" data-state={scopeState}
|
||||
title={partial ? galaxyLayerText.partialTitle(partial.layer, partial.limit === 'nodes' ? nodeBudget : edgeBudget, partial.limit) : undefined}>
|
||||
{scope.validating ? galaxyLayerText.checking
|
||||
: scope.loading ? scope.progress ? galaxyLayerText.loadingProgress(scope.progress.layer, scope.progress.nodes, scope.progress.edges) : galaxyLayerText.loading(scope.depth)
|
||||
: organicTask.loading ? galaxyLayerText.arranging
|
||||
: scope.complete ? `${galaxyLayerText.counts(data?.nodes.length ?? 0, data?.edges.length ?? 0)}${partial ? galaxyLayerText.partial : scope.result?.exhausted ? galaxyLayerText.endOfTrace : ''}`
|
||||
: galaxyLayerText.partialPreview}</span>
|
||||
{scope.error && <span className="atlas-graph-scope-warning" title={scope.error}>Some relationships could not be loaded. <button type="button" onClick={scope.retry}>Retry</button></span>}
|
||||
{!props.workspaceExpanded && scope.complete && <small>All indexed direct dependencies included.</small>}
|
||||
{mode === 'galaxy' && organic && organic.groups.length > 1 && <small className="atlas-graph-scope-groups" title={galaxyToolbarText.groupsTitle(organic.groups.length)}>{galaxyToolbarText.groups(organic.groups.length)}</small>}
|
||||
@@ -2630,7 +2653,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
)}
|
||||
<div className="atlas-galaxy-scene" data-testid="atlas-galaxy-scene" ref={scene}
|
||||
aria-busy={layoutLoading || scope.loading || organicTask.loading || spacingBusy}>
|
||||
<RenderProgress busy={visible && (layoutLoading || scope.loading || organicTask.loading || spacingBusy)} />
|
||||
<RenderProgress busy={visible && (layoutLoading || scope.loading || organicTask.loading || spacingBusy)}
|
||||
{...(scope.scope && scope.loading ? { label: galaxyLayerText.previewLoading(scope.depth) } : {})} />
|
||||
{trailView && <PathSteps heading={trailView.heading} note={trailView.note} steps={trailView.steps} active={trailActive}
|
||||
nameOf={trailView.nameOf} lines={trailView.lines} onStep={setTrailStep} onClear={clearTrail} />}
|
||||
{/*
|
||||
|
||||
@@ -56,6 +56,34 @@ export const galaxyHistoryText = {
|
||||
pathTo: (name: string) => `path to ${name}`,
|
||||
};
|
||||
|
||||
const count = (value: number, one: string, many: string) => `${value.toLocaleString()} ${value === 1 ? one : many}`;
|
||||
|
||||
/** Loading, cancelling and the render limit of a scope layer (hand test K8). */
|
||||
export const galaxyLayerText = {
|
||||
checking: 'Checking index…',
|
||||
loading: (layer: number) => `Loading layer ${layer}…`,
|
||||
// A comma, not the dot of the finished counts: tools that wait for "N nodes · M edges" must not take this for done.
|
||||
loadingProgress: (layer: number, nodes: number, edges: number) =>
|
||||
`Loading layer ${layer}: ${count(nodes, 'node', 'nodes')}, ${count(edges, 'edge', 'edges')} so far`,
|
||||
arranging: 'Arranging nodes…',
|
||||
counts: (nodes: number, edges: number) => `${count(nodes, 'node', 'nodes')} · ${count(edges, 'edge', 'edges')}`,
|
||||
endOfTrace: ' · end of trace',
|
||||
partial: ' · partial',
|
||||
partialPreview: 'Partial preview',
|
||||
previewLoading: (layer: number) => `Partial preview while layer ${layer} loads`,
|
||||
partialTitle: (layer: number, limit: number, kind: 'nodes' | 'edges') =>
|
||||
`Layer ${layer} stopped at the render limit of ${limit.toLocaleString()} ${kind}. Raise the limit under Limits or trace fewer edge types to load all of it.`,
|
||||
removeLayer: 'Remove the outermost layer',
|
||||
cancelLoading: (layer: number) => `Cancel loading layer ${layer} and return to ${layer - 1 === 1 ? '1 layer' : `${layer - 1} layers`}`,
|
||||
/* Measured, not promised: a hub at the edge can bring far more than the last layer did (JSONBAgg layer 3: about 400 expected, over 9,000 loaded). */
|
||||
expandTitle: (layer: number, frontier: number, estimate: number, limit: number) =>
|
||||
`Load layer ${layer}: ${count(frontier, 'node', 'nodes')} to expand. Growing like the last layer it adds about ${estimate.toLocaleString()} nodes; `
|
||||
+ `a hub can add many more. Loading stops at the render limit of ${limit.toLocaleString()} nodes and marks the layer partial.`,
|
||||
expandOverLimit: 'Likely past the render limit.',
|
||||
expandPartial: 'This layer stopped at the render limit, so there is no complete edge to grow from. Raise the limit under Limits first.',
|
||||
expandEnd: 'End of trace: no relationship leads further.',
|
||||
};
|
||||
|
||||
/** Compact toolbar words, so the scoped toolbar keeps to one row at 1600 px. */
|
||||
export const galaxyToolbarText = {
|
||||
groups: (count: number) => `${count.toLocaleString()} groups`,
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
.atlas-graph-scope-name { max-width:14rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.atlas-graph-scope-count { color:#93b2a1; margin-inline-start:auto; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
.atlas-graph-scope-groups { white-space:nowrap; }
|
||||
/* Handtest K8: eine Ebene, die am Render-Limit anhielt, und ein Expand, das vermutlich dorthin fuehrt. */
|
||||
.atlas-graph-scope-count[data-state=partial] { color:#d8b27a; cursor:help; }
|
||||
.atlas-graph-exploration button[data-warning=true] { border-color:#9b7b4a; }
|
||||
/* Der Deckel im Scope: dieselbe Bauform wie die Kantenarten (Review-Befund G7). */
|
||||
.atlas-graph-limits { position:relative; }
|
||||
.atlas-graph-limits > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { arrangeScopedGraph, graphEdgeTypesKey, limitGraphRender, loadGraphScope, readGraphPages, scenePictureFor, scopedHierarchy, type GraphQueryClient } from './graph-scope';
|
||||
import { arrangeScopedGraph, graphEdgeTypesKey, limitGraphRender, loadGraphScope, nextLayerEstimate, readGraphPages, scenePictureFor, scopedHierarchy, type GraphQueryClient } from './graph-scope';
|
||||
import type { QueryGraphResult } from '../provider/rpc-schemas';
|
||||
import type { GraphData, GraphNode } from './types';
|
||||
|
||||
@@ -239,3 +239,63 @@ it('keeps the whole graph on screen while the first scope picture is still empty
|
||||
expect(scenePictureFor(current, stale, layout)).toBe(current);
|
||||
expect(scenePictureFor(undefined, stale, layout)).toBe(stale);
|
||||
});
|
||||
|
||||
describe('handtest K8: deep layers load in few large pages, report progress and stop at the render limit', () => {
|
||||
const scope = { kind: 'node' as const, id: 1, name: 'n1', qualifiedName: 'p.n1' };
|
||||
const star = (from: GraphNode, count: number, first: number) => Array.from({ length: count }, (_, at) => edgeRow(first + at, from, node(first + at)));
|
||||
|
||||
it('asks for large pages, so one hop is not split into dozens of re-executed continuations', async () => {
|
||||
const a = node(1), b = node(2);
|
||||
const queryGraph = vi.fn<GraphQueryClient['queryGraph']>().mockResolvedValueOnce(page([row(a)]))
|
||||
.mockResolvedValueOnce(page([edgeRow(1, a, b)])).mockResolvedValueOnce(page([]));
|
||||
await loadGraphScope('p', scope, 1, 'both', undefined, { client: { queryGraph } });
|
||||
expect(queryGraph).toHaveBeenCalledTimes(3);
|
||||
for (const call of queryGraph.mock.calls) {
|
||||
expect(call[3]?.maxRows).toBeGreaterThanOrEqual(2000);
|
||||
expect(call[3]?.maxOutputTokens).toBeGreaterThanOrEqual(100_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports loaded nodes and edges after every request', async () => {
|
||||
const a = node(1), b = node(2), c = node(3);
|
||||
const queryGraph = vi.fn<GraphQueryClient['queryGraph']>().mockResolvedValueOnce(page([row(a)]))
|
||||
.mockResolvedValueOnce(page([edgeRow(1, a, b)])).mockResolvedValueOnce(page([edgeRow(2, b, c)]));
|
||||
const progress = vi.fn();
|
||||
await loadGraphScope('p', scope, 2, 'outbound', undefined, { client: { queryGraph }, onProgress: progress });
|
||||
expect(progress.mock.calls.map(([value]) => [value.layer, value.nodes, value.edges, value.requests])).toEqual([[1, 2, 1, 2], [2, 3, 2, 3]]);
|
||||
});
|
||||
|
||||
it('stops at the node limit, marks the layer partial and never continues from it', async () => {
|
||||
const a = node(1), b = node(2);
|
||||
const queryGraph = vi.fn<GraphQueryClient['queryGraph']>().mockResolvedValueOnce(page([row(a)]))
|
||||
.mockResolvedValueOnce(page([edgeRow(1, a, b)]))
|
||||
.mockResolvedValueOnce(page(star(b, 6, 10)));
|
||||
const result = await loadGraphScope('p', scope, 3, 'outbound', undefined, { client: { queryGraph }, limits: { nodes: 5, edges: 1000 } });
|
||||
expect(result.partial).toEqual({ layer: 2, nodes: 8, edges: 7, limit: 'nodes' });
|
||||
expect(result.exhausted).toBe(false);
|
||||
expect(result.depth).toBe(2);
|
||||
// Hop 3 was never requested: the layer before it was already over the limit.
|
||||
expect(queryGraph).toHaveBeenCalledTimes(3);
|
||||
const fresh = vi.fn<GraphQueryClient['queryGraph']>().mockResolvedValueOnce(page([row(a)])).mockResolvedValue(page([]));
|
||||
await loadGraphScope('p', scope, 3, 'outbound', undefined, { client: { queryGraph: fresh }, previous: result, limits: { nodes: 50, edges: 1000 } });
|
||||
// A partial layer is no base for the next one: the load starts again from the root.
|
||||
expect(fresh.mock.calls[0]![1]).toMatch(/^MATCH \(n\) WHERE/);
|
||||
});
|
||||
|
||||
it('estimates the next layer from the frontier and the growth of the last layer', () => {
|
||||
const nodes = [1, 2, 3, 4, 5, 6, 7].map(id => node(id));
|
||||
const levels = new Map([[1, 0], [2, 1], [3, 1], [4, 2], [5, 2], [6, 2], [7, 2]]);
|
||||
const scoped = { data: { nodes, edges: [], total_nodes: 7 }, roots: new Set([1]), depth: 2, exhausted: false, levels };
|
||||
expect(nextLayerEstimate(scoped)).toEqual({ layer: 3, frontier: 4, perNode: 2, estimate: 8 });
|
||||
expect(nextLayerEstimate({ ...scoped, exhausted: true })).toBeUndefined();
|
||||
expect(nextLayerEstimate({ ...scoped, partial: { layer: 2, nodes: 7, edges: 6, limit: 'nodes' } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops at the edge limit too', async () => {
|
||||
const a = node(1);
|
||||
const queryGraph = vi.fn<GraphQueryClient['queryGraph']>().mockResolvedValueOnce(page([row(a)]))
|
||||
.mockResolvedValueOnce(page(star(a, 4, 10)));
|
||||
const result = await loadGraphScope('p', scope, 2, 'outbound', undefined, { client: { queryGraph }, limits: { nodes: 100, edges: 3 } });
|
||||
expect(result.partial).toMatchObject({ layer: 1, edges: 4, limit: 'edges' });
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,23 @@ import type { GraphNeighborhoodTransaction } from './graph-neighborhood-cache';
|
||||
|
||||
export type TraceDirection = 'both' | 'inbound' | 'outbound';
|
||||
export type GraphScope = { kind: 'node'; id: number; name: string; qualifiedName?: string } | { kind: 'symbol'; qualifiedName: string; name: string } | { kind: 'file' | 'folder'; path: string; name: string; range?: SourceFocusRange };
|
||||
export interface ScopedGraph { data: GraphData; roots: Set<number>; depth: number; exhausted: boolean; frontier?: number[]; levels?: Map<number, number>; traversalKey?: string }
|
||||
export interface GraphQueryClient { queryGraph(project: string, query: string, cursor?: string): Promise<QueryGraphResult> }
|
||||
/** A layer that stopped at the render limit (hand test K8): what was loaded, and which limit stopped it. */
|
||||
export interface ScopePartial { layer: number; nodes: number; edges: number; limit: 'nodes' | 'edges' }
|
||||
export interface ScopedGraph { data: GraphData; roots: Set<number>; depth: number; exhausted: boolean; frontier?: number[]; levels?: Map<number, number>; traversalKey?: string; partial?: ScopePartial }
|
||||
/** How large one query_graph page may be. */
|
||||
export interface QueryBudget { maxRows?: number; maxOutputTokens?: number }
|
||||
export interface GraphQueryClient { queryGraph(project: string, query: string, cursor?: string, budget?: QueryBudget): Promise<QueryGraphResult> }
|
||||
/** What a running load has gathered so far, for the toolbar instead of an endless "Loading". */
|
||||
export interface ScopeProgress { layer: number; nodes: number; edges: number; requests: number }
|
||||
/*
|
||||
* Grosse Seiten, und das ist die eigentliche Antwort auf K8. Der Server rechnet
|
||||
* eine Abfrage fuer JEDE Fortsetzungsseite vollstaendig neu und schickt davon
|
||||
* nur ein Fenster (Vorgabe 200 Zeilen und rund 3.200 Tokens, also bei dieser
|
||||
* Spaltenbreite etwa 30 Zeilen). Die dritte Ebene um JSONBAgg brauchte so ueber
|
||||
* 500 Aufrufe nacheinander, je rund 0,6 s. Mit diesem Fenster ist eine Ebene
|
||||
* eine Handvoll Aufrufe; die Ergebnisse selbst aendern sich nicht.
|
||||
*/
|
||||
export const SCOPE_PAGE_BUDGET: QueryBudget = { maxRows: 5000, maxOutputTokens: 600_000 };
|
||||
/** Undefined includes every relationship type; an explicit empty list includes none. */
|
||||
export function graphEdgeTypesKey(edgeTypes?: readonly string[]): string {
|
||||
return JSON.stringify(edgeTypes === undefined ? null : [...new Set(edgeTypes)].sort());
|
||||
@@ -26,9 +41,11 @@ const nodeColumns = (alias: string, prefix: string) => [`id(${alias}) AS ${prefi
|
||||
/** The cursor binds a complete query result to one graph generation. Every
|
||||
* continuation is consumed; a daemon cap or missing cursor is never silently
|
||||
* accepted as the selected file's complete neighborhood. */
|
||||
export async function readGraphPages(client: GraphQueryClient, project: string, query: string, key: string, signal?: AbortSignal): Promise<Record<string, string>[]> {
|
||||
export async function readGraphPages(client: GraphQueryClient, project: string, query: string, key: string, signal?: AbortSignal,
|
||||
onRequest?: () => void): Promise<Record<string, string>[]> {
|
||||
signal?.throwIfAborted();
|
||||
let page = await client.queryGraph(project, query);
|
||||
onRequest?.();
|
||||
let page = await client.queryGraph(project, query, undefined, SCOPE_PAGE_BUDGET);
|
||||
const columns = page.columns, total = page.total, records: Record<string, string>[] = [];
|
||||
const cursors = new Set<string>(), identities = new Set<number>();
|
||||
for (;;) {
|
||||
@@ -49,7 +66,8 @@ export async function readGraphPages(client: GraphQueryClient, project: string,
|
||||
if (!page.rows.length || cursors.has(page.nextCursor)) throw new Error('Graph pagination did not advance.');
|
||||
if (page.nextOffset !== undefined && page.nextOffset !== records.length) throw new Error('Graph pagination skipped rows.');
|
||||
cursors.add(page.nextCursor); signal?.throwIfAborted();
|
||||
page = await client.queryGraph(project, query, page.nextCursor);
|
||||
onRequest?.();
|
||||
page = await client.queryGraph(project, query, page.nextCursor, SCOPE_PAGE_BUDGET);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +118,11 @@ export function arrangeScopedGraph(nodes: readonly GraphNode[], edges: readonly
|
||||
}
|
||||
|
||||
export async function loadGraphScope(project: string, scope: GraphScope, depth: number, direction: TraceDirection,
|
||||
layout: GraphData | undefined, options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; client?: GraphQueryClient; previous?: ScopedGraph; edgeTypes?: readonly string[]; cache?: GraphNeighborhoodTransaction } = {}): Promise<ScopedGraph> {
|
||||
layout: GraphData | undefined, options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; client?: GraphQueryClient; previous?: ScopedGraph; edgeTypes?: readonly string[]; cache?: GraphNeighborhoodTransaction;
|
||||
/** Called after every relationship request with what is loaded so far. */
|
||||
onProgress?: (progress: ScopeProgress) => void;
|
||||
/** The render limits: a layer that grows past them stops and is marked partial. */
|
||||
limits?: { nodes: number; edges: number } } = {}): Promise<ScopedGraph> {
|
||||
options.signal?.throwIfAborted();
|
||||
const client = options.client ?? new RpcIntelligenceClient({ fetch: options.fetch, signal: options.signal });
|
||||
const edgeTypes = options.edgeTypes === undefined ? undefined : [...new Set(options.edgeTypes)].sort();
|
||||
@@ -117,12 +139,15 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
throw new Error('This node has no indexed qualified identity; its relationships cannot be retrieved reliably.');
|
||||
const predicate = scope.kind === 'node' ? `n.qualified_name = "${escapeLiteral(scope.qualifiedName ?? known.get(scope.id)?.qualified_name ?? '')}"` : scope.kind === 'symbol' ? `n.qualified_name = "${escapeLiteral(scope.qualifiedName)}"` : scope.kind === 'file' ? `n.file_path = "${escapeLiteral(scope.path)}"`
|
||||
: `n.file_path STARTS WITH "${escapeLiteral(scope.path.replace(/\/$/, '') + '/')}"`;
|
||||
const previous = options.previous?.traversalKey === traversalKey && options.previous.depth <= depth ? options.previous : undefined;
|
||||
// A partial layer misses part of its frontier, so it is never the base of the next one.
|
||||
const previous = options.previous?.traversalKey === traversalKey && options.previous.depth <= depth && !options.previous.partial ? options.previous : undefined;
|
||||
let requests = 0;
|
||||
const counted = () => { requests += 1; };
|
||||
const cachedNode = scope.kind === 'node' ? options.cache?.node(scope.id) : undefined;
|
||||
let rootNodes = cachedNode && cachedNode.qualified_name === (scope.kind === 'node' ? scope.qualifiedName ?? known.get(scope.id)?.qualified_name : '')
|
||||
? [cachedNode] : options.cache?.roots(predicate);
|
||||
if (!previous && !rootNodes) {
|
||||
const rootRows = await readGraphPages(client, project, `MATCH (n) WHERE ${predicate} RETURN ${nodeColumns('n', '')}`, 'id', options.signal);
|
||||
const rootRows = await readGraphPages(client, project, `MATCH (n) WHERE ${predicate} RETURN ${nodeColumns('n', '')}`, 'id', options.signal, counted);
|
||||
rootNodes = rootRows.map(row => readNode(row, '', known));
|
||||
options.cache?.rememberRoots(predicate, rootNodes);
|
||||
}
|
||||
@@ -133,7 +158,11 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
const levels = new Map<number, number>(previous?.levels ?? [...roots].map(identity => [identity, 0] as const));
|
||||
const edges = new Map<number, GraphEdge>(previous?.data.edges.filter(edge => edge.id !== undefined).map(edge => [edge.id!, edge]) ?? []);
|
||||
let frontier = edgeTypes?.length === 0 ? [] : previous?.frontier ?? [...roots], reachedDepth = previous?.depth ?? 0;
|
||||
for (let hop = reachedDepth; hop < Math.max(depth, roots.size > 1 ? 1 : 0) && frontier.length; hop++) {
|
||||
let partial: ScopePartial | undefined;
|
||||
const overLimit = (layer: number): ScopePartial | undefined => !options.limits ? undefined
|
||||
: nodes.size > options.limits.nodes ? { layer, nodes: nodes.size, edges: edges.size, limit: 'nodes' }
|
||||
: edges.size > options.limits.edges ? { layer, nodes: nodes.size, edges: edges.size, limit: 'edges' } : undefined;
|
||||
for (let hop = reachedDepth; hop < Math.max(depth, roots.size > 1 ? 1 : 0) && frontier.length && !partial; hop++) {
|
||||
const next = new Set<number>();
|
||||
const fileBoundary = hop === 0 && (scope.kind === 'folder' || (scope.kind === 'file' && !scope.range));
|
||||
const accept = (evidenceNodes: readonly GraphNode[], evidenceEdges: readonly GraphEdge[]) => {
|
||||
@@ -149,6 +178,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
};
|
||||
const legs = direction === 'both' ? ['outbound', 'inbound'] as const : [direction];
|
||||
for (const leg of legs) {
|
||||
if (partial) break;
|
||||
const missing: number[] = [];
|
||||
for (const identity of frontier) {
|
||||
const cached = options.cache?.neighborhood(identity, leg, edgeTypes);
|
||||
@@ -156,7 +186,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
}
|
||||
const useBoundary = fileBoundary && missing.length === frontier.length;
|
||||
const batchSize = useBoundary ? Math.max(1, missing.length) : BATCH_SIZE;
|
||||
for (let at = 0; at < missing.length; at += batchSize) {
|
||||
for (let at = 0; at < missing.length && !partial; at += batchSize) {
|
||||
const batch = missing.slice(at, at + batchSize), batchIds = new Set(batch);
|
||||
const names = batch.map(value => nodes.get(value)?.qualified_name);
|
||||
if (!useBoundary && names.some(name => !name)) throw new Error('A selected graph node has no qualified identity; its dependencies cannot be loaded completely.');
|
||||
@@ -168,7 +198,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
// Keep each predicate on the left seed, so early WHERE prunes before
|
||||
// adjacency expansion. An OR spanning a and b materializes the repo.
|
||||
const pattern = leg === 'inbound' ? `(b)<-${relationship}-(a)` : `(a)-${relationship}->(b)`;
|
||||
const rows = await readGraphPages(client, project, `MATCH ${pattern} WHERE ${relation} RETURN id(r) AS edge_id, type(r) AS edge_type, r.line AS edge_line, ${nodeColumns('a', 'a_')}, ${nodeColumns('b', 'b_')}`, 'edge_id', options.signal);
|
||||
const rows = await readGraphPages(client, project, `MATCH ${pattern} WHERE ${relation} RETURN id(r) AS edge_id, type(r) AS edge_type, r.line AS edge_line, ${nodeColumns('a', 'a_')}, ${nodeColumns('b', 'b_')}`, 'edge_id', options.signal, counted);
|
||||
const evidenceNodes = new Map(batch.map(identity => [identity, nodes.get(identity)!]));
|
||||
const evidenceEdges: GraphEdge[] = [];
|
||||
for (const row of rows) {
|
||||
@@ -185,6 +215,8 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
const verifiedNodes = [...evidenceNodes.values()];
|
||||
options.cache?.rememberNeighborhood(batch, leg, edgeTypes, verifiedNodes, evidenceEdges);
|
||||
accept(verifiedNodes, evidenceEdges);
|
||||
options.onProgress?.({ layer: hop + 1, nodes: nodes.size, edges: edges.size, requests });
|
||||
partial = overLimit(hop + 1);
|
||||
}
|
||||
}
|
||||
reachedDepth = hop + 1;
|
||||
@@ -195,7 +227,21 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
const initial = [...edges.values()].filter(edge => roots.has(edge.source) && roots.has(edge.target));
|
||||
return { data: arrangeScopedGraph([...nodes.values()].filter(node => roots.has(node.id)), initial, roots, levels), roots, depth: 0, exhausted: edgeTypes?.length === 0, frontier: edgeTypes?.length === 0 ? [] : [...roots], levels, traversalKey };
|
||||
}
|
||||
return { data: arrangeScopedGraph([...nodes.values()], [...edges.values()], roots, levels), roots, depth: reachedDepth, exhausted: edgeTypes?.length === 0 || (depth > 0 && frontier.length === 0), frontier, levels, traversalKey };
|
||||
return { data: arrangeScopedGraph([...nodes.values()], [...edges.values()], roots, levels), roots, depth: reachedDepth,
|
||||
exhausted: !partial && (edgeTypes?.length === 0 || (depth > 0 && frontier.length === 0)), frontier, levels, traversalKey, ...(partial ? { partial } : {}) };
|
||||
}
|
||||
|
||||
/** Eine Schaetzung der naechsten Ebene, bevor sie geladen wird (K8): so viele
|
||||
* Knoten stehen an ihrem Rand, und so viele neue brachte jeder Knoten der
|
||||
* letzten Ebene. Eine Schaetzung und keine Zusage; die Grenze beim Laden
|
||||
* selbst haelt die Ebene trotzdem am Render-Limit an. */
|
||||
export function nextLayerEstimate(scope: ScopedGraph | undefined): { layer: number; frontier: number; perNode: number; estimate: number } | undefined {
|
||||
if (!scope?.levels || scope.exhausted || scope.partial) return undefined;
|
||||
const at = (level: number) => scope.data.nodes.filter(node => (scope.roots.has(node.id) ? 0 : scope.levels!.get(node.id)) === level).length;
|
||||
const frontier = at(scope.depth);
|
||||
if (frontier === 0) return undefined;
|
||||
const perNode = scope.depth > 0 ? frontier / Math.max(1, at(scope.depth - 1)) : 1;
|
||||
return { layer: scope.depth + 1, frontier, perNode, estimate: Math.round(frontier * perNode) };
|
||||
}
|
||||
|
||||
/** Was die Galaxie zeigt, waehrend ein Scope angeordnet wird: das aktuelle
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { graphEdgeTypesKey, loadGraphScope, type GraphScope, type ScopedGraph, type TraceDirection } from './graph-scope';
|
||||
import { graphEdgeTypesKey, loadGraphScope, type GraphScope, type ScopedGraph, type ScopeProgress, type TraceDirection } from './graph-scope';
|
||||
import { RpcIntelligenceClient } from '../provider/rpc-client';
|
||||
import { readerGraphFocus, type SourceFocusRange } from './reader-graph-focus';
|
||||
import type { GraphData } from './types';
|
||||
@@ -12,8 +12,10 @@ export function minimumScopeDepth(scope: GraphScope | undefined): number {
|
||||
}
|
||||
|
||||
/** Scoped requests are independent of the whole-repository layout budget. */
|
||||
export function useGraphScope({ project, layout, filePath, range, fetch: fetchImpl, edgeTypes }: {
|
||||
export function useGraphScope({ project, layout, filePath, range, fetch: fetchImpl, edgeTypes, limits }: {
|
||||
project: string; layout?: GraphData; filePath?: string; range?: SourceFocusRange; fetch?: typeof globalThis.fetch; edgeTypes?: readonly string[];
|
||||
/** The render limits: a layer that grows past them stops early and is marked partial (hand test K8). */
|
||||
limits?: { nodes: number; edges: number };
|
||||
}) {
|
||||
const cache = useRef(new Map<string, ScopedGraph>());
|
||||
const [chosen, setChosen] = useState<GraphScope>();
|
||||
@@ -23,6 +25,7 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
|
||||
const [cachedPreview, setCachedPreview] = useState<{ key: string; value: ScopedGraph }>();
|
||||
const [pendingPhase, setPendingPhase] = useState<{ key: string; validating: boolean }>();
|
||||
const [failure, setFailure] = useState<{ key: string; message: string }>();
|
||||
const [progress, setProgress] = useState<{ key: string; value: ScopeProgress }>();
|
||||
const [reload, setReload] = useState(0);
|
||||
const transportKey = graphNeighborhoodTransportKey(fetchImpl);
|
||||
const readerKey = JSON.stringify([transportKey, project, filePath, range?.startLine, range?.endLine]);
|
||||
@@ -34,7 +37,8 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
|
||||
const edgeTypesKey = graphEdgeTypesKey(edgeTypes);
|
||||
const selectedTypes = useMemo<string[] | undefined>(() => edgeTypesKey === 'null' ? undefined : JSON.parse(edgeTypesKey) as string[], [edgeTypesKey]);
|
||||
const actualDirection = filePath ? 'both' : direction;
|
||||
const scopeKey = JSON.stringify([transportKey, project, scope, actualDirection, edgeTypesKey, reload]);
|
||||
const limitNodes = limits?.nodes, limitEdges = limits?.edges;
|
||||
const scopeKey = JSON.stringify([transportKey, project, scope, actualDirection, edgeTypesKey, reload, limitNodes ?? null, limitEdges ?? null]);
|
||||
const key = JSON.stringify([scopeKey, actualDepth]);
|
||||
useEffect(() => { setChosen(undefined); setDepth(0); setDirection('both'); setResult(undefined); setFailure(undefined); }, [project]);
|
||||
useEffect(() => {
|
||||
@@ -74,6 +78,8 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
|
||||
const transaction = neighborhood?.begin();
|
||||
const value = await loadGraphScope(project, scope, actualDepth, actualDirection, layout,
|
||||
{ fetch: fetchImpl, signal: abort.signal, previous, edgeTypes: selectedTypes, cache: transaction,
|
||||
...(limitNodes !== undefined && limitEdges !== undefined ? { limits: { nodes: limitNodes, edges: limitEdges } } : {}),
|
||||
onProgress: value => { if (!abort.signal.aborted) setProgress({ key, value }); },
|
||||
client: { queryGraph: (...args) => {
|
||||
setPendingPhase({ key, validating: false }); return client.queryGraph(...args);
|
||||
} } });
|
||||
@@ -130,6 +136,7 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
|
||||
}, [scope, layout, actualDepth, actualDirection, selectedTypes]);
|
||||
const minDepth = filePath ? 1 : minimumScopeDepth(scope);
|
||||
return { scope, depth: actualDepth, minDepth, direction, setDirection,
|
||||
progress: progress?.key === key && !complete ? progress.value : undefined,
|
||||
result: complete ?? (cachedPreview?.key === key ? cachedPreview.value : undefined)
|
||||
?? (result?.scopeKey === scopeKey && result.value.depth <= actualDepth ? result.value : fallback), loading: Boolean(scope && !complete && !error), error,
|
||||
validating: Boolean(scope && !complete && !error && pendingPhase?.key === key && pendingPhase.validating),
|
||||
|
||||
@@ -18,6 +18,18 @@ it('reads cursor pages without losing the project, query or row order', async ()
|
||||
expect(rpc.calls[1].args).toEqual({ project: 'project-a', query: 'MATCH query', cursor: 'q1.next' });
|
||||
});
|
||||
|
||||
it('sends a page budget as max_rows and max_output_tokens only when one is asked for (hand test K8)', async () => {
|
||||
const { client: c, rpc } = client();
|
||||
await c.queryGraph('p', 'q', undefined, { maxRows: 5000, maxOutputTokens: 600000 });
|
||||
await c.queryGraph('p', 'q', 'q1.next', { maxRows: 5000, maxOutputTokens: 600000 });
|
||||
await c.queryGraph('p', 'q');
|
||||
expect(rpc.calls.map(call => call.args)).toEqual([
|
||||
{ project: 'p', query: 'q', max_rows: 5000, max_output_tokens: 600000 },
|
||||
{ project: 'p', query: 'q', cursor: 'q1.next', max_rows: 5000, max_output_tokens: 600000 },
|
||||
{ project: 'p', query: 'q' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps truncation metadata on the single-page API', async () => {
|
||||
expect(await client().client.queryGraph('p', 'q')).toMatchObject({ total: 2, returned: 1, truncated: true, nextCursor: 'q1.next' });
|
||||
});
|
||||
|
||||
@@ -210,8 +210,12 @@ export class RpcIntelligenceClient {
|
||||
* schreibt an derselben Stelle `""`, und ohne die Uebersetzung wuerde ein
|
||||
* Bindestrich als Dateiname durch das Produkt wandern.
|
||||
*/
|
||||
async queryGraph(project: string, query: string, cursor?: string): Promise<QueryGraphResult> {
|
||||
const text = await this.text('query_graph', { project, query, ...(cursor ? { cursor } : {}) });
|
||||
async queryGraph(project: string, query: string, cursor?: string,
|
||||
/* Groessere Seiten fuer Lader, die alles brauchen (Galaxy-Scope, Handtest K8). */
|
||||
budget?: { maxRows?: number; maxOutputTokens?: number }): Promise<QueryGraphResult> {
|
||||
const text = await this.text('query_graph', { project, query, ...(cursor ? { cursor } : {}),
|
||||
...(budget?.maxRows ? { max_rows: budget.maxRows } : {}),
|
||||
...(budget?.maxOutputTokens ? { max_output_tokens: budget.maxOutputTokens } : {}) });
|
||||
let parsed: CompactRows;
|
||||
try {
|
||||
parsed = parseCompactRows(text);
|
||||
|
||||
@@ -297,8 +297,76 @@ async function checkK2(page) {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* K8: die dritte Ebene haengt nicht, "−" bricht ab */
|
||||
|
||||
const CHECKS = { K9: checkK9, K2: checkK2 };
|
||||
const scopeState = (page) => page.locator('.atlas-graph-scope-count').first().getAttribute('data-state', { timeout: 1000 }).catch(() => null);
|
||||
const rpcSince = (page, t0) => page.evaluate((from) => window.__probeRpc.filter((row) => row.t >= from), t0);
|
||||
const pageNow = (page) => page.evaluate(() => Math.round(performance.now()));
|
||||
const progressShown = (page) => page.locator('.atlas-graph-render-progress').first().innerText({ timeout: 500 }).catch(() => '');
|
||||
|
||||
async function checkK8(page) {
|
||||
await section(page, 'K8', 'Expand to layer 3 of JSONBAgg (both directions, all edge types) completes or warns quickly; "−" cancels within 1 s');
|
||||
await open(page);
|
||||
await select(page, ROOT);
|
||||
await settled(page);
|
||||
await expand(page);
|
||||
await shot(page, 'K8', 'two-layers', `${ROOT} at 2 layers: ${await countText(page)}`);
|
||||
const minus = page.getByRole('button', { name: 'Remove graph layer' });
|
||||
const expandButton = page.getByRole('button', { name: 'Expand +1' });
|
||||
const expandTitle = await expandButton.getAttribute('title');
|
||||
|
||||
// 1. Abbrechen waehrend die dritte Ebene laedt.
|
||||
await expandButton.click();
|
||||
await wait(400);
|
||||
const during = { status: await countText(page), state: await scopeState(page), minusDisabled: await minus.isDisabled(), minusTitle: await minus.getAttribute('title'),
|
||||
progress: await progressShown(page) };
|
||||
await shot(page, 'K8', 'cancel-loading-400ms', `400 ms into layer 3: "${during.status}", "−" enabled with "${during.minusTitle}"`);
|
||||
const c0 = Date.now();
|
||||
await minus.click();
|
||||
await page.waitForFunction(() => document.querySelector('.atlas-graph-scope-count')?.getAttribute('data-state') !== 'loading'
|
||||
&& [...document.querySelectorAll('.atlas-graph-exploration span')].some((el) => el.textContent?.trim() === '2 layers'), null, { timeout: 10000 }).catch(() => {});
|
||||
const cancelMs = Date.now() - c0;
|
||||
await shot(page, 'K8', 'cancel-0ms', `Right after "−": back on 2 layers in ${cancelMs} ms`);
|
||||
await wait(250);
|
||||
await shot(page, 'K8', 'cancel-250ms', '250 ms after "−"');
|
||||
await wait(750);
|
||||
const cancelled = { layers: await layerText(page), status: await countText(page), state: await scopeState(page), ms: cancelMs };
|
||||
await shot(page, 'K8', 'cancel-1s', `1 s after "−": ${cancelled.layers}, ${cancelled.status}`);
|
||||
await check('K8', '"−" cancels a running layer and returns to the previous one within 1 s', during.state === 'loading' && during.minusDisabled === false
|
||||
&& cancelMs <= 1000 && cancelled.layers === '2 layers' && /^90 nodes · 201 edges/.test(cancelled.status), { expandTitle, during, cancelled });
|
||||
|
||||
// 2. Die dritte Ebene ganz laden.
|
||||
await wait(1500);
|
||||
const t0 = await pageNow(page);
|
||||
const w0 = Date.now();
|
||||
await expandButton.click();
|
||||
const frames = [];
|
||||
for (const [label, at] of [['0ms', 0], ['250ms', 250], ['1s', 1000], ['3s', 3000]]) {
|
||||
const elapsed = Date.now() - w0;
|
||||
if (at > elapsed) await wait(at - elapsed);
|
||||
frames.push({ at: label, status: await countText(page), state: await scopeState(page), progress: await progressShown(page) });
|
||||
await shot(page, 'K8', `layer3-${label}`, `${label} after Expand: "${frames.at(-1).status}"${frames.at(-1).progress ? `, overlay "${frames.at(-1).progress}"` : ''}`);
|
||||
}
|
||||
await page.waitForFunction(() => ['complete', 'partial'].includes(document.querySelector('.atlas-graph-scope-count')?.getAttribute('data-state') ?? ''), null, { timeout: 180000 }).catch(() => {});
|
||||
const doneMs = Date.now() - w0;
|
||||
const calls = await rpcSince(page, t0);
|
||||
const queries = calls.filter((row) => row.tool === 'query_graph');
|
||||
await shot(page, 'K8', 'layer3-done', `Layer 3 finished after ${doneMs} ms with ${queries.length} query_graph calls: "${await countText(page)}"`);
|
||||
await wait(2000);
|
||||
const done = { ms: doneMs, status: await countText(page), state: await scopeState(page),
|
||||
title: await page.locator('.atlas-graph-scope-count').first().getAttribute('title').catch(() => null),
|
||||
overlayAfter2s: await progressShown(page), queryGraph: queries.length, withCursor: queries.filter((row) => row.cursor).length,
|
||||
maxRows: [...new Set(queries.map((row) => row.maxRows))], layers: await layerText(page),
|
||||
expandDisabled: await expandButton.isDisabled(), expandTitle: await expandButton.getAttribute('title') };
|
||||
await shot(page, 'K8', 'layer3-done-2s', `2 s later: no "Updating view" overlay (${done.overlayAfter2s ? 'still shown' : 'gone'})`);
|
||||
await check('K8', 'Layer 3 completes (or stops at the render limit with a partial note) within 30 s in few large requests, and the overlay goes away',
|
||||
['complete', 'partial'].includes(done.state ?? '') && doneMs <= 30000 && done.queryGraph <= 40 && done.overlayAfter2s === '' && done.layers === '3 layers',
|
||||
{ frames, done });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8 };
|
||||
|
||||
await mkdir(OUT, { recursive: true });
|
||||
const context = await chromium.launchPersistentContext(PROFILE, {
|
||||
|
||||
Reference in new issue
Block a user