From 934d995661ccbc7020314819bf44c8d99e963ae5 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Wed, 13 May 2026 15:05:18 +0200 Subject: [PATCH] test(ui): fix flaky attack paths test (#11154) Co-authored-by: Pablo F.G --- .../attack-paths-page.browser.test.tsx | 72 +++++++++---------- .../attack-paths-page.harness.ts | 25 +++++-- 2 files changed, 56 insertions(+), 41 deletions(-) diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index 1d9ea021ca..9e455a4b6d 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -81,7 +81,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.background).toBeTruthy(); expect(graph.minimap).toBeTruthy(); @@ -91,7 +91,7 @@ describe("running a query", () => { test("nodes are laid out at distinct positions", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); const positions = graph.nodePositions; expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true); @@ -102,7 +102,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(1); + await graph.waitForGraphStable(1); expect(graph.toolbar.zoomInButton).toBeTruthy(); expect(graph.toolbar.zoomOutButton).toBeTruthy(); @@ -115,7 +115,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -128,7 +128,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); expect(graph.findingEdges.length).toBeGreaterThan(0); @@ -138,7 +138,7 @@ describe("running a query", () => { test("edges connect string source and target ids", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(2); + await graph.waitForGraphStable(2, 1); const edgeIds = graph.renderedEdgeIds; expect(edgeIds.length).toBeGreaterThan(0); @@ -153,7 +153,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.singleNode()); await graph.executeQuery(); - await graph.waitForLayoutStable(1); + await graph.waitForGraphStable(1); expect(graph.nodes).toHaveLength(1); }); @@ -175,7 +175,7 @@ describe("running a query", () => { const graph = await mountWith(fixtures.large(200)); const start = performance.now(); await graph.executeQuery(); - await graph.waitForLayoutStable(1); + await graph.waitForGraphStable(1); const elapsed = performance.now() - start; expect(elapsed).toBeLessThan(5000); }); @@ -183,7 +183,7 @@ describe("running a query", () => { test("disconnected components are both visible", async ({ mountWith }) => { const graph = await mountWith(fixtures.disconnected()); await graph.executeQuery(); - await graph.waitForLayoutStable(4); + await graph.waitForGraphStable(4); expect(graph.nodes.length).toBe(4); }); @@ -192,7 +192,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.resourcesOnly()); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.findingNodes.length).toBe(0); expect(graph.resourceNodes.length).toBe(3); }); @@ -202,7 +202,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.findingsOnly()); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.findingNodes.length).toBe(3); expect(graph.resourceNodes.length).toBe(0); @@ -239,7 +239,7 @@ describe("running a query", () => { const graph = await mountWith(fixture); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); // Then - hidden findings do not influence initial resource coordinates. for (const node of visibleNodes) { @@ -254,7 +254,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.edgeCases()); await graph.executeQuery(); - await graph.waitForLayoutStable(5); + await graph.waitForGraphStable(5); expect(graph.nodes.length).toBe(7); expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true); @@ -267,7 +267,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); expect(graph.isInFilteredView).toBe(false); @@ -284,7 +284,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.findingNodes.length).toBe(0); expect(graph.hasNodeDetailsModal).toBe(false); @@ -301,7 +301,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -318,7 +318,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); const initialViewport = graph.viewportTransform; @@ -344,7 +344,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -366,7 +366,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(fixtures.large(20)); await graph.executeQuery(); - await graph.waitForLayoutStable(16); + await graph.waitForGraphStable(16); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -388,7 +388,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.hasNodeDetailsModal).toBe(false); expect(graph.hasNodeActionDialog).toBe(false); @@ -406,13 +406,13 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); const fullNodes = graph.nodes.length; await graph.clickFirstFindingNode(); await graph.exitFilteredView(); - await graph.waitForLayoutStable(fullNodes); + await graph.waitForGraphStable(fullNodes); expect(graph.isInFilteredView).toBe(false); }); @@ -420,7 +420,7 @@ describe("exploring the graph", () => { const fixture = fixtures.typical(); const graph = await mountWith(fixture); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); const hoveredNodeId = graph.resourceNodes[0]?.getAttribute("data-id"); expect(hoveredNodeId).toBeTruthy(); @@ -458,7 +458,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickFirstResourceNodeWithoutFindings(); @@ -470,7 +470,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickEmptyCanvas(); expect(graph.isInFilteredView).toBe(false); @@ -481,7 +481,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); await graph.rapidlyClickFirstFindingNode(2); @@ -493,7 +493,7 @@ describe("exploring the graph", () => { test("double-clicking a node doesn't break state", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.dblClickFirstResourceNode(); expect(graph.nodes.length).toBeGreaterThan(0); @@ -506,7 +506,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.minimapMaskStrokeWidth).toBeGreaterThan(0); }); @@ -516,7 +516,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); // Given - zoom into the current overview so newly revealed findings can // sit entirely outside the current frame. The expand auto-fit should then @@ -543,7 +543,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); const beforeFilter = graph.viewportTransform; @@ -561,7 +561,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); await graph.clickFirstFindingNode(); expect(graph.isInFilteredView).toBe(true); @@ -569,7 +569,7 @@ describe("auto-fitting the viewport", () => { const filterT = graph.viewportTransform; await graph.exitFilteredView(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.waitForTransition(); expect(graph.viewportTransform).not.toBe(filterT); @@ -582,7 +582,7 @@ describe("exporting the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.toolbar.isExportButtonEnabled).toBe(true); }); @@ -592,7 +592,7 @@ describe("exporting the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); const png = await graph.captureExportPNG(); @@ -608,13 +608,13 @@ describe("running a different query", () => { test("the previous filtered view is cleared", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); await graph.clickFirstFindingNode(); expect(graph.isInFilteredView).toBe(true); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.isInFilteredView).toBe(false); }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts index 86ba2bf56b..dff9ccdb51 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -276,13 +276,28 @@ export class AttackPathPageHarness { // --- Sync helpers --- - /** Wait until React Flow has rendered at least `expected` node elements. */ - async waitForLayoutStable(expected = 1, timeoutMs = 3000): Promise { + /** + * Wait until React Flow has rendered at least `expectedNodes` node elements + * and `expectedEdges` edge elements. React Flow renders edges asynchronously + * after nodes are measured via ResizeObserver, so tests that assert on edge + * state must opt in to waiting for them — node-only waits race against the + * edge measurement pass. + */ + async waitForGraphStable( + expectedNodes = 1, + expectedEdges = 0, + timeoutMs = 3000, + ): Promise { await vi.waitFor( () => { - if (this.nodes.length < expected) { + if (this.nodes.length < expectedNodes) { throw new Error( - `expected ${expected} nodes, got ${this.nodes.length}`, + `expected ${expectedNodes} nodes, got ${this.nodes.length}`, + ); + } + if (this.edges.length < expectedEdges) { + throw new Error( + `expected ${expectedEdges} edges, got ${this.edges.length}`, ); } }, @@ -397,7 +412,7 @@ export class AttackPathPageHarness { 10000, ); await this.user.click(button); - await this.waitForLayoutStable(1, 10000); + await this.waitForGraphStable(1, 0, 10000); } async clickNode(nodeId: string): Promise {