test(ui): stabilize attack-paths refit integration test (#12896)

This commit is contained in:
Alejandro Bailo
2026-09-29 18:42:19 +02:00
committed by GitHub
parent f418b32c81
commit 04511f339e
4 changed files with 81 additions and 8 deletions
@@ -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<null> {
constructor() {
@@ -21,6 +23,10 @@ class WaitingHarness extends BrowserHarness<null> {
probeOrNull<T>(fn: () => T | null | undefined | false): Promise<T | null> {
return this.waitForOrNull(fn, 200, "probe");
}
probeStable<T>(read: () => T): Promise<T> {
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);
});
});
+25
View File
@@ -211,6 +211,31 @@ export abstract class BrowserHarness<TFixture> {
}
}
/** Wait until `read` returns the same value for `quietMs`, and return it. */
protected async waitForStable<T>(
read: () => T,
quietMs: number,
timeoutMs = 5000,
label?: string,
): Promise<T> {
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,
@@ -18,6 +18,8 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
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<PageFixture> {
/** Wait until the React Flow viewport transform changes from `previous`. */
async waitForViewportChange(
previous: string,
timeoutMs = 2000,
timeoutMs?: number,
): Promise<void> {
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<string> {
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<void> {
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<PageFixture> {
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. */
@@ -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,