From 04511f339e2cc13857f8d876d689857429f65edf Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:42:19 +0200 Subject: [PATCH] test(ui): stabilize attack-paths refit integration test (#12896) --- .../browser-harness.integration.test.ts | 22 +++++++++++++ ui/__tests__/browser-harness.ts | 25 +++++++++++++++ .../attack-paths-page.harness.ts | 31 ++++++++++++++++--- .../attack-paths-page.integration.test.tsx | 11 +++++-- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/ui/__tests__/browser-harness.integration.test.ts b/ui/__tests__/browser-harness.integration.test.ts index a1befbc1ec..19005d0c08 100644 --- a/ui/__tests__/browser-harness.integration.test.ts +++ b/ui/__tests__/browser-harness.integration.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from "vitest"; import { BrowserHarness } from "./browser-harness"; +const QUIET_MS = 50; + /** Exposes the protected waiting helpers; no fixture or DOM is involved. */ class WaitingHarness extends BrowserHarness { constructor() { @@ -21,6 +23,10 @@ class WaitingHarness extends BrowserHarness { probeOrNull(fn: () => T | null | undefined | false): Promise { return this.waitForOrNull(fn, 200, "probe"); } + + probeStable(read: () => T): Promise { + return this.waitForStable(read, QUIET_MS, 1000, "probe"); + } } describe("BrowserHarness waiting helpers", () => { @@ -58,4 +64,20 @@ describe("BrowserHarness waiting helpers", () => { }), ).resolves.toBe("ready"); }); + + it("resolves with a value only once it has held for the quiet window", async () => { + const harness = new WaitingHarness(); + let reads = 0; + let settledAt = 0; + + // Changes on each of the first reads, then holds at 4. + const settled = await harness.probeStable(() => { + reads += 1; + if (reads === 4) settledAt = performance.now(); + return Math.min(reads, 4); + }); + + expect(settled).toBe(4); + expect(performance.now() - settledAt).toBeGreaterThanOrEqual(QUIET_MS); + }); }); diff --git a/ui/__tests__/browser-harness.ts b/ui/__tests__/browser-harness.ts index 1aa44e2946..8c4345ecf1 100644 --- a/ui/__tests__/browser-harness.ts +++ b/ui/__tests__/browser-harness.ts @@ -211,6 +211,31 @@ export abstract class BrowserHarness { } } + /** Wait until `read` returns the same value for `quietMs`, and return it. */ + protected async waitForStable( + read: () => T, + quietMs: number, + timeoutMs = 5000, + label?: string, + ): Promise { + let value = read(); + let since = performance.now(); + const settled = await this.waitFor( + () => { + const next = read(); + if (!Object.is(next, value)) { + value = next; + since = performance.now(); + return null; + } + return performance.now() - since >= quietMs ? { value } : null; + }, + timeoutMs, + label ?? `a value stable for ${quietMs}ms`, + ); + return settled.value; + } + protected async waitForText( pattern: RegExp, timeoutMs = 5000, 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 6e846dcf29..e358d9287e 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 @@ -18,6 +18,8 @@ export class AttackPathPageHarness extends BrowserHarness { private static readonly VIEWPORT_SEL = ".react-flow__viewport"; private static readonly MINIMAP_SEL = ".react-flow__minimap"; private static readonly BACKGROUND_SEL = ".react-flow__background"; + // Matches the graph's auto-fit duration; a pause this long means no fit is mid-flight. + private static readonly FIT_ANIMATION_MS = 300; private static isFindingElement(el: Element): boolean { return ( @@ -255,17 +257,31 @@ export class AttackPathPageHarness extends BrowserHarness { /** Wait until the React Flow viewport transform changes from `previous`. */ async waitForViewportChange( previous: string, - timeoutMs = 2000, + timeoutMs?: number, ): Promise { - await this.waitFor(() => this.viewportTransform !== previous, timeoutMs); + await this.waitFor( + () => this.viewportTransform !== previous, + timeoutMs, + "the viewport transform to change", + ); + } + + /** Wait until the viewport stops moving and return its settled transform. */ + async waitForViewportSettled(): Promise { + return this.waitForStable( + () => this.viewportTransform, + AttackPathPageHarness.FIT_ANIMATION_MS, + undefined, + "the viewport to settle", + ); } /** Wait until every requested node is fully contained in the graph canvas. */ async waitForNodesInViewport( nodeIds: string[], - timeoutMs = 2000, + timeoutMs?: number, ): Promise { - await this.waitFor(() => { + const allInViewport = () => { const canvas = this.q(AttackPathPageHarness.FLOW_SEL); if (!canvas) return false; @@ -282,7 +298,12 @@ export class AttackPathPageHarness extends BrowserHarness { nodeRect.bottom <= canvasRect.bottom ); }); - }, timeoutMs); + }; + await this.waitFor( + allInViewport, + timeoutMs, + `nodes ${nodeIds.join(", ")} to be in the viewport`, + ); } /** Wait until exactly `count` edges are highlighted. */ diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx index d081a58e97..d15733db06 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx @@ -407,19 +407,24 @@ describe("exploring the graph", () => { const graph = await mountWith(); await graph.executeQuery(); await graph.waitForGraphStable(3); - - const initialViewport = graph.viewportTransform; + // Settle before each capture so the next change can only come from the + // action under test, not the tail of the previous fit animation. + const initialViewport = await graph.waitForViewportSettled(); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); await graph.waitForViewportChange(initialViewport); - const contextualViewport = graph.viewportTransform; + const contextualViewport = await graph.waitForViewportSettled(); + const visibleNodeIds = graph.renderedNodeIds; await graph.fit(); await graph.waitForViewportChange(contextualViewport); + // The fit must end with the whole visible graph on screen, not just move + await graph.waitForViewportSettled(); + await graph.waitForNodesInViewport(visibleNodeIds); }); test("clicking an expanded resource re-fits the remaining visible graph", async ({ mountWith,