From a436eacf43bfe8beea8f3e72f2943d4a569fc771 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sat, 3 Oct 2026 18:56:34 +0200 Subject: [PATCH] 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 --- .../src/galaxy/GalaxyPanel.layers.test.tsx | 96 +++++++++++++++++++ .../galaxy/GalaxyPanel.reader-focus.test.tsx | 3 +- graph-ui/src/galaxy/GalaxyPanel.tsx | 42 ++++++-- graph-ui/src/galaxy/galaxy-strings.ts | 28 ++++++ graph-ui/src/galaxy/graph-exploration.css | 3 + graph-ui/src/galaxy/graph-scope.test.ts | 62 +++++++++++- graph-ui/src/galaxy/graph-scope.ts | 70 +++++++++++--- graph-ui/src/galaxy/use-graph-scope.ts | 13 ++- graph-ui/src/provider/query-pages.test.ts | 12 +++ graph-ui/src/provider/rpc-client.ts | 8 +- graph-ui/tools/handtest-fixes-galaxy.mjs | 70 +++++++++++++- 11 files changed, 378 insertions(+), 29 deletions(-) create mode 100644 graph-ui/src/galaxy/GalaxyPanel.layers.test.tsx diff --git a/graph-ui/src/galaxy/GalaxyPanel.layers.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.layers.test.tsx new file mode 100644 index 000000000..22e613f27 --- /dev/null +++ b/graph-ui/src/galaxy/GalaxyPanel.layers.test.tsx @@ -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(), + GraphScene: () => , +})); + +let host: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).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('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(resolve => { release = resolve; }); + } }); + await act(async () => root.render()); + 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()); + 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')); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx index 4688a5bc0..69d1db84f 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx @@ -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()); - 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.'); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 5a396816a..b5c5737cc 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -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 { } : 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 { } {props.workspaceExpanded && kind.type)} selected={traceTypes} onChange={changeTraceTypes} />} - {scope.depth} {scope.depth === 1 ? 'layer' : 'layers'} - {props.workspaceExpanded && mode === 'galaxy' && <> { 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} } - {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'} + + {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} {scope.error && Some relationships could not be loaded. } {!props.workspaceExpanded && scope.complete && All indexed direct dependencies included.} {mode === 'galaxy' && organic && organic.groups.length > 1 && {galaxyToolbarText.groups(organic.groups.length)}} @@ -2630,7 +2653,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )}
- + {trailView && } {/* diff --git a/graph-ui/src/galaxy/galaxy-strings.ts b/graph-ui/src/galaxy/galaxy-strings.ts index e7381e51b..d25e68d25 100644 --- a/graph-ui/src/galaxy/galaxy-strings.ts +++ b/graph-ui/src/galaxy/galaxy-strings.ts @@ -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`, diff --git a/graph-ui/src/galaxy/graph-exploration.css b/graph-ui/src/galaxy/graph-exploration.css index c87ebc648..78b6e13c1 100644 --- a/graph-ui/src/galaxy/graph-exploration.css +++ b/graph-ui/src/galaxy/graph-exploration.css @@ -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; } diff --git a/graph-ui/src/galaxy/graph-scope.test.ts b/graph-ui/src/galaxy/graph-scope.test.ts index 8c4ca316a..b9361cfca 100644 --- a/graph-ui/src/galaxy/graph-scope.test.ts +++ b/graph-ui/src/galaxy/graph-scope.test.ts @@ -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().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().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().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().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().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' }); + }); +}); diff --git a/graph-ui/src/galaxy/graph-scope.ts b/graph-ui/src/galaxy/graph-scope.ts index 41ba49c85..a62600a4c 100644 --- a/graph-ui/src/galaxy/graph-scope.ts +++ b/graph-ui/src/galaxy/graph-scope.ts @@ -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; depth: number; exhausted: boolean; frontier?: number[]; levels?: Map; traversalKey?: string } -export interface GraphQueryClient { queryGraph(project: string, query: string, cursor?: string): Promise } +/** 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; depth: number; exhausted: boolean; frontier?: number[]; levels?: Map; 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 } +/** 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[]> { +export async function readGraphPages(client: GraphQueryClient, project: string, query: string, key: string, signal?: AbortSignal, + onRequest?: () => void): Promise[]> { 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[] = []; const cursors = new Set(), identities = new Set(); 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 { + 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 { 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(previous?.levels ?? [...roots].map(identity => [identity, 0] as const)); const edges = new Map(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(); 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 diff --git a/graph-ui/src/galaxy/use-graph-scope.ts b/graph-ui/src/galaxy/use-graph-scope.ts index 2c7f9711c..c4f8988e5 100644 --- a/graph-ui/src/galaxy/use-graph-scope.ts +++ b/graph-ui/src/galaxy/use-graph-scope.ts @@ -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()); const [chosen, setChosen] = useState(); @@ -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(() => 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), diff --git a/graph-ui/src/provider/query-pages.test.ts b/graph-ui/src/provider/query-pages.test.ts index 957ef962d..8426cf1a8 100644 --- a/graph-ui/src/provider/query-pages.test.ts +++ b/graph-ui/src/provider/query-pages.test.ts @@ -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' }); }); diff --git a/graph-ui/src/provider/rpc-client.ts b/graph-ui/src/provider/rpc-client.ts index aca4ba430..2315ef957 100644 --- a/graph-ui/src/provider/rpc-client.ts +++ b/graph-ui/src/provider/rpc-client.ts @@ -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 { - 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 { + 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); diff --git a/graph-ui/tools/handtest-fixes-galaxy.mjs b/graph-ui/tools/handtest-fixes-galaxy.mjs index a9cb49529..8c4c84d5a 100644 --- a/graph-ui/tools/handtest-fixes-galaxy.mjs +++ b/graph-ui/tools/handtest-fixes-galaxy.mjs @@ -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, {