Compare commits

...

21 Commits

Author SHA1 Message Date
Alan Buscaglia aa311623fe refactor(ui): simplify attack paths graph interactions
- Reuse shared measured-fit scheduling for graph viewport updates
- Consolidate node action dialog state
- Tighten browser harness dialog detection
2026-05-05 19:25:00 +02:00
Alan Buscaglia 142b45a387 fix(ui): improve attack paths graph interactions
- Restore supported graph scroll zoom behavior
- Add node action selector for ambiguous resource clicks
- Open finding and node details in existing drawers
- Cover resource actions with browser tests
2026-05-05 19:13:04 +02:00
Pablo F.G ec102d1569 fix(ui): re-fit attack-path graph when expand reveals off-screen findings
Recover the expand-time auto-fit lost while smoothing the filter
fix. Hidden findings are not measured by React Flow on initial
render, so `fitViewOptions.includeHiddenNodes` cannot extend the
initial viewport to cover them — clicking a resource that has its
findings laid out beyond the framed area would leave the user with
empty space and no way to discover the newly revealed nodes.

The expand-fit only fires for resources that just transitioned from
collapsed to expanded, and only when at least one of the connected
findings sits entirely past the current viewport (full bounding box
beyond the edge). Partially clipped edge nodes are left alone so
the framing the user already has is preserved when nothing has
actually moved off-screen.

Auto-fits now use asymmetric padding (extra room on the right and
bottom) to keep the minimap clear after a fit. Without it, fitted
nodes could land underneath the bottom-right minimap and become
unclickable.

The expand-without-re-fit test was a lock-in for the previous
behaviour and is replaced with one asserting the new behaviour:
expanding resources whose findings sit off-screen re-fits the
viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:28:08 +02:00
Pablo F.G d26f455784 fix(ui): re-fit attack-path graph on filter toggle and harden minimap
The Attack Path graph now re-fits its viewport when the user enters
the filtered view (click on a finding) or returns to the full graph
("Back to Full View"), so the focused subgraph and the restored
full graph are always centered instead of leaving the viewport
pointing at the previous coordinates. Resource expansion no longer
re-fits — the initial fit already includes hidden findings, so newly
revealed nodes sit inside the framing the user already has.

The minimap viewport indicator (mask cut-out) is darkened and given
a thicker border to stand out against the dark theme, where it was
previously hard to see.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:05:57 +02:00
Pablo F.G 4d5a77a58a chore(openspec): stop tracking openspec as submodule
Detach the openspec submodule so the directory is managed as a
local clone instead. /openspec/ remains in .gitignore so the cloned
working tree is never tracked by this repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:35:50 +02:00
Pablo F.G c183d5e868 fix: format 2026-05-05 14:18:51 +02:00
Pablo F.G 74e5118646 Merge remote-tracking branch 'origin/PROWLER-1273/react-flow-migration' into PROWLER-1273/react-flow-migration 2026-05-05 13:48:03 +02:00
Pablo F.G 48882b553f Merge remote-tracking branch 'origin/master' into PROWLER-1273/react-flow-migration 2026-05-05 13:41:30 +02:00
Pablo Fernandez Guerra (PFE) 8acbddd125 [CHAIN] test(ui): add Vitest Browser test coverage for Attack Paths (#10970)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 13:38:42 +02:00
Pablo F.G 3d4f5e66ab Merge remote-tracking branch 'origin/master' into PROWLER-1273/react-flow-migration
# Conflicts:
#	ui/CHANGELOG.md
#	ui/dependency-log.json
2026-05-05 09:21:51 +02:00
Pablo Fernandez Guerra (PFE) a4fc230cf4 [CHAIN] feat(ui): add graph export, minimap and fullscreen polish (#10800)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 09:16:29 +02:00
Pablo Fernandez Guerra (PFE) 1d54244f2b [CHAIN] feat(ui): add graph interactions and filtered view (#10756)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 09:00:09 +02:00
Pablo Fernandez Guerra (PFE) ff2bf5b01d [CHAIN] refactor(ui): replace D3 graph rendering with React Flow (#10705)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 08:55:47 +02:00
Pablo Fernandez Guerra (PFE) ba84b23afb [CHAIN] refactor(ui): normalize graph edge types and remove dead code (#10701)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 16:29:29 +02:00
Pablo F.G a9427c8024 chore(openspec): bump submodule to include PR4 test coverage proposal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:29:57 +02:00
Pablo F.G 10a62a6850 Merge remote-tracking branch 'origin/master' into PROWLER-1273/react-flow-migration 2026-04-17 14:27:53 +02:00
Pablo F.G b4601abb4e chore(openspec): consolidate submodule to include all chain task and spec updates
Bumps the openspec submodule to incorporate the linearized task completion
status and spec updates from PR0 (1373), PR1 (1374), and PR2 (1375).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:27:14 +02:00
Pablo F.G 5c981f5683 chore: update openspec with restructured expect-cli tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:49:47 +02:00
Pablo F.G 9922b15391 chore: update openspec with expect-cli validation tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:43:48 +02:00
Pablo F.G 6e77abea01 chore: update openspec submodule with react-flow-migration proposal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:31:24 +02:00
Pablo F.G 4d57f3bef1 chore: add prowler-openspec-opensource as git submodule
Adds the openspec repository as a submodule at openspec/ for shared
spec definitions used by SDD tooling across AI coding assistants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:56:09 +02:00
40 changed files with 4102 additions and 1809 deletions
+20
View File
@@ -42,6 +42,8 @@ jobs:
fonts.gstatic.com:443
api.github.com:443
release-assets.githubusercontent.com:443
cdn.playwright.dev:443
objects.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -152,6 +154,24 @@ jobs:
echo "Only test files changed - running ALL unit tests"
pnpm run test:run
- name: Cache Playwright browsers
if: steps.check-changes.outputs.any_changed == 'true'
id: playwright-cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-chromium-
- name: Install Playwright Chromium browser
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- name: Run browser tests
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run test:browser
- name: Build application
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run build
+4
View File
@@ -7,6 +7,7 @@
# testing
/coverage
__screenshots__/
# next.js
/.next/
@@ -28,6 +29,9 @@ yarn-error.log*
.env*.local
.env
# Claude Code local settings
.claude/
# vercel
.vercel
+12
View File
@@ -2,6 +2,18 @@
All notable changes to the **Prowler UI** are documented in this file.
## Unreleased
### 🚀 Added
- Browser test mode using Vitest with the Playwright provider, with initial coverage of the Attack Paths page and a new `pnpm test:browser` script wired into CI
### 🔄 Changed
- Attack Paths graph: extract shared primitives across `FindingNode`, `ResourceNode`, and `InternetNode` (hidden handles, label truncation, fill/border resolution) without forcing a generic node renderer [(#10705)](https://github.com/prowler-cloud/prowler/pull/10705)
---
## [1.25.2] (Prowler v5.25.2)
### 🔄 Changed
+106
View File
@@ -0,0 +1,106 @@
import { http, HttpResponse } from "msw";
import type { PageFixture } from "@/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures";
import type {
AttackPathQueriesResponse,
AttackPathQuery,
AttackPathQueryResult,
AttackPathScan,
AttackPathScansResponse,
QueryResultAttributes,
} from "@/types/attack-paths";
const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
type JsonApiErrorBody = {
errors: Array<{ detail: string; status: string }>;
};
const toScansApiResponse = (
scans: AttackPathScan[],
): AttackPathScansResponse => ({
data: scans,
links: {
first: `${API}/attack-paths-scans?page=1`,
last: `${API}/attack-paths-scans?page=1`,
next: null,
prev: null,
},
});
const toQueriesApiResponse = (
queries: AttackPathQuery[],
): AttackPathQueriesResponse => ({
data: queries,
});
const toQueryResultApiResponse = (
attrs: QueryResultAttributes,
queryId: string,
): AttackPathQueryResult => ({
data: {
type: "attack-paths-query-run-requests",
id: queryId,
attributes: attrs,
},
});
const toErrorBody = (detail: string, status: number): JsonApiErrorBody => ({
errors: [{ detail, status: String(status) }],
});
export const handlersForFixture = (fx: PageFixture) => [
http.get(`${API}/attack-paths-scans`, () =>
HttpResponse.json<AttackPathScansResponse>(toScansApiResponse(fx.scans)),
),
http.get<{ scanId: string }>(
`${API}/attack-paths-scans/:scanId/queries`,
() =>
HttpResponse.json<AttackPathQueriesResponse>(
toQueriesApiResponse(fx.queries),
),
),
http.post<{ scanId: string }>(
`${API}/attack-paths-scans/:scanId/queries/run`,
() => {
if (fx.queryError) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody(fx.queryError.error, fx.queryError.status),
{ status: fx.queryError.status },
);
}
if (!fx.queryResult) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody("No data found", 404),
{ status: 404 },
);
}
return HttpResponse.json<AttackPathQueryResult>(
toQueryResultApiResponse(fx.queryResult, fx.queryId),
);
},
),
http.post<{ scanId: string }>(
`${API}/attack-paths-scans/:scanId/queries/custom`,
() => {
if (fx.queryError) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody(fx.queryError.error, fx.queryError.status),
{ status: fx.queryError.status },
);
}
if (!fx.queryResult) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody("No data found", 404),
{ status: 404 },
);
}
return HttpResponse.json<AttackPathQueryResult>(
toQueryResultApiResponse(fx.queryResult, fx.queryId),
);
},
),
];
+13
View File
@@ -0,0 +1,13 @@
import type { HttpHandler } from "msw";
/**
* Static handlers shared by every browser test — registered as defaults on
* the worker. Use this list for endpoints whose response doesn't change
* across tests (e.g. `/users/me`, `/tenants/current`, health checks).
*
* Per-domain dynamic handlers that depend on fixture data live in their own
* files alongside this index (e.g. `./attack-paths.ts`) and are imported
* directly by the tests that need them, then wired via
* `worker.use(...handlersForFixture(fx))`.
*/
export const handlers: HttpHandler[] = [];
+5
View File
@@ -0,0 +1,5 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
+25
View File
@@ -0,0 +1,25 @@
import type { ComponentType, PropsWithChildren, ReactElement } from "react";
import { render as vitestRender } from "vitest-browser-react";
const TestProviders = ({ children }: PropsWithChildren) => <>{children}</>;
type RenderOptions = Parameters<typeof vitestRender>[1];
export function render(ui: ReactElement, options?: RenderOptions) {
const userWrapper = options?.wrapper as
| ComponentType<PropsWithChildren>
| undefined;
const Wrapper = userWrapper
? ({ children }: PropsWithChildren) => {
const Inner = userWrapper;
return (
<TestProviders>
<Inner>{children}</Inner>
</TestProviders>
);
}
: TestProviders;
return vitestRender(ui, { ...options, wrapper: Wrapper });
}
@@ -131,27 +131,16 @@ export function adaptQueryResultToGraphData(
// Populate findings and resources based on HAS_FINDING edges
edges.forEach((edge) => {
if (edge.type === "HAS_FINDING") {
const sourceId =
typeof edge.source === "string"
? edge.source
: (edge.source as { id?: string })?.id;
const targetId =
typeof edge.target === "string"
? edge.target
: (edge.target as { id?: string })?.id;
// Add finding to source node (resource -> finding)
const sourceNode = normalizedNodes.find((n) => n.id === edge.source);
if (sourceNode) {
sourceNode.findings.push(edge.target);
}
if (sourceId && targetId) {
// Add finding to source node (resource -> finding)
const sourceNode = normalizedNodes.find((n) => n.id === sourceId);
if (sourceNode) {
sourceNode.findings.push(targetId);
}
// Add resource to target node (finding <- resource)
const targetNode = normalizedNodes.find((n) => n.id === targetId);
if (targetNode) {
targetNode.resources.push(sourceId);
}
// Add resource to target node (finding <- resource)
const targetNode = normalizedNodes.find((n) => n.id === edge.target);
if (targetNode) {
targetNode.resources.push(edge.source);
}
}
});
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { GraphControls } from "./graph-controls";
const baseProps = {
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onFitToScreen: vi.fn(),
};
describe("GraphControls", () => {
it("disables the export button and surfaces the unavailable message when no onExport is provided", () => {
render(<GraphControls {...baseProps} />);
const exportButton = screen.getByRole("button", {
name: /export available soon/i,
});
expect(exportButton).toBeDisabled();
expect(
screen.queryByRole("button", { name: /^export graph$/i }),
).not.toBeInTheDocument();
});
it("enables the export button and invokes the callback when onExport is provided", async () => {
const user = userEvent.setup();
const onExport = vi.fn();
render(<GraphControls {...baseProps} onExport={onExport} />);
const exportButton = screen.getByRole("button", {
name: /^export graph$/i,
});
expect(exportButton).toBeEnabled();
await user.click(exportButton);
expect(onExport).toHaveBeenCalledTimes(1);
});
});
@@ -14,7 +14,7 @@ interface GraphControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onFitToScreen: () => void;
onExport: () => void;
onExport?: () => void;
}
/**
@@ -38,6 +38,7 @@ export const GraphControls = ({
size="sm"
onClick={onZoomIn}
className="h-8 w-8 p-0"
aria-label="Zoom in"
>
<ZoomIn size={18} />
</Button>
@@ -52,6 +53,7 @@ export const GraphControls = ({
size="sm"
onClick={onZoomOut}
className="h-8 w-8 p-0"
aria-label="Zoom out"
>
<ZoomOut size={18} />
</Button>
@@ -66,6 +68,7 @@ export const GraphControls = ({
size="sm"
onClick={onFitToScreen}
className="h-8 w-8 p-0"
aria-label="Fit graph to view"
>
<Minimize2 size={18} />
</Button>
@@ -79,12 +82,16 @@ export const GraphControls = ({
variant="ghost"
size="sm"
onClick={onExport}
disabled={!onExport}
className="h-8 w-8 p-0"
aria-label={onExport ? "Export graph" : "Export available soon"}
>
<Download size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Export graph</TooltipContent>
<TooltipContent>
{onExport ? "Export graph" : "Export available soon"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -1,4 +1,4 @@
export type { AttackPathGraphRef } from "./attack-path-graph";
export type { GraphHandle } from "./attack-path-graph";
export { AttackPathGraph } from "./attack-path-graph";
export { GraphControls } from "./graph-controls";
export { GraphLegend } from "./graph-legend";
@@ -0,0 +1,82 @@
"use client";
import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, truncateLabel } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const HEXAGON_WIDTH = 200;
const HEXAGON_HEIGHT = 55;
const TITLE_MAX_CHARS = 24;
export const FindingNode = ({ data, selected }: NodeProps) => {
const { graphNode } = data as FindingNodeData;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
});
const title = String(
graphNode.properties?.check_title ||
graphNode.properties?.name ||
graphNode.properties?.id ||
"Finding",
);
const displayTitle = truncateLabel(title, TITLE_MAX_CHARS);
// Hexagon SVG path
const w = HEXAGON_WIDTH;
const h = HEXAGON_HEIGHT;
const sideInset = w * 0.15;
const hexPath = `
M ${sideInset} 0
L ${w - sideInset} 0
L ${w} ${h / 2}
L ${w - sideInset} ${h}
L ${sideInset} ${h}
L 0 ${h / 2}
Z
`;
return (
<>
<HiddenHandles />
<svg
width={w}
height={h}
className="overflow-visible"
style={{ filter: selected ? undefined : "url(#glow)" }}
>
<path
d={hexPath}
fill={fillColor}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={selected ? 4 : 2}
className={selected ? "selected-node" : undefined}
/>
<text
x={w / 2}
y={h / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="11px"
fontWeight="600"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitle}
</text>
</svg>
</>
);
};
@@ -0,0 +1,10 @@
"use client";
import { Handle, Position } from "@xyflow/react";
export const HiddenHandles = () => (
<>
<Handle type="target" position={Position.Left} className="invisible" />
<Handle type="source" position={Position.Right} className="invisible" />
</>
);
@@ -0,0 +1,81 @@
"use client";
import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
interface InternetNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const RADIUS = 40; // NODE_HEIGHT * 0.8
const DIAMETER = RADIUS * 2;
export const InternetNode = ({ data, selected }: NodeProps) => {
const { graphNode } = data as InternetNodeData;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
});
const strokeWidth = selected ? 4 : 1.5;
return (
<>
<HiddenHandles />
<svg width={DIAMETER} height={DIAMETER} className="overflow-visible">
{/* Main circle */}
<circle
cx={RADIUS}
cy={RADIUS}
r={RADIUS}
fill={fillColor}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={strokeWidth}
className={selected ? "selected-node" : undefined}
/>
{/* Horizontal ellipse (equator) */}
<ellipse
cx={RADIUS}
cy={RADIUS}
rx={RADIUS}
ry={RADIUS * 0.35}
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.5}
/>
{/* Vertical ellipse (meridian) */}
<ellipse
cx={RADIUS}
cy={RADIUS}
rx={RADIUS * 0.35}
ry={RADIUS}
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.5}
/>
{/* Label */}
<text
x={RADIUS}
y={RADIUS}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="11px"
fontWeight="600"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
Internet
</text>
</svg>
</>
);
};
@@ -0,0 +1,92 @@
"use client";
import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, truncateLabel } from "../../../_lib";
import { formatNodeLabel } from "../../../_lib/format";
import { HiddenHandles } from "./hidden-handles";
interface ResourceNodeData {
graphNode: GraphNode;
hasFindings?: boolean;
[key: string]: unknown;
}
const NODE_WIDTH = 180;
const NODE_HEIGHT = 50;
const NODE_RADIUS = 25;
const NAME_MAX_CHARS = 22;
export const ResourceNode = ({ data, selected }: NodeProps) => {
const { graphNode, hasFindings } = data as ResourceNodeData;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
hasFindings,
});
const strokeWidth = selected ? 4 : hasFindings ? 2.5 : 1.5;
const name = String(
graphNode.properties?.name ||
graphNode.properties?.id ||
(graphNode.labels.length > 0
? formatNodeLabel(graphNode.labels[0])
: "Unknown"),
);
const displayName = truncateLabel(name, NAME_MAX_CHARS);
const typeLabel =
graphNode.labels.length > 0 ? formatNodeLabel(graphNode.labels[0]) : "";
return (
<>
<HiddenHandles />
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<rect
x={0}
y={0}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={NODE_RADIUS}
ry={NODE_RADIUS}
fill={fillColor}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={strokeWidth}
className={selected ? "selected-node" : undefined}
/>
<text
x={NODE_WIDTH / 2}
y={NODE_HEIGHT / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
<tspan
x={NODE_WIDTH / 2}
dy="-0.3em"
fontSize="11px"
fontWeight="600"
>
{displayName}
</tspan>
{typeLabel && (
<tspan
x={NODE_WIDTH / 2}
dy="1.3em"
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
</>
);
};
@@ -1,4 +1,3 @@
export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel";
export { NodeOverview } from "./node-overview";
export { NodeRelationships } from "./node-relationships";
export { NodeRemediation } from "./node-remediation";
@@ -1,105 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import type { GraphEdge } from "@/types/attack-paths";
interface NodeRelationshipsProps {
incomingEdges: GraphEdge[];
outgoingEdges: GraphEdge[];
}
/**
* Format edge type to human-readable label
* e.g., "HAS_FINDING" -> "Has Finding"
*/
function formatEdgeType(edgeType: string): string {
return edgeType
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
interface EdgeItemProps {
edge: GraphEdge;
isOutgoing: boolean;
}
/**
* Reusable edge item component
*/
function EdgeItem({ edge, isOutgoing }: EdgeItemProps) {
const targetId =
typeof edge.target === "string" ? edge.target : String(edge.target);
const sourceId =
typeof edge.source === "string" ? edge.source : String(edge.source);
const displayId = (isOutgoing ? targetId : sourceId).substring(0, 30);
return (
<div
key={edge.id}
className="border-border-neutral-tertiary dark:border-border-neutral-tertiary flex items-center justify-between rounded border p-2"
>
<code className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
{displayId}
</code>
<span
className={cn(
"rounded px-2 py-1 text-xs font-medium",
isOutgoing
? "bg-bg-data-info text-text-neutral-primary dark:text-text-neutral-primary"
: "bg-bg-pass-primary text-text-neutral-primary dark:text-text-neutral-primary",
)}
>
{formatEdgeType(edge.type)}
</span>
</div>
);
}
/**
* Node relationships section showing incoming and outgoing edges
*/
export const NodeRelationships = ({
incomingEdges,
outgoingEdges,
}: NodeRelationshipsProps) => {
return (
<div className="flex flex-col gap-6">
{/* Outgoing Relationships */}
<div>
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Outgoing Relationships ({outgoingEdges.length})
</h4>
{outgoingEdges.length > 0 ? (
<div className="space-y-2">
{outgoingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No outgoing relationships
</p>
)}
</div>
{/* Incoming Relationships */}
<div className="border-border-neutral-tertiary dark:border-border-neutral-tertiary border-t pt-6">
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Incoming Relationships ({incomingEdges.length})
</h4>
{incomingEdges.length > 0 ? (
<div className="space-y-2">
{incomingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing={false} />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No incoming relationships
</p>
)}
</div>
</div>
);
};
@@ -14,6 +14,11 @@ interface FilteredViewState {
isFilteredView: boolean;
filteredNodeId: string | null;
fullData: AttackPathGraphData | null; // Original data before filtering
// Tier 1 expansion state: which resource nodes have their findings revealed.
// Lives in the store (not local component state) so it survives the data
// swaps that happen when entering/exiting filtered view. Reset only on
// fresh data loads (new query / scan) — see `setGraphData`.
expandedResources: Set<string>;
}
interface GraphStore extends GraphState, FilteredViewState {
@@ -21,14 +26,13 @@ interface GraphStore extends GraphState, FilteredViewState {
setSelectedNodeId: (nodeId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setZoom: (zoomLevel: number) => void;
setPan: (panX: number, panY: number) => void;
setFilteredView: (
isFiltered: boolean,
nodeId: string | null,
filteredData: AttackPathGraphData | null,
fullData: AttackPathGraphData | null,
) => void;
toggleExpandedResource: (resourceId: string) => void;
reset: () => void;
}
@@ -37,15 +41,13 @@ const initialState: GraphState & FilteredViewState = {
selectedNodeId: null,
loading: false,
error: null,
zoomLevel: 1,
panX: 0,
panY: 0,
isFilteredView: false,
filteredNodeId: null,
fullData: null,
expandedResources: new Set(),
};
const useGraphStore = create<GraphStore>((set) => ({
export const useGraphStore = create<GraphStore>((set) => ({
...initialState,
setGraphData: (data) =>
set({
@@ -54,12 +56,12 @@ const useGraphStore = create<GraphStore>((set) => ({
error: null,
isFilteredView: false,
filteredNodeId: null,
// Fresh data → drop any stale expansion from the previous graph.
expandedResources: new Set(),
}),
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setZoom: (zoomLevel) => set({ zoomLevel }),
setPan: (panX, panY) => set({ panX, panY }),
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
set({
isFilteredView: isFiltered,
@@ -68,6 +70,16 @@ const useGraphStore = create<GraphStore>((set) => ({
fullData,
selectedNodeId: nodeId,
}),
toggleExpandedResource: (resourceId) =>
set((state) => {
const next = new Set(state.expandedResources);
if (next.has(resourceId)) {
next.delete(resourceId);
} else {
next.add(resourceId);
}
return { expandedResources: next };
}),
reset: () => set(initialState),
}));
@@ -106,11 +118,6 @@ export const useGraphState = () => {
store.setError(error);
};
const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => {
store.setZoom(zoomLevel);
store.setPan(panX, panY);
};
const resetGraph = () => {
store.reset();
};
@@ -162,18 +169,16 @@ export const useGraphState = () => {
selectedNode: getSelectedNode(),
loading: store.loading,
error: store.error,
zoomLevel: store.zoomLevel,
panX: store.panX,
panY: store.panY,
isFilteredView: store.isFilteredView,
filteredNodeId: store.filteredNodeId,
filteredNode: getFilteredNode(),
expandedResources: store.expandedResources,
toggleExpandedResource: store.toggleExpandedResource,
updateGraphData,
selectNode,
startLoading,
stopLoading,
setError,
updateZoomAndPan,
resetGraph,
clearGraph,
enterFilteredView,
@@ -0,0 +1,81 @@
import type { Rect } from "@xyflow/react";
import { domToPng } from "modern-screenshot";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { exportGraphAsJSON, exportGraphAsPNG } from "./export";
vi.mock("modern-screenshot", () => ({
domToPng: vi.fn(),
}));
const bounds: Rect = { x: 0, y: 0, width: 100, height: 100 };
const buildContainerWithViewport = () => {
const container = document.createElement("div");
const viewport = document.createElement("div");
viewport.className = "react-flow__viewport";
container.appendChild(viewport);
return container;
};
describe("exportGraphAsPNG", () => {
beforeEach(() => {
vi.mocked(domToPng).mockReset();
});
it("throws when the container is not mounted", async () => {
await expect(exportGraphAsPNG(null, bounds)).rejects.toThrow(
"Graph container not mounted",
);
expect(domToPng).not.toHaveBeenCalled();
});
it("throws when the React Flow viewport is missing inside the container", async () => {
const container = document.createElement("div");
await expect(exportGraphAsPNG(container, bounds)).rejects.toThrow(
"React Flow viewport not found in container",
);
expect(domToPng).not.toHaveBeenCalled();
});
it("throws when bounds are null (no nodes to export)", async () => {
const container = buildContainerWithViewport();
await expect(exportGraphAsPNG(container, null)).rejects.toThrow(
"No nodes to export",
);
expect(domToPng).not.toHaveBeenCalled();
});
it("re-throws a generic export error when domToPng rejects", async () => {
const container = buildContainerWithViewport();
vi.mocked(domToPng).mockRejectedValueOnce(new Error("rasterizer boom"));
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
await expect(exportGraphAsPNG(container, bounds)).rejects.toThrow(
"Failed to export graph",
);
expect(domToPng).toHaveBeenCalledOnce();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe("exportGraphAsJSON", () => {
it("re-throws a generic export error when serialization fails", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
expect(() => exportGraphAsJSON(circular)).toThrow("Failed to export graph");
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
@@ -1,13 +1,11 @@
/**
* Export utilities for attack path graphs
* Handles exporting graph visualization to various formats
* React Flow renders HTML, so PNG export uses modern-screenshot + RF viewport math
*/
/**
* Helper function to download a blob as a file
* @param blob The blob to download
* @param filename The name of the file
*/
import { getViewportForBounds, type Rect } from "@xyflow/react";
import { domToPng } from "modern-screenshot";
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -19,106 +17,73 @@ const downloadBlob = (blob: Blob, filename: string) => {
URL.revokeObjectURL(url);
};
/**
* Export graph as SVG image
* @param svgElement The SVG element to export
* @param filename The name of the file to download
*/
export const exportGraphAsSVG = (
svgElement: SVGSVGElement | null,
filename: string = "attack-path-graph.svg",
) => {
if (!svgElement) return;
try {
// Clone the SVG element to avoid modifying the original
const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement;
// Find the main container group (first g element with transform)
const containerGroup = clonedSvg.querySelector("g");
if (!containerGroup) {
throw new Error("Could not find graph container");
}
// Get the bounding box of the actual graph content
// We need to get it from the original SVG since cloned elements don't have computed geometry
const originalContainer = svgElement.querySelector("g");
if (!originalContainer) {
throw new Error("Could not find original graph container");
}
const bbox = originalContainer.getBBox();
// Add padding around the content
const padding = 50;
const contentWidth = bbox.width + padding * 2;
const contentHeight = bbox.height + padding * 2;
// Set the SVG dimensions to fit the content
clonedSvg.setAttribute("width", `${contentWidth}`);
clonedSvg.setAttribute("height", `${contentHeight}`);
clonedSvg.setAttribute(
"viewBox",
`${bbox.x - padding} ${bbox.y - padding} ${contentWidth} ${contentHeight}`,
);
// Remove the zoom transform from the container - the viewBox now handles positioning
containerGroup.removeAttribute("transform");
// Add white background for better visibility
const bgRect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
bgRect.setAttribute("x", `${bbox.x - padding}`);
bgRect.setAttribute("y", `${bbox.y - padding}`);
bgRect.setAttribute("width", `${contentWidth}`);
bgRect.setAttribute("height", `${contentHeight}`);
bgRect.setAttribute("fill", "#1c1917"); // Dark background matching the app
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
const svgData = new XMLSerializer().serializeToString(clonedSvg);
const blob = new Blob([svgData], { type: "image/svg+xml" });
downloadBlob(blob, filename);
} catch (error) {
console.error("Failed to export graph as SVG:", error);
throw new Error("Failed to export graph");
}
const downloadDataUrl = (dataUrl: string, filename: string) => {
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Export target dimensions — fixed so bounds math is deterministic across zoom levels
const EXPORT_IMAGE_WIDTH = 1920;
const EXPORT_IMAGE_HEIGHT = 1080;
const EXPORT_MIN_ZOOM = 0.2;
const EXPORT_MAX_ZOOM = 2;
const EXPORT_PADDING = 0.1;
const EXPORT_BACKGROUND = "#1c1917";
/**
* Export graph as PNG image
* @param svgElement The SVG element to export
* @param filename The name of the file to download
* Export graph as PNG via modern-screenshot.
*
* Receives pre-computed node bounds (use `GraphHandle.getNodesBounds()` so the
* React Flow instance's `nodeLookup` is honored for sub-flows). Then uses
* `getViewportForBounds()` to produce a viewport transform that fits all nodes
* inside the export canvas regardless of the user's current zoom/pan, and
* applies it to `.react-flow__viewport` before rasterizing.
*/
export const exportGraphAsPNG = async (
svgElement: SVGSVGElement | null,
containerElement: HTMLDivElement | null,
bounds: Rect | null,
filename: string = "attack-path-graph.png",
) => {
if (!svgElement) return;
if (!containerElement) {
throw new Error("Graph container not mounted");
}
const viewportElement = containerElement.querySelector<HTMLElement>(
".react-flow__viewport",
);
if (!viewportElement) {
throw new Error("React Flow viewport not found in container");
}
if (!bounds) {
throw new Error("No nodes to export");
}
const viewport = getViewportForBounds(
bounds,
EXPORT_IMAGE_WIDTH,
EXPORT_IMAGE_HEIGHT,
EXPORT_MIN_ZOOM,
EXPORT_MAX_ZOOM,
EXPORT_PADDING,
);
try {
const svgData = new XMLSerializer().serializeToString(svgElement);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
if (!ctx) throw new Error("Could not get canvas context");
const svg = new Image();
svg.onload = () => {
canvas.width = svg.width;
canvas.height = svg.height;
ctx.drawImage(svg, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
downloadBlob(blob, filename);
}
});
};
svg.onerror = () => {
throw new Error("Failed to load SVG for PNG conversion");
};
svg.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
const dataUrl = await domToPng(viewportElement, {
backgroundColor: EXPORT_BACKGROUND,
width: EXPORT_IMAGE_WIDTH,
height: EXPORT_IMAGE_HEIGHT,
style: {
width: `${EXPORT_IMAGE_WIDTH}px`,
height: `${EXPORT_IMAGE_HEIGHT}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
},
});
downloadDataUrl(dataUrl, filename);
} catch (error) {
console.error("Failed to export graph as PNG:", error);
throw new Error("Failed to export graph");
@@ -126,9 +91,7 @@ export const exportGraphAsPNG = async (
};
/**
* Export graph data as JSON
* @param graphData The graph data to export
* @param filename The name of the file to download
* Export graph data as JSON (format-agnostic — does not depend on DOM rendering).
*/
export const exportGraphAsJSON = (
graphData: Record<string, unknown>,
@@ -23,3 +23,7 @@ export function formatNodeLabel(label: string): string {
export function formatNodeLabels(labels: string[]): string {
return labels.map(formatNodeLabel).join(", ");
}
export function truncateLabel(text: string, maxChars: number): string {
return text.length > maxChars ? `${text.substring(0, maxChars)}...` : text;
}
@@ -128,6 +128,37 @@ export const getNodeBorderColor = (
return GRAPH_NODE_BORDER_COLORS.default;
};
interface ResolveNodeColorsParams {
labels: string[];
properties?: Record<string, unknown>;
selected?: boolean;
hasFindings?: boolean;
}
interface NodeColorResult {
fillColor: string;
borderColor: string;
}
/**
* Resolve fill and border colors for a graph node, layering selection and
* finding-alert state on top of the label/severity defaults.
*/
export const resolveNodeColors = ({
labels,
properties,
selected,
hasFindings,
}: ResolveNodeColorsParams): NodeColorResult => {
const fillColor = getNodeColor(labels, properties);
const borderColor = hasFindings
? GRAPH_ALERT_BORDER_COLOR
: selected
? GRAPH_EDGE_HIGHLIGHT_COLOR
: getNodeBorderColor(labels, properties);
return { fillColor, borderColor };
};
/**
* Check if a background color is light (for determining text color)
*/
@@ -4,23 +4,6 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
/**
* Type for edge node reference - can be a string ID or an object with id property
* Note: We use `object` to match GraphEdge type from attack-paths.ts
*/
export type EdgeNodeRef = string | object;
/**
* Helper to get edge source/target ID from string or object
*/
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
if (typeof nodeRef === "string") {
return nodeRef;
}
// Edge node references are objects with an id property
return (nodeRef as { id: string }).id;
};
/**
* Compute a filtered subgraph containing only the path through the target node.
* This follows the directed graph structure of attack paths:
@@ -44,10 +27,8 @@ export const computeFilteredSubgraph = (
});
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
forwardEdges.get(sourceId)?.add(targetId);
backwardEdges.get(targetId)?.add(sourceId);
forwardEdges.get(edge.source)?.add(edge.target);
backwardEdges.get(edge.target)?.add(edge.source);
});
const visibleNodeIds = new Set<string>();
@@ -84,35 +65,30 @@ export const computeFilteredSubgraph = (
traverseDownstream(targetNodeId);
// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
const sourceNode = nodes.find((n) => n.id === sourceId);
const targetNode = nodes.find((n) => n.id === targetId);
const sourceIsFinding = sourceNode?.labels.some((l) =>
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);
const targetIsFinding = targetNode?.labels.some((l) =>
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);
// Include findings connected to the selected node
if (sourceId === targetNodeId && targetIsFinding) {
visibleNodeIds.add(targetId);
if (edge.source === targetNodeId && targetIsFinding) {
visibleNodeIds.add(edge.target);
}
if (targetId === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(sourceId);
if (edge.target === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(edge.source);
}
});
// Filter nodes and edges to only include visible ones
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
const filteredEdges = edges.filter((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
const filteredEdges = edges.filter(
(edge) =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
);
return {
nodes: filteredNodes,
@@ -1,9 +1,5 @@
export {
exportGraphAsJSON,
exportGraphAsPNG,
exportGraphAsSVG,
} from "./export";
export { formatNodeLabel, formatNodeLabels } from "./format";
export { exportGraphAsJSON, exportGraphAsPNG } from "./export";
export { formatNodeLabel, formatNodeLabels, truncateLabel } from "./format";
export {
getNodeBorderColor,
getNodeColor,
@@ -14,10 +10,7 @@ export {
GRAPH_NODE_BORDER_COLORS,
GRAPH_NODE_COLORS,
GRAPH_SELECTION_COLOR,
resolveNodeColors,
} from "./graph-colors";
export {
computeFilteredSubgraph,
type EdgeNodeRef,
getEdgeNodeId,
getPathEdges,
} from "./graph-utils";
export { computeFilteredSubgraph, getPathEdges } from "./graph-utils";
export { layoutWithDagre } from "./layout";
@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
import { layoutWithDagre } from "./layout";
const findingNode: GraphNode = {
id: "finding-1",
labels: ["ProwlerFinding"],
properties: { check_title: "Open S3 bucket", severity: "high" },
};
const resourceNode: GraphNode = {
id: "resource-1",
labels: ["S3Bucket"],
properties: { name: "bucket-1" },
};
const internetNode: GraphNode = {
id: "internet-1",
labels: ["Internet"],
properties: {},
};
describe("layoutWithDagre", () => {
it("returns empty arrays for empty input", () => {
const result = layoutWithDagre([], []);
expect(result.rfNodes).toEqual([]);
expect(result.rfEdges).toEqual([]);
});
it("assigns node types and dimensions from labels", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, resourceNode, internetNode],
[],
);
const byId = new Map(rfNodes.map((n) => [n.id, n]));
expect(byId.get("finding-1")).toMatchObject({
type: "finding",
width: 200,
height: 55,
});
expect(byId.get("resource-1")).toMatchObject({
type: "resource",
width: 180,
height: 50,
});
expect(byId.get("internet-1")).toMatchObject({
type: "internet",
width: 80,
height: 80,
});
});
it("is deterministic: same input produces equal output across runs", () => {
const nodes = [findingNode, resourceNode];
const edges: GraphEdge[] = [
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
];
const a = layoutWithDagre(nodes, edges);
const b = layoutWithDagre(nodes, edges);
expect(a).toEqual(b);
});
it("offsets dagre center positions by half of the node dimensions (top-left)", () => {
const { rfNodes } = layoutWithDagre([findingNode, resourceNode], []);
rfNodes.forEach((node) => {
expect(Number.isFinite(node.position.x)).toBe(true);
expect(Number.isFinite(node.position.y)).toBe(true);
});
// Different node types must end up with different sizes — confirms the
// dimension-aware offset is wired up.
const findingDims = rfNodes.find((n) => n.id === "finding-1");
const resourceDims = rfNodes.find((n) => n.id === "resource-1");
expect(findingDims?.width).not.toEqual(resourceDims?.width);
});
it("reverses container relationships while preserving original endpoints in edge data", () => {
const containerNode: GraphNode = {
id: "container",
labels: ["AWSAccount"],
properties: { name: "acct" },
};
const childNode: GraphNode = {
id: "child",
labels: ["S3Bucket"],
properties: { name: "bucket" },
};
const { rfEdges } = layoutWithDagre(
[containerNode, childNode],
[
{
id: "e1",
source: "container",
target: "child",
type: "RUNS_IN",
},
],
);
expect(rfEdges).toHaveLength(1);
expect(rfEdges[0]).toMatchObject({
source: "child",
target: "container",
data: { originalSource: "container", originalTarget: "child" },
});
});
it("animates edges that touch a finding node and tags them with finding-edge", () => {
const { rfEdges } = layoutWithDagre(
[findingNode, resourceNode, internetNode],
[
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
{
id: "e2",
source: "internet-1",
target: "resource-1",
type: "CONNECTS_TO",
},
],
);
const findingEdge = rfEdges.find(
(e) => e.source === "resource-1" && e.target === "finding-1",
);
const plainEdge = rfEdges.find(
(e) => e.source === "internet-1" && e.target === "resource-1",
);
expect(findingEdge).toMatchObject({
animated: true,
className: "finding-edge",
});
expect(plainEdge).toMatchObject({
animated: false,
className: "resource-edge",
});
});
it("builds rf edge IDs as `${source}-${target}` after layout", () => {
const { rfEdges } = layoutWithDagre(
[findingNode, resourceNode],
[
{
id: "ignored-by-rf",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
],
);
expect(rfEdges[0]?.id).toBe("resource-1-finding-1");
});
});
@@ -0,0 +1,149 @@
/**
* Pure Dagre layout adapter for React Flow
* Converts normalized GraphNode[] + GraphEdge[] to positioned RF nodes
*/
import { Graph, layout as dagreLayout } from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
// Node dimensions matching the original D3 implementation
const NODE_WIDTH = 180;
const NODE_HEIGHT = 50;
const HEXAGON_WIDTH = 200;
const HEXAGON_HEIGHT = 55;
const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
"RUNS_IN",
"BELONGS_TO",
"LOCATED_IN",
"PART_OF",
]);
interface NodeData extends Record<string, unknown> {
graphNode: GraphNode;
}
const NODE_TYPE = {
FINDING: "finding",
INTERNET: "internet",
RESOURCE: "resource",
} as const;
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
export const isFindingNode = (labels: string[]): boolean =>
labels.some((l) => l.toLowerCase().includes("finding"));
const getNodeType = (labels: string[]): NodeType => {
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
if (labels.some((l) => l.toLowerCase() === "internet"))
return NODE_TYPE.INTERNET;
return NODE_TYPE.RESOURCE;
};
const getNodeDimensions = (
type: NodeType,
): { width: number; height: number } => {
if (type === NODE_TYPE.FINDING)
return { width: HEXAGON_WIDTH, height: HEXAGON_HEIGHT };
if (type === NODE_TYPE.INTERNET)
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
return { width: NODE_WIDTH, height: NODE_HEIGHT };
};
/**
* Pure layout function: computes positioned React Flow nodes from graph data.
* Deterministic — same inputs always produce same outputs.
*/
export const layoutWithDagre = (
nodes: GraphNode[],
edges: GraphEdge[],
): { rfNodes: Node<NodeData>[]; rfEdges: Edge[] } => {
const g = new Graph();
g.setGraph({
rankdir: "LR",
nodesep: 80,
ranksep: 150,
marginx: 50,
marginy: 50,
});
g.setDefaultEdgeLabel(() => ({}));
// Add nodes with type-based dimensions
nodes.forEach((node) => {
const type = getNodeType(node.labels);
const { width, height } = getNodeDimensions(type);
g.setNode(node.id, { label: node.id, width, height });
});
// Add edges, reversing container relationships for proper hierarchy
edges.forEach((edge) => {
let sourceId = edge.source;
let targetId = edge.target;
if (CONTAINER_RELATIONS.has(edge.type)) {
[sourceId, targetId] = [targetId, sourceId];
}
if (sourceId && targetId) {
g.setEdge(sourceId, targetId, {
originalSource: edge.source,
originalTarget: edge.target,
});
}
});
dagreLayout(g);
// Build RF nodes from layout
const rfNodes: Node<NodeData>[] = nodes.map((node) => {
const dagreNode = g.node(node.id);
const type = getNodeType(node.labels);
const { width, height } = getNodeDimensions(type);
return {
id: node.id,
type,
position: {
x: dagreNode.x - width / 2,
y: dagreNode.y - height / 2,
},
data: { graphNode: node },
width,
height,
};
});
// Build RF edges from dagre edges (using layout order, not original)
const rfEdges: Edge[] = g.edges().map((e: { v: string; w: string }) => {
const edgeData = g.edge(e) as {
originalSource: string;
originalTarget: string;
};
// Check if either end is a finding node
const sourceNode = nodes.find((n) => n.id === e.v);
const targetNode = nodes.find((n) => n.id === e.w);
const hasFinding =
isFindingNode(sourceNode?.labels ?? []) ||
isFindingNode(targetNode?.labels ?? []);
return {
id: `${e.v}-${e.w}`,
source: e.v,
target: e.w,
animated: hasFinding,
className: hasFinding ? "finding-edge" : "resource-edge",
data: {
originalSource: edgeData.originalSource,
originalTarget: edgeData.originalTarget,
},
};
});
return { rfNodes, rfEdges };
};
@@ -0,0 +1,498 @@
/**
* Browser-mode tests for <AttackPathsPage />.
*
* Tests are grouped by user-perceived flow, not by internal spec taxonomy. Each
* test interacts with the page ONLY through `AttackPathPageHarness`. Each test:
* 1. picks a fixture
* 2. calls `mountWith(fx)` — wires MSW handlers, sets the URL, mounts the page
* 3. drives the harness
*
* If you find yourself reaching for a DOM query in a test, push it into the harness.
*/
import { beforeEach, describe, expect, test as base } from "vitest";
import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import { useGraphStore } from "./_hooks/use-graph-state";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
interface Fixtures {
mountWith: (fx?: PageFixture) => Promise<AttackPathPageHarness>;
}
// The graph store is module-scoped, so it survives across tests in the same
// file. Reset it before each test so no test sees stale state from a previous
// one (selection, filtered view, expanded resources, etc.).
beforeEach(() => {
useGraphStore.getState().reset();
});
const test = base.extend<Fixtures>({
mountWith: async ({}, use) => {
// `use` is Vitest's fixture-injection callback, not React's `use` hook.
// eslint-disable-next-line react-hooks/rules-of-hooks
await use(async (fx = fixtures.typical()) => {
worker.use(...handlersForFixture(fx));
window.history.replaceState({}, "", `/attack-paths?scanId=${fx.scanId}`);
await render(<AttackPathsPage />);
return new AttackPathPageHarness(fx);
});
},
});
describe("loading the page", () => {
test("an account with no scans shows the empty state", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.emptyScans());
expect(await graph.emptyStateMessage()).toMatch(/No scans available/i);
});
});
describe("running a query", () => {
test("the graph renders with a background, a minimap, and a viewport", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.background).toBeTruthy();
expect(graph.minimap).toBeTruthy();
expect(graph.viewport).toBeTruthy();
});
test("nodes are laid out at distinct positions", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
const positions = graph.nodePositions;
expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true);
});
test("the toolbar exposes zoom, fit, and export controls", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(1);
expect(graph.toolbar.zoomInButton).toBeTruthy();
expect(graph.toolbar.zoomOutButton).toBeTruthy();
expect(graph.toolbar.fitButton).toBeTruthy();
expect(graph.toolbar.exportButton).toBeTruthy();
});
test("finding, resource, and internet nodes all render", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.resourceNodes.length).toBeGreaterThan(0);
expect(graph.internetNodes.length).toBeGreaterThan(0);
});
test("only edges connected to a finding are animated", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
expect(graph.findingEdges.length).toBeGreaterThan(0);
expect(graph.resourceEdges.length).toBeGreaterThan(0);
});
test("edges connect string source and target ids", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(2);
const edgeIds = graph.renderedEdgeIds;
expect(edgeIds.length).toBeGreaterThan(0);
for (const id of edgeIds) {
expect(id).toMatch(/^[\w-]+-[\w-]+$/);
}
});
test("a query that returns one node renders just that node", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.singleNode());
await graph.executeQuery();
await graph.waitForLayoutStable(1);
expect(graph.nodes).toHaveLength(1);
});
test("a query that returns no graph data surfaces an error without crashing", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.emptyGraph());
try {
await graph.executeQuery();
} catch {
/* expected: layout never stabilizes */
}
expect(graph.nodes).toHaveLength(0);
});
test("a 200-node graph finishes laying out within 5s", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.large(200));
const start = performance.now();
await graph.executeQuery();
await graph.waitForLayoutStable(1);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(5000);
});
test("disconnected components are both visible", async ({ mountWith }) => {
const graph = await mountWith(fixtures.disconnected());
await graph.executeQuery();
await graph.waitForLayoutStable(4);
expect(graph.nodes.length).toBe(4);
});
test("a query that returns only resources renders no findings", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.resourcesOnly());
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.findingNodes.length).toBe(0);
expect(graph.resourceNodes.length).toBe(3);
});
test("findings without a connected resource are hidden by default", async ({
mountWith,
}) => {
// Tier 1 view: unattached findings stay hidden until the user expands
// their adjacent resource — none here, so nothing renders.
const graph = await mountWith(fixtures.findingsOnly());
try {
await graph.executeQuery();
} catch {
/* expected: nothing visible, layout never stabilizes */
}
expect(graph.findingNodes.length).toBe(0);
expect(graph.resourceNodes.length).toBe(0);
});
test("self-loops, cycles, long labels, unicode, and duplicate edges all render", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.edgeCases());
await graph.executeQuery();
await graph.waitForLayoutStable(5);
expect(graph.nodes.length).toBe(7);
expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true);
});
});
describe("exploring the graph", () => {
test("clicking a finding opens the filtered view", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
expect(graph.isInFilteredView).toBe(false);
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
expect(graph.hasNodeDetailsModal).toBe(false);
});
test("clicking a resource with findings opens the action selector", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.hasNodeDetailsModal).toBe(false);
await graph.clickFirstResourceNode();
expect(graph.hasNodeActionDialog).toBe(true);
expect(graph.hasNodeDetailsModal).toBe(false);
});
test("choosing Show findings reveals related finding nodes", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
await graph.chooseShowFindingsAction();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.hasNodeDetailsModal).toBe(false);
});
test("expanded resources offer Hide findings in the action selector", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
await graph.chooseShowFindingsAction();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.clickFirstResourceNode();
expect(graph.hasNodeActionDialog).toBe(true);
expect(graph.containsText(/Hide findings/i)).toBe(true);
await graph.chooseHideFindingsAction();
expect(graph.findingNodes.length).toBe(0);
});
test("choosing View node details opens node details in a modal", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
await graph.chooseViewNodeDetailsAction();
expect(graph.hasNodeDetailsModal).toBe(true);
});
test("clicking a resource without findings opens node details in a modal", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.hasNodeDetailsModal).toBe(false);
await graph.clickFirstResourceNodeWithoutFindings();
expect(graph.hasNodeDetailsModal).toBe(true);
});
test("clicking a parent node in filtered view asks whether to go back or view details", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.clickFirstResourceNode();
expect(graph.hasNodeActionDialog).toBe(true);
expect(graph.containsText(/Back to full graph/i)).toBe(true);
await graph.chooseBackToFullGraphAction();
expect(graph.isInFilteredView).toBe(false);
});
test("exiting the filtered view restores the full graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const fullNodes = graph.nodes.length;
await graph.clickFirstFindingNode();
await graph.exitFilteredView();
await graph.waitForLayoutStable(fullNodes);
expect(graph.isInFilteredView).toBe(false);
});
test("hovering a node highlights its path edges", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.hoverFirstResourceNode();
await graph.waitForTransition(120);
expect(graph.highlightedEdges.length).toBeGreaterThanOrEqual(0);
await graph.unhoverNodes();
await graph.waitForTransition(120);
expect(graph.highlightedEdges.length).toBe(0);
});
test("clicking the empty canvas keeps the full graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickEmptyCanvas();
expect(graph.isInFilteredView).toBe(false);
});
test("rapid clicks on a finding don't duplicate the filtered view", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
await graph.rapidlyClickFirstFindingNode(2);
expect(graph.isInFilteredView).toBe(true);
});
test("double-clicking a node doesn't break state", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.dblClickFirstResourceNode();
expect(graph.nodes.length).toBeGreaterThan(0);
});
});
describe("auto-fitting the viewport", () => {
test("the minimap viewport indicator has a visible border", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.minimapMaskStrokeWidth).toBeGreaterThan(0);
});
test("expanding resources re-fits the viewport when revealed findings fall off-screen", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
// Given - zoom into the current overview so newly revealed findings can
// sit entirely outside the current frame. The expand auto-fit should then
// recover the user instead of leaving them hunting off-screen.
for (let i = 0; i < 5; i++) {
await graph.zoomIn();
await graph.waitForTransition(80);
}
// Hidden findings are not measured by the initial declarative fit, so
// their positions can sit outside the framed viewport. Expanding the
// resources should re-fit so the user does not have to hunt for the
// newly visible findings off-screen.
const before = graph.viewportTransform;
expect(before).toBeTruthy();
await graph.expandAllFindings();
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(before);
});
test("clicking a finding re-fits the viewport for the filtered subgraph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const beforeFilter = graph.viewportTransform;
expect(beforeFilter).toBeTruthy();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(beforeFilter);
});
test("Back to Full View re-fits the viewport for the full graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.waitForTransition();
const filterT = graph.viewportTransform;
await graph.exitFilteredView();
await graph.waitForLayoutStable(3);
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(filterT);
});
});
describe("exporting the graph", () => {
test("the export button is enabled when a graph is rendered", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.toolbar.isExportButtonEnabled).toBe(true);
});
test("clicking export downloads a PNG sized to the configured export canvas", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const png = await graph.captureExportPNG();
expect(png.filename).toBe("attack-path-graph.png");
expect(png.mimeType).toBe("image/png");
// Regressions in the viewport element passed to `domToPng`, the
// configured export size, or the bounds-driven viewport transform
// fail loudly here.
expect(png.width).toBe(1920);
expect(png.height).toBe(1080);
});
});
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.expandAllFindings();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.isInFilteredView).toBe(false);
});
});
@@ -0,0 +1,279 @@
/**
* Typed fixture builders for <AttackPathsPage /> browser tests.
*
* Each builder returns a self-contained snapshot of the API surface the page
* exercises: scans list, available queries, and query execution result. The
* MSW handler factory in the harness turns a fixture into HTTP mocks.
*/
import type {
AttackPathQuery,
AttackPathScan,
GraphNode,
GraphRelationship,
QueryResultAttributes,
} from "@/types/attack-paths";
export interface PageFixture {
scans: AttackPathScan[];
scanId: string;
queries: AttackPathQuery[];
queryId: string;
queryResult: QueryResultAttributes | null;
queryError?: { status: number; error: string };
}
const TYPICAL_SCAN_ID = "11111111-1111-4111-8111-111111111111";
const SECOND_SCAN_ID = "22222222-2222-4222-8222-222222222222";
const DEFAULT_QUERY_ID = "aws-public-s3-buckets";
const buildScan = (
id: string,
overrides: Partial<AttackPathScan["attributes"]> = {},
): AttackPathScan => ({
type: "attack-paths-scans",
id,
attributes: {
state: "completed",
progress: 100,
graph_data_ready: true,
provider_alias: `Provider ${id.slice(0, 4)}`,
provider_type: "aws",
provider_uid: `123456789${id.slice(0, 3)}`,
inserted_at: "2026-04-21T10:00:00Z",
started_at: "2026-04-21T10:00:00Z",
completed_at: "2026-04-21T10:05:00Z",
duration: 300,
...overrides,
},
relationships: {
provider: { data: { type: "providers", id: `provider-${id}` } },
scan: { data: { type: "scans", id: `base-scan-${id}` } },
task: { data: { type: "tasks", id: `task-${id}` } },
},
});
const buildQuery = (
id: string,
name: string,
overrides: Partial<AttackPathQuery["attributes"]> = {},
): AttackPathQuery => ({
type: "attack-paths-scans",
id,
attributes: {
name,
short_description: `Run the ${name} query`,
description: `Detailed description for ${name}.`,
provider: "aws",
parameters: [],
attribution: null,
documentation_link: null,
...overrides,
},
});
const buildResourceNode = (
id: string,
label: string,
name: string,
extraLabels: string[] = [],
): GraphNode => ({
id,
labels: [label, ...extraLabels],
properties: { id, name, arn: `arn:aws:example::${id}` },
});
const buildFindingNode = (
id: string,
title: string,
severity = "high",
): GraphNode => ({
id,
labels: ["ProwlerFinding"],
properties: { id, check_title: title, severity, status: "FAIL" },
});
const buildInternetNode = (): GraphNode => ({
id: "internet",
labels: ["Internet"],
properties: { id: "internet", name: "Internet" },
});
const buildRel = (
id: string,
source: string,
target: string,
label: string,
): GraphRelationship => ({ id, source, target, label });
export const typical = (): PageFixture => {
const nodes: GraphNode[] = [
buildInternetNode(),
buildResourceNode("ec2-1", "EC2Instance", "api-server-01"),
buildResourceNode("s3-1", "S3Bucket", "private-data-bucket"),
buildResourceNode("iam-1", "IAMRole", "AppRole"),
buildFindingNode("f-1", "S3 bucket is public", "critical"),
buildFindingNode("f-2", "EC2 exposed to internet", "high"),
];
const relationships: GraphRelationship[] = [
buildRel("r1", "internet", "ec2-1", "CAN_REACH"),
buildRel("r2", "ec2-1", "s3-1", "CAN_ACCESS"),
buildRel("r3", "ec2-1", "iam-1", "ASSUMES"),
buildRel("r4", "s3-1", "f-1", "HAS_FINDING"),
buildRel("r5", "ec2-1", "f-2", "HAS_FINDING"),
];
return {
scans: [buildScan(TYPICAL_SCAN_ID), buildScan(SECOND_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [
buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets"),
buildQuery("aws-open-security-groups", "Open security groups"),
],
queryId: DEFAULT_QUERY_ID,
queryResult: { nodes, relationships },
};
};
export const emptyScans = (): PageFixture => ({
scans: [],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
export const emptyGraph = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
queryError: { status: 404, error: "No data found" },
});
export const singleNode = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [buildResourceNode("only-1", "S3Bucket", "solitary-bucket")],
relationships: [],
},
});
export const findingsOnly = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Findings only")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [
buildFindingNode("f-1", "Finding A", "critical"),
buildFindingNode("f-2", "Finding B", "high"),
buildFindingNode("f-3", "Finding C", "medium"),
],
relationships: [],
},
});
export const resourcesOnly = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Resources only")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [
buildResourceNode("ec2-1", "EC2Instance", "web-1"),
buildResourceNode("ec2-2", "EC2Instance", "web-2"),
buildResourceNode("s3-1", "S3Bucket", "logs"),
],
relationships: [
buildRel("r1", "ec2-1", "s3-1", "CAN_ACCESS"),
buildRel("r2", "ec2-2", "s3-1", "CAN_ACCESS"),
],
},
});
export const disconnected = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Disconnected components")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [
buildResourceNode("a-1", "EC2Instance", "alpha-ec2"),
buildResourceNode("a-2", "S3Bucket", "alpha-s3"),
buildResourceNode("b-1", "EC2Instance", "beta-ec2"),
buildResourceNode("b-2", "S3Bucket", "beta-s3"),
],
relationships: [
buildRel("r1", "a-1", "a-2", "CAN_ACCESS"),
buildRel("r2", "b-1", "b-2", "CAN_ACCESS"),
],
},
});
export const large = (count = 200): PageFixture => {
const nodes: GraphNode[] = [];
const relationships: GraphRelationship[] = [];
for (let i = 0; i < count; i++) {
const id = `n-${i}`;
if (i % 5 === 0) {
nodes.push(buildFindingNode(id, `Finding ${i}`, "high"));
} else {
nodes.push(buildResourceNode(id, "EC2Instance", `instance-${i}`));
}
if (i > 0) {
relationships.push(buildRel(`r-${i}`, `n-${i - 1}`, id, "CAN_REACH"));
}
}
return {
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Large graph")],
queryId: DEFAULT_QUERY_ID,
queryResult: { nodes, relationships },
};
};
export const edgeCases = (): PageFixture => {
const longLabel =
"a very long resource name that should be truncated ".repeat(4);
const nodes: GraphNode[] = [
buildResourceNode("self-1", "EC2Instance", "self-loop"),
buildResourceNode("cy-1", "EC2Instance", "cycle-a"),
buildResourceNode("cy-2", "EC2Instance", "cycle-b"),
buildResourceNode("long-1", "EC2Instance", longLabel),
buildResourceNode("emoji-1", "S3Bucket", "🔒-secure-bucket-日本語"),
buildResourceNode("dup-a", "EC2Instance", "dup-source"),
buildResourceNode("dup-b", "S3Bucket", "dup-target"),
];
const relationships: GraphRelationship[] = [
buildRel("self-edge", "self-1", "self-1", "REFERS_TO"),
buildRel("cy-a", "cy-1", "cy-2", "CAN_REACH"),
buildRel("cy-b", "cy-2", "cy-1", "CAN_REACH"),
buildRel("dup-1", "dup-a", "dup-b", "CAN_ACCESS"),
buildRel("dup-2", "dup-a", "dup-b", "CAN_ACCESS"),
];
return {
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Edge cases")],
queryId: DEFAULT_QUERY_ID,
queryResult: { nodes, relationships },
};
};
export const fixtures = {
typical,
emptyScans,
emptyGraph,
singleNode,
findingsOnly,
resourcesOnly,
disconnected,
large,
edgeCases,
};
@@ -0,0 +1,641 @@
/**
* Test harness for <AttackPathsPage /> browser-mode tests.
*
* Selectors + flows only. Mounting and MSW setup live in the test file.
*/
import { vi } from "vitest";
import { userEvent } from "vitest/browser";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness {
private static readonly NODE_SEL = ".react-flow__node";
private static readonly EDGE_SEL = ".react-flow__edge";
private static readonly VIEWPORT_SEL = ".react-flow__viewport";
private static readonly MINIMAP_SEL = ".react-flow__minimap";
private static readonly BACKGROUND_SEL = ".react-flow__background";
private static isFindingElement(el: Element): boolean {
return (
el.classList.contains("react-flow__node-finding") ||
el.getAttribute("data-nodetype") === "finding"
);
}
private static isResourceElement(el: Element): boolean {
return (
el.classList.contains("react-flow__node-resource") ||
el.getAttribute("data-nodetype") === "resource"
);
}
private static isInternetElement(el: Element): boolean {
return (
el.classList.contains("react-flow__node-internet") ||
el.getAttribute("data-nodetype") === "internet"
);
}
readonly user = userEvent;
constructor(readonly fixture: PageFixture) {}
// --- Container ---
get container(): HTMLElement {
return document.body;
}
// --- Collections ---
get nodes(): HTMLElement[] {
return Array.from(
this.container.querySelectorAll<HTMLElement>(
AttackPathPageHarness.NODE_SEL,
),
);
}
get edges(): HTMLElement[] {
return Array.from(
this.container.querySelectorAll<HTMLElement>(
AttackPathPageHarness.EDGE_SEL,
),
);
}
get findingNodes(): HTMLElement[] {
return this.nodes.filter(AttackPathPageHarness.isFindingElement);
}
get resourceNodes(): HTMLElement[] {
return this.nodes.filter(AttackPathPageHarness.isResourceElement);
}
get internetNodes(): HTMLElement[] {
return this.nodes.filter(AttackPathPageHarness.isInternetElement);
}
get findingEdges(): HTMLElement[] {
return this.edges.filter((e) => e.classList.contains("finding-edge"));
}
get resourceEdges(): HTMLElement[] {
return this.edges.filter((e) => e.classList.contains("resource-edge"));
}
get highlightedEdges(): HTMLElement[] {
return this.edges.filter((e) => e.classList.contains("highlighted"));
}
get renderedNodeIds(): string[] {
return this.nodes.map((el) => el.getAttribute("data-id") ?? "");
}
get renderedEdgeIds(): string[] {
return this.edges.map((el) => el.getAttribute("data-id") ?? "");
}
/**
* Parsed `translate(x, y)` from each rendered node's transform style.
* Useful for asserting layout actually placed nodes (non-zero, distinct, …).
*/
get nodePositions(): Array<{ x: number; y: number }> {
return this.nodes.map((el) => {
const match = /translate\(\s*([-\d.]+)px?\s*,\s*([-\d.]+)px?\s*\)/.exec(
el.style.transform,
);
return match
? { x: Number(match[1]), y: Number(match[2]) }
: { x: 0, y: 0 };
});
}
// --- Predicates ---
get isInFilteredView(): boolean {
return !!this.container.querySelector(
'[aria-label="Return to full graph view"]',
);
}
isNodeSelected(nodeId: string): boolean {
const el = this.getNodeById(nodeId);
return !!el && el.classList.contains("selected");
}
isEdgeHighlighted(edgeId: string): boolean {
const el = this.getEdgeById(edgeId);
return !!el && el.classList.contains("highlighted");
}
isNodeHidden(nodeId: string): boolean {
return !this.getNodeById(nodeId);
}
// --- Lookups ---
getNodeById(id: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(
`${AttackPathPageHarness.NODE_SEL}[data-id="${id}"]`,
);
}
getEdgeById(id: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(
`${AttackPathPageHarness.EDGE_SEL}[data-id="${id}"]`,
);
}
// --- Handles ---
private q(selector: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(selector);
}
get toolbar() {
const exportButton =
this.q('button[aria-label="Export graph"]') ??
this.q('button[aria-label="Export available soon"]');
return {
zoomInButton: this.q('button[aria-label="Zoom in"]'),
zoomOutButton: this.q('button[aria-label="Zoom out"]'),
fitButton: this.q('button[aria-label="Fit graph to view"]'),
exportButton,
isExportButtonEnabled:
!!exportButton && !(exportButton as HTMLButtonElement).disabled,
backToFullViewButton: this.q(
'button[aria-label="Return to full graph view"]',
),
fullscreenButton: this.q('button[aria-label="Fullscreen"]'),
};
}
// --- Page-level surface (alerts, raw text) ---
/** Wait for a `[role="alert"]` to appear and return its text. */
async emptyStateMessage(timeoutMs = 2000): Promise<string> {
const alert = await this.waitFor(
() => this.container.querySelector<HTMLElement>('[role="alert"]'),
timeoutMs,
);
return alert.textContent ?? "";
}
/** True when the rendered page contains text matching `pattern`. */
containsText(pattern: RegExp): boolean {
return pattern.test(this.container.textContent ?? "");
}
get minimap(): HTMLElement | null {
return this.q(AttackPathPageHarness.MINIMAP_SEL);
}
get background(): HTMLElement | null {
return this.q(AttackPathPageHarness.BACKGROUND_SEL);
}
get viewport(): HTMLElement | null {
return this.q(AttackPathPageHarness.VIEWPORT_SEL);
}
/**
* Inline `transform` of the React Flow viewport element. This is the
* pan/zoom matrix React Flow rewrites on every fit/zoom/pan, so comparing
* it before vs. after a user action is enough to assert that the viewport
* actually moved (or stayed put).
*/
get viewportTransform(): string {
return this.viewport?.style.transform ?? "";
}
/**
* `stroke-width` of the minimap mask SVG. The mask cuts out the area
* currently in view; the cut-out's border is what indicates the viewport
* inside the minimap. A non-zero stroke-width means the indicator has a
* visible border (rather than blending into the dark theme background).
*/
get minimapMaskStrokeWidth(): number {
const mask = this.minimap?.querySelector<SVGPathElement>(
".react-flow__minimap-mask",
);
if (!mask) return 0;
const inline = mask.getAttribute("stroke-width");
if (inline) return Number.parseFloat(inline);
const computed = getComputedStyle(mask).strokeWidth;
return Number.parseFloat(computed);
}
get fullscreenDialog(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="dialog"]');
}
get nodeDetailsHeading(): HTMLElement | null {
return (
Array.from(
document.querySelectorAll<HTMLElement>("[role='dialog'] h2"),
).find((heading) => /^Node Details$/i.test(heading.textContent ?? "")) ??
null
);
}
get hasNodeDetailsModal(): boolean {
return !!this.nodeDetailsHeading;
}
get nodeActionHeading(): HTMLElement | null {
return (
Array.from(
document.querySelectorAll<HTMLElement>("[role='dialog'] h2"),
).find((heading) =>
/^Choose node action$/i.test(heading.textContent ?? ""),
) ?? null
);
}
get hasNodeActionDialog(): boolean {
return !!this.nodeActionHeading;
}
// --- Sync helpers ---
/** Wait until React Flow has rendered at least `expected` node elements. */
async waitForLayoutStable(expected = 1, timeoutMs = 3000): Promise<void> {
await vi.waitFor(
() => {
if (this.nodes.length < expected) {
throw new Error(
`expected ${expected} nodes, got ${this.nodes.length}`,
);
}
},
{ timeout: timeoutMs, interval: 16 },
);
}
/** Wait until the predicate returns truthy and return that value. */
async waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs = 3000,
): Promise<T> {
return vi.waitFor(
() => {
const v = fn();
if (!v) throw new Error("waitFor predicate not yet truthy");
return v;
},
{ timeout: timeoutMs, interval: 16 },
) as Promise<T>;
}
async waitForTransition(ms = 350): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
// --- Action methods ---
async selectQuery(queryId?: string): Promise<void> {
const trigger = await this.waitFor<HTMLButtonElement>(
() =>
this.container.querySelector<HTMLButtonElement>(
'button[role="combobox"]',
),
10000,
);
await this.user.click(trigger);
const targetId = queryId ?? this.fixture.queryId;
const targetName = this.fixture.queries.find((q) => q.id === targetId)
?.attributes.name;
const option = await this.waitFor<HTMLElement>(
() =>
document.querySelector<HTMLElement>(
`[role="option"][data-value="${targetId}"]`,
) ??
Array.from(
document.querySelectorAll<HTMLElement>('[role="option"]'),
).find((el) => targetName && el.textContent?.includes(targetName)),
10000,
);
await this.user.click(option);
await this.waitForTransition();
}
async executeQuery(options: { selectFirst?: boolean } = {}): Promise<void> {
if (options.selectFirst !== false) {
await this.selectQuery();
}
const button = await this.waitFor<HTMLButtonElement>(
() =>
Array.from(
this.container.querySelectorAll<HTMLButtonElement>("button"),
).find(
(b) =>
!b.disabled &&
/execute query/i.test(b.textContent ?? "") &&
!/executing/i.test(b.textContent ?? ""),
),
10000,
);
await this.user.click(button);
await this.waitForLayoutStable(1, 10000);
}
async clickNode(nodeId: string): Promise<void> {
const el = this.getNodeById(nodeId);
if (!el) throw new Error(`clickNode: node "${nodeId}" not found`);
await this.user.click(el);
await this.waitForTransition();
}
async clickFirstFindingNode(): Promise<HTMLElement> {
const [finding] = this.findingNodes;
if (!finding) throw new Error("clickFirstFindingNode: no finding rendered");
await this.user.click(finding);
await this.waitForTransition();
return finding;
}
async clickFirstResourceNode(): Promise<HTMLElement> {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("clickFirstResourceNode: no resource rendered");
await this.user.click(resource);
await this.waitForTransition();
return resource;
}
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
for (const rel of this.fixture.queryResult?.relationships ?? []) {
if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target);
if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source);
}
const resource = this.resourceNodes.find((node) => {
const id = node.getAttribute("data-id");
return id && !resourceWithFindingIds.has(id);
});
if (!resource) {
throw new Error(
"clickFirstResourceNodeWithoutFindings: no resource without findings rendered",
);
}
await this.user.click(resource);
await this.waitForTransition();
return resource;
}
/**
* Click the first finding `times` times back-to-back, with no transition
* waits between clicks. Used for rapid-click race tests.
*/
async rapidlyClickFirstFindingNode(times = 2): Promise<HTMLElement> {
const [finding] = this.findingNodes;
if (!finding)
throw new Error("rapidlyClickFirstFindingNode: no finding rendered");
for (let i = 0; i < times; i++) {
await this.user.click(finding);
}
await this.waitForTransition();
return finding;
}
async dblClickFirstResourceNode(): Promise<HTMLElement> {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("dblClickFirstResourceNode: no resource rendered");
await this.user.dblClick(resource);
await this.waitForTransition();
return resource;
}
/**
* Click the React Flow background pane (anywhere not on a node/edge), used
* to verify that empty-canvas clicks don't open the filtered view.
*/
async clickEmptyCanvas(): Promise<void> {
const pane = this.q(".react-flow__pane") ?? this.q(".react-flow__renderer");
if (!pane) throw new Error("clickEmptyCanvas: pane not rendered");
await this.user.click(pane);
await this.waitForTransition();
}
/**
* Click every resource that the fixture's relationships connect to a finding.
* Findings are hidden by default in the full graph view (Tier 1) — clicking
* their adjacent resources reveals them.
*/
async expandAllFindings(): Promise<void> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
for (const rel of this.fixture.queryResult?.relationships ?? []) {
if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target);
if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source);
}
for (const id of Array.from(resourceWithFindingIds)) {
const el = this.getNodeById(id);
if (el) {
await this.user.click(el);
await this.waitForTransition(50);
if (this.hasNodeActionDialog) {
await this.chooseShowFindingsAction();
}
}
}
}
async hoverNode(nodeId: string): Promise<void> {
const el = this.getNodeById(nodeId);
if (!el) throw new Error(`hoverNode: node "${nodeId}" not found`);
await this.user.hover(el);
await this.waitForTransition(80);
}
async hoverFirstResourceNode(): Promise<HTMLElement> {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("hoverFirstResourceNode: no resource rendered");
await this.user.hover(resource);
await this.waitForTransition(80);
return resource;
}
async unhoverNodes(): Promise<void> {
const canvas =
this.q(".react-flow__pane") ?? this.q(".react-flow__renderer");
if (canvas) await this.user.hover(canvas);
await this.waitForTransition(80);
}
async zoomIn(): Promise<void> {
const btn = this.toolbar.zoomInButton;
if (!btn) throw new Error("zoomIn: toolbar not rendered");
await this.user.click(btn);
}
async zoomOut(): Promise<void> {
const btn = this.toolbar.zoomOutButton;
if (!btn) throw new Error("zoomOut: toolbar not rendered");
await this.user.click(btn);
}
async fit(): Promise<void> {
const btn = this.toolbar.fitButton;
if (!btn) throw new Error("fit: toolbar not rendered");
await this.user.click(btn);
}
async closeNodeDetailsModal(): Promise<void> {
const btn = this.q('button[aria-label="Close node details"]');
if (!btn) throw new Error("closeNodeDetailsModal: modal not rendered");
await this.user.click(btn);
await this.waitForTransition();
}
async chooseShowFindingsAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /show findings/i.test(btn.textContent ?? ""));
if (!button) throw new Error("chooseShowFindingsAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async chooseHideFindingsAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /hide findings/i.test(btn.textContent ?? ""));
if (!button) throw new Error("chooseHideFindingsAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async chooseViewNodeDetailsAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /view node details/i.test(btn.textContent ?? ""));
if (!button)
throw new Error("chooseViewNodeDetailsAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async chooseBackToFullGraphAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /back to full graph/i.test(btn.textContent ?? ""));
if (!button)
throw new Error("chooseBackToFullGraphAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async exitFilteredView(): Promise<void> {
const btn = this.toolbar.backToFullViewButton;
if (!btn) throw new Error("exitFilteredView: not in filtered view");
await this.user.click(btn);
await this.waitForTransition();
}
async openFullscreen(): Promise<void> {
const btn = this.toolbar.fullscreenButton;
if (!btn) throw new Error("openFullscreen: button not found");
await this.user.click(btn);
await this.waitFor(() => this.fullscreenDialog, 3000);
await this.waitForTransition();
}
async closeFullscreen(): Promise<void> {
const dialog = this.fullscreenDialog;
if (!dialog) return;
const close = dialog.querySelector<HTMLButtonElement>(
'button[aria-label="Close"]',
);
if (close) await this.user.click(close);
else await this.user.keyboard("{Escape}");
await this.waitForTransition();
}
async exportAsPNG(target: "main" | "fullscreen" = "main"): Promise<void> {
const scope =
target === "fullscreen" ? this.fullscreenDialog : this.container;
if (!scope) throw new Error("exportAsPNG: target scope missing");
const btn = scope.querySelector<HTMLButtonElement>(
'button[aria-label="Export graph"]',
);
if (!btn) throw new Error("exportAsPNG: export button disabled or missing");
await this.user.click(btn);
await this.waitForTransition(300);
}
/**
* Trigger an export and capture the resulting download as a structured
* record. Intercepts `HTMLAnchorElement.prototype.click` so the test
* environment doesn't actually navigate, and parses width/height from the
* PNG IHDR chunk so callers can assert on canvas size.
*/
async captureExportPNG(
target: "main" | "fullscreen" = "main",
timeoutMs = 10000,
): Promise<{
filename: string;
href: string;
mimeType: string;
width: number;
height: number;
}> {
const downloads: Array<{ href: string; download: string }> = [];
const originalClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () {
if (this.download) {
downloads.push({ href: this.href, download: this.download });
return;
}
originalClick.call(this);
};
try {
await this.exportAsPNG(target);
await this.waitFor(() => downloads.length > 0, timeoutMs);
} finally {
HTMLAnchorElement.prototype.click = originalClick;
}
const [download] = downloads;
const [meta, base64 = ""] = download.href.split(",");
const mimeType = /^data:([^;]+)/.exec(meta ?? "")?.[1] ?? "";
// PNG IHDR chunk: bytes 16-19 = width (uint32 BE), 20-23 = height.
const bytes = atob(base64);
const u32BE = (offset: number) =>
((bytes.charCodeAt(offset) << 24) |
(bytes.charCodeAt(offset + 1) << 16) |
(bytes.charCodeAt(offset + 2) << 8) |
bytes.charCodeAt(offset + 3)) >>>
0;
return {
filename: download.download,
href: download.href,
mimeType,
width: u32BE(16),
height: u32BE(20),
};
}
}
@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -22,17 +22,16 @@ import {
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
} from "@/components/shadcn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/shadcn/dialog";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { Modal } from "@/components/shadcn/modal/modal";
import { useToast } from "@/components/ui";
import type {
AttackPathQuery,
@@ -48,17 +47,40 @@ import {
GraphControls,
GraphLegend,
GraphLoading,
NodeDetailContent,
NodeDetailPanel as NodeDetailDrawer,
QueryDescription,
QueryExecutionError,
QueryParametersForm,
QuerySelector,
ScanListTable,
} from "./_components";
import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph";
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsSVG, formatNodeLabel } from "./_lib";
import { exportGraphAsPNG } from "./_lib";
const NODE_ACTION_CONTEXT = {
RESOURCE_FINDINGS: "resource-findings",
FILTERED_PARENT: "filtered-parent",
} as const;
type NodeActionContext =
(typeof NODE_ACTION_CONTEXT)[keyof typeof NODE_ACTION_CONTEXT];
interface NodeActionBase {
node: GraphNode;
context: NodeActionContext;
}
interface ResourceFindingsNodeAction extends NodeActionBase {
context: typeof NODE_ACTION_CONTEXT.RESOURCE_FINDINGS;
}
interface FilteredParentNodeAction extends NodeActionBase {
context: typeof NODE_ACTION_CONTEXT.FILTERED_PARENT;
}
type NodeActionState = ResourceFindingsNodeAction | FilteredParentNodeAction;
/**
* Attack Paths
@@ -76,10 +98,10 @@ export default function AttackPathsPage() {
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const graphRef = useRef<AttackPathGraphRef>(null);
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
const [nodeAction, setNodeAction] = useState<NodeActionState | null>(null);
const graphRef = useRef<GraphHandle>(null);
const fullscreenGraphRef = useRef<GraphHandle>(null);
const hasResetRef = useRef(false);
const nodeDetailsRef = useRef<HTMLDivElement>(null);
const graphContainerRef = useRef<HTMLDivElement>(null);
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
@@ -95,6 +117,11 @@ export default function AttackPathsPage() {
}
}, [graphState]);
// Reset graph state when scan changes
useEffect(() => {
graphState.resetGraph();
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
// Load available scans on mount
useEffect(() => {
const loadScans = async () => {
@@ -189,10 +216,6 @@ export default function AttackPathsPage() {
loadQueries();
}, [scanId, toast]);
const handleQueryChange = (queryId: string) => {
queryBuilder.handleQueryChange(queryId);
};
const showErrorToast = (title: string, description: string) => {
toast({
title,
@@ -289,22 +312,44 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
// Enter filtered view showing only paths containing this node
graphState.enterFilteredView(node.id);
// For findings, also scroll to the details section
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
if (isFinding) {
setTimeout(() => {
nodeDetailsRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, 100);
// Findings skip the intermediate node-details modal. The finding drawer
// is the useful destination, so open it directly from the graph click.
graphState.enterFilteredView(node.id);
// enterFilteredView stores the filtered node as selected so the graph can
// highlight it. Clear the selection right after for findings so the node
// details modal does not open before the finding drawer.
graphState.selectNode(null);
handleViewFinding(String(node.properties?.id || node.id));
return;
}
if (graphState.isFilteredView) {
setNodeAction({ node, context: NODE_ACTION_CONTEXT.FILTERED_PARENT });
return;
}
const sourceData = graphState.fullData || graphState.data;
const hasFindings = sourceData?.edges?.some((edge) => {
if (edge.source !== node.id && edge.target !== node.id) return false;
const otherId = edge.source === node.id ? edge.target : edge.source;
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
return otherNode?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
});
if (hasFindings) {
setNodeAction({ node, context: NODE_ACTION_CONTEXT.RESOURCE_FINDINGS });
return;
}
finding.resetFindingDetails();
graphState.selectNode(node.id);
};
const handleBackToFullView = () => {
@@ -315,33 +360,53 @@ export default function AttackPathsPage() {
graphState.selectNode(null);
};
const getFindingId = (node: GraphNode | null) =>
node ? String(node.properties?.id || node.id) : "";
const handleShowNodeFindings = () => {
if (!nodeAction) return;
graphState.toggleExpandedResource(nodeAction.node.id);
setNodeAction(null);
};
const handleOpenNodeDetails = () => {
if (!nodeAction) return;
finding.resetFindingDetails();
graphState.selectNode(nodeAction.node.id);
setNodeAction(null);
};
const handleReturnToFullGraph = () => {
graphState.exitFilteredView();
graphState.selectNode(null);
setNodeAction(null);
};
const handleViewFinding = (findingId: string) => {
if (!findingId) return;
void finding.navigateToFinding(findingId);
};
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
const actionNodeFindingsExpanded = nodeAction
? graphState.expandedResources.has(nodeAction.node.id)
: false;
const handleGraphExport = async (target: "main" | "fullscreen") => {
const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef;
const handle = ref.current;
if (!handle) return;
try {
if (svgElement) {
exportGraphAsSVG(svgElement, "attack-path-graph.svg");
toast({
title: "Success",
description: "Graph exported as SVG",
variant: "default",
});
} else {
throw new Error("Could not find graph element");
}
} catch (error) {
await exportGraphAsPNG(
handle.getContainerElement(),
handle.getNodesBounds(),
);
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Failed to export graph",
variant: "destructive",
title: "Success",
description: "Graph exported",
variant: "default",
});
} catch (error) {
const description =
error instanceof Error ? error.message : "Failed to export graph";
showErrorToast("Export failed", description);
}
};
@@ -423,7 +488,7 @@ export default function AttackPathsPage() {
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={handleQueryChange}
onQueryChange={queryBuilder.handleQueryChange}
/>
{queryBuilder.selectedQueryData && (
@@ -524,11 +589,7 @@ export default function AttackPathsPage() {
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitToScreen={() => graphRef.current?.resetZoom()}
onExport={() =>
handleGraphExport(
graphRef.current?.getSVGElement() || null,
)
}
onExport={() => handleGraphExport("main")}
/>
{/* Fullscreen button */}
@@ -550,6 +611,10 @@ export default function AttackPathsPage() {
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 rounded-none border-0 p-0 sm:max-w-full">
<DialogHeader className="sr-only">
<DialogTitle>Fullscreen graph view</DialogTitle>
<DialogDescription>
Explore the attack path graph at full size. Use
the toolbar to zoom, fit, or export the graph.
</DialogDescription>
</DialogHeader>
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
<GraphControls
@@ -562,15 +627,10 @@ export default function AttackPathsPage() {
onFitToScreen={() =>
fullscreenGraphRef.current?.resetZoom()
}
onExport={() =>
handleGraphExport(
fullscreenGraphRef.current?.getSVGElement() ||
null,
)
}
onExport={() => handleGraphExport("fullscreen")}
/>
</div>
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6 lg:flex-row">
<div className="flex flex-1 items-center justify-center">
<AttackPathGraph
ref={fullscreenGraphRef}
@@ -578,64 +638,11 @@ export default function AttackPathsPage() {
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
expandedResources={
graphState.expandedResources
}
/>
</div>
{/* Node Detail Panel - Side by side */}
{graphState.selectedNode && (
<section aria-labelledby="node-details-heading">
<Card className="w-96 overflow-y-auto">
<CardContent className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3
id="node-details-heading"
className="text-sm font-semibold"
>
Node Details
</h3>
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
<p className="text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label
.toLowerCase()
.includes("finding"),
)
? graphState.selectedNode?.properties
?.check_title ||
graphState.selectedNode?.properties
?.id ||
"Unknown Finding"
: graphState.selectedNode?.properties
?.name ||
graphState.selectedNode?.properties
?.id ||
"Unknown Resource"}
</p>
<div className="flex flex-col gap-4">
<div>
<h4 className="mb-2 text-xs font-semibold">
Type
</h4>
<p className="text-text-neutral-secondary text-xs">
{graphState.selectedNode?.labels
.map(formatNodeLabel)
.join(", ")}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
)}
</div>
</DialogContent>
</Dialog>
@@ -654,11 +661,12 @@ export default function AttackPathsPage() {
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
expandedResources={graphState.expandedResources}
/>
</div>
{/* Legend below */}
<div className="hidden justify-center lg:flex">
<div className="flex justify-center overflow-x-auto">
<GraphLegend data={graphState.data} />
</div>
</>
@@ -666,68 +674,48 @@ export default function AttackPathsPage() {
</div>
)}
{/* Node Detail Panel - Below Graph */}
{graphState.selectedNode && graphState.data && (
<div
ref={nodeDetailsRef}
className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary mt-1 text-sm">
{String(
graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
)
? graphState.selectedNode.properties?.check_title ||
graphState.selectedNode.properties?.id ||
"Unknown Finding"
: graphState.selectedNode.properties?.name ||
graphState.selectedNode.properties?.id ||
"Unknown Resource",
)}
</p>
</div>
<div className="flex items-center gap-2">
{graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
) && (
<Button
variant="default"
size="sm"
onClick={() =>
handleViewFinding(getFindingId(graphState.selectedNode))
}
disabled={finding.findingDetailLoading}
aria-label={`View finding ${getFindingId(graphState.selectedNode)}`}
>
{finding.findingDetailLoading ? (
<Spinner className="size-4" />
) : (
"View Finding"
)}
</Button>
)}
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
</div>
{/* Node Detail Drawer */}
{graphState.data && (
<NodeDetailDrawer
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onClose={handleCloseDetails}
onViewFinding={handleViewFinding}
viewFindingLoading={finding.findingDetailLoading}
/>
)}
<NodeDetailContent
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onViewFinding={handleViewFinding}
viewFindingLoading={finding.findingDetailLoading}
/>
</div>
{nodeAction && (
<Modal
open={!!nodeAction}
onOpenChange={(open) => {
if (!open) setNodeAction(null);
}}
size="md"
title="Choose node action"
description={
nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT
? "You're viewing a filtered path. Choose whether to return to the full graph or inspect this node."
: "This node has related findings. Choose whether to reveal them in the graph or inspect the node metadata."
}
>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleOpenNodeDetails}>
View node details
</Button>
{nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (
<Button onClick={handleReturnToFullGraph}>
Back to full graph
</Button>
) : (
<Button onClick={handleShowNodeFindings}>
{actionNodeFindingsExpanded
? "Hide findings"
: "Show findings"}
</Button>
)}
</div>
</Modal>
)}
{finding.findingDetails && (
+1 -1
View File
@@ -15,7 +15,7 @@ export function Navbar({ title, icon }: NavbarProps) {
title={title}
icon={icon}
feedsSlot={
<Suspense fallback={<FeedsLoadingFallback />}>
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
<FeedsServer limit={15} />
</Suspense>
}
+62 -22
View File
@@ -55,6 +55,14 @@
"strategy": "installed",
"generatedAt": "2026-03-26T08:39:34.728Z"
},
{
"section": "dependencies",
"name": "@dagrejs/dagre",
"from": "3.0.0",
"to": "3.0.0",
"strategy": "installed",
"generatedAt": "2026-04-16T12:15:51.357Z"
},
{
"section": "dependencies",
"name": "@extractus/feed-extractor",
@@ -133,7 +141,7 @@
"from": "16.1.6",
"to": "16.2.3",
"strategy": "installed",
"generatedAt": "2026-04-17T09:52:03.464Z"
"generatedAt": "2026-04-22T13:43:48.734Z"
},
{
"section": "dependencies",
@@ -367,14 +375,6 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "@types/dagre",
"from": "0.7.53",
"to": "0.7.53",
"strategy": "installed",
"generatedAt": "2025-11-27T11:47:22.908Z"
},
{
"section": "dependencies",
"name": "@types/js-yaml",
@@ -391,6 +391,14 @@
"strategy": "installed",
"generatedAt": "2026-03-26T08:39:34.728Z"
},
{
"section": "dependencies",
"name": "@xyflow/react",
"from": "12.10.2",
"to": "12.10.2",
"strategy": "installed",
"generatedAt": "2026-04-16T12:15:51.357Z"
},
{
"section": "dependencies",
"name": "ai",
@@ -447,14 +455,6 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "dagre",
"from": "0.8.5",
"to": "0.8.5",
"strategy": "installed",
"generatedAt": "2025-11-27T11:47:22.908Z"
},
{
"section": "dependencies",
"name": "date-fns",
@@ -535,6 +535,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "modern-screenshot",
"from": "4.7.0",
"to": "4.7.0",
"strategy": "installed",
"generatedAt": "2026-05-05T07:21:33.657Z"
},
{
"section": "dependencies",
"name": "nanoid",
@@ -549,7 +557,7 @@
"from": "16.1.6",
"to": "16.2.3",
"strategy": "installed",
"generatedAt": "2026-04-17T09:52:03.464Z"
"generatedAt": "2026-04-22T13:43:48.734Z"
},
{
"section": "dependencies",
@@ -581,7 +589,7 @@
"from": "19.2.4",
"to": "19.2.5",
"strategy": "installed",
"generatedAt": "2026-04-17T09:52:03.464Z"
"generatedAt": "2026-04-22T13:43:48.734Z"
},
{
"section": "dependencies",
@@ -597,7 +605,7 @@
"from": "19.2.4",
"to": "19.2.5",
"strategy": "installed",
"generatedAt": "2026-04-17T09:52:03.464Z"
"generatedAt": "2026-04-22T13:43:48.734Z"
},
{
"section": "dependencies",
@@ -765,7 +773,7 @@
"from": "16.1.6",
"to": "16.2.3",
"strategy": "installed",
"generatedAt": "2026-04-17T09:52:03.464Z"
"generatedAt": "2026-04-22T13:43:48.734Z"
},
{
"section": "devDependencies",
@@ -887,6 +895,22 @@
"strategy": "installed",
"generatedAt": "2026-01-29T16:42:27.795Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser",
"from": "4.0.18",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-04-30T13:13:39.682Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser-playwright",
"from": "4.0.18",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-04-30T13:13:39.682Z"
},
{
"section": "devDependencies",
"name": "@vitest/coverage-v8",
@@ -933,7 +957,7 @@
"from": "16.1.6",
"to": "16.2.3",
"strategy": "installed",
"generatedAt": "2026-04-17T09:52:03.464Z"
"generatedAt": "2026-04-22T13:43:48.734Z"
},
{
"section": "devDependencies",
@@ -1039,6 +1063,14 @@
"strategy": "installed",
"generatedAt": "2026-04-10T11:55:26.693Z"
},
{
"section": "devDependencies",
"name": "msw",
"from": "2.13.4",
"to": "2.13.4",
"strategy": "installed",
"generatedAt": "2026-04-30T13:13:39.682Z"
},
{
"section": "devDependencies",
"name": "postcss",
@@ -1102,5 +1134,13 @@
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-01-29T16:42:27.795Z"
},
{
"section": "devDependencies",
"name": "vitest-browser-react",
"from": "2.0.4",
"to": "2.0.4",
"strategy": "installed",
"generatedAt": "2026-04-30T13:23:20.132Z"
}
]
+19 -6
View File
@@ -16,8 +16,11 @@
"lint:knip:fix": "knip --fix --max-issues 504",
"format:check": "./node_modules/.bin/prettier --check ./app",
"format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app",
"test": "vitest",
"test:run": "vitest run",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --project unit",
"test:browser": "vitest run --project browser",
"test:browser:watch": "vitest --project browser",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans",
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --ui",
@@ -35,6 +38,7 @@
"@codemirror/language": "6.12.2",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.40.0",
"@dagrejs/dagre": "3.0.0",
"@extractus/feed-extractor": "7.1.7",
"@heroui/react": "2.8.4",
"@hookform/resolvers": "5.2.2",
@@ -74,9 +78,9 @@
"@tailwindcss/postcss": "4.1.18",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@types/dagre": "0.7.53",
"@types/js-yaml": "4.0.9",
"@uiw/react-codemirror": "4.25.8",
"@xyflow/react": "12.10.2",
"ai": "5.0.109",
"alert": "6.0.2",
"class-variance-authority": "0.7.1",
@@ -84,7 +88,6 @@
"cmdk": "1.1.1",
"codemirror": "6.0.2",
"d3": "7.9.0",
"dagre": "0.8.5",
"date-fns": "4.1.0",
"framer-motion": "11.18.2",
"import-in-the-middle": "2.0.0",
@@ -95,6 +98,7 @@
"langchain": "1.2.10",
"lucide-react": "0.543.0",
"marked": "15.0.12",
"modern-screenshot": "4.7.0",
"nanoid": "5.1.6",
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
@@ -141,6 +145,8 @@
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"@vitest/browser": "4.0.18",
"@vitest/browser-playwright": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"autoprefixer": "10.4.19",
"babel-plugin-react-compiler": "1.0.0",
@@ -160,6 +166,7 @@
"globals": "17.0.0",
"jsdom": "27.4.0",
"knip": "6.3.1",
"msw": "2.13.4",
"postcss": "8.4.38",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.14",
@@ -167,7 +174,8 @@
"tailwind-variants": "0.1.20",
"tailwindcss": "4.1.18",
"typescript": "5.5.4",
"vitest": "4.0.18"
"vitest": "4.0.18",
"vitest-browser-react": "2.0.4"
},
"pnpm": {
"overrides": {
@@ -196,5 +204,10 @@
}
},
"version": "0.0.1",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"msw": {
"workerDirectory": [
"public"
]
}
}
+253 -131
View File
@@ -53,6 +53,9 @@ importers:
'@codemirror/view':
specifier: 6.40.0
version: 6.40.0
'@dagrejs/dagre':
specifier: 3.0.0
version: 3.0.0
'@extractus/feed-extractor':
specifier: 7.1.7
version: 7.1.7
@@ -170,15 +173,15 @@ importers:
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@types/dagre':
specifier: 0.7.53
version: 0.7.53
'@types/js-yaml':
specifier: 4.0.9
version: 4.0.9
'@uiw/react-codemirror':
specifier: 4.25.8
version: 4.25.8(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@xyflow/react':
specifier: 12.10.2
version: 12.10.2(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
ai:
specifier: 5.0.109
version: 5.0.109(zod@4.1.11)
@@ -200,9 +203,6 @@ importers:
d3:
specifier: 7.9.0
version: 7.9.0
dagre:
specifier: 0.8.5
version: 0.8.5
date-fns:
specifier: 4.1.0
version: 4.1.0
@@ -233,6 +233,9 @@ importers:
marked:
specifier: 15.0.12
version: 15.0.12
modern-screenshot:
specifier: 4.7.0
version: 4.7.0
nanoid:
specifier: 5.1.6
version: 5.1.6
@@ -366,9 +369,15 @@ importers:
'@vitejs/plugin-react':
specifier: 5.1.2
version: 5.1.2(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/browser':
specifier: 4.0.18
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/browser-playwright':
specifier: 4.0.18
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
autoprefixer:
specifier: 10.4.19
version: 10.4.19(postcss@8.4.38)
@@ -423,6 +432,9 @@ importers:
knip:
specifier: 6.3.1
version: 6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
msw:
specifier: 2.13.4
version: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
postcss:
specifier: 8.4.38
version: 8.4.38
@@ -446,7 +458,10 @@ importers:
version: 5.5.4
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
vitest-browser-react:
specifier: 2.0.4
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.0.18)
packages:
@@ -993,6 +1008,12 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dagrejs/dagre@3.0.0':
resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==}
'@dagrejs/graphlib@4.0.1':
resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
@@ -2113,35 +2134,35 @@ packages:
cpu: [x64]
os: [win32]
'@inquirer/ansi@1.0.2':
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
engines: {node: '>=18'}
'@inquirer/ansi@2.0.5':
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/confirm@5.1.21':
resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==}
engines: {node: '>=18'}
'@inquirer/confirm@6.0.12':
resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@10.3.2':
resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
engines: {node: '>=18'}
'@inquirer/core@11.1.9':
resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@1.0.15':
resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
engines: {node: '>=18'}
'@inquirer/figures@2.0.5':
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/type@3.0.10':
resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
engines: {node: '>=18'}
'@inquirer/type@4.0.5':
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
@@ -2373,6 +2394,9 @@ packages:
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@open-draft/deferred-promise@3.0.0':
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
'@open-draft/logger@0.3.0':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
@@ -5180,9 +5204,6 @@ packages:
'@types/d3@7.4.3':
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
'@types/dagre@0.7.53':
resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -5245,6 +5266,9 @@ packages:
'@types/react@19.2.8':
resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
'@types/set-cookie-parser@2.4.10':
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@@ -5569,6 +5593,15 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
'@xyflow/react@12.10.2':
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@xyflow/system@0.0.76':
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -5890,6 +5923,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -6210,9 +6246,6 @@ packages:
dagre-d3-es@7.0.13:
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
dagre@0.8.5:
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -6736,9 +6769,18 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-wrap-ansi@0.2.0:
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
fast-xml-parser@5.3.8:
resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==}
hasBin: true
@@ -6956,11 +6998,8 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphlib@2.1.8:
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
graphql@16.12.0:
resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==}
graphql@16.13.2:
resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
hachure-fill@0.5.2:
@@ -7036,8 +7075,8 @@ packages:
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -7934,6 +7973,9 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
modern-screenshot@4.7.0:
resolution: {integrity: sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==}
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
@@ -7950,8 +7992,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msw@2.12.14:
resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==}
msw@2.13.4:
resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -7964,9 +8006,9 @@ packages:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
@@ -8733,8 +8775,8 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
rettime@0.10.1:
resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
rettime@0.11.8:
resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
@@ -8821,6 +8863,9 @@ packages:
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -9159,6 +9204,10 @@ packages:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.19:
resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==}
@@ -9186,6 +9235,10 @@ packages:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -9233,8 +9286,8 @@ packages:
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
engines: {node: '>=8'}
type-fest@5.4.1:
resolution: {integrity: sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==}
type-fest@5.6.0:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
type-is@2.0.1:
@@ -9479,6 +9532,20 @@ packages:
yaml:
optional: true
vitest-browser-react@2.0.4:
resolution: {integrity: sha512-FQq2z519Bwp/rANaQXU+ox7M4d0q/bTQkF2pgwRAehE+pqJ6myYOLp+P2Dy2kuk+K4IQJHMyijMCSQ1da/xW8w==}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
vitest: ^4.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
vitest@4.0.18:
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -9632,10 +9699,6 @@ packages:
world-atlas@2.0.2:
resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -9698,10 +9761,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
yoctocolors-cjs@2.1.3:
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
engines: {node: '>=18'}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
@@ -9723,6 +9782,21 @@ packages:
zod@4.1.11:
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
@@ -10932,6 +11006,12 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@dagrejs/dagre@3.0.0':
dependencies:
'@dagrejs/graphlib': 4.0.1
'@dagrejs/graphlib@4.0.1': {}
'@date-fns/tz@1.4.1': {}
'@dotenvx/dotenvx@1.51.4':
@@ -12371,31 +12451,30 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@inquirer/ansi@1.0.2': {}
'@inquirer/ansi@2.0.5': {}
'@inquirer/confirm@5.1.21(@types/node@24.10.8)':
'@inquirer/confirm@6.0.12(@types/node@24.10.8)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.10.8)
'@inquirer/type': 3.0.10(@types/node@24.10.8)
'@inquirer/core': 11.1.9(@types/node@24.10.8)
'@inquirer/type': 4.0.5(@types/node@24.10.8)
optionalDependencies:
'@types/node': 24.10.8
'@inquirer/core@10.3.2(@types/node@24.10.8)':
'@inquirer/core@11.1.9(@types/node@24.10.8)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.10.8)
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@24.10.8)
cli-width: 4.1.0
mute-stream: 2.0.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.10.8
'@inquirer/figures@1.0.15': {}
'@inquirer/figures@2.0.5': {}
'@inquirer/type@3.0.10(@types/node@24.10.8)':
'@inquirer/type@4.0.5(@types/node@24.10.8)':
optionalDependencies:
'@types/node': 24.10.8
@@ -12676,6 +12755,8 @@ snapshots:
'@open-draft/deferred-promise@2.2.0': {}
'@open-draft/deferred-promise@3.0.0': {}
'@open-draft/logger@0.3.0':
dependencies:
is-node-process: 1.2.0
@@ -13062,8 +13143,7 @@ snapshots:
dependencies:
playwright: 1.56.1
'@polka/url@1.0.0-next.29':
optional: true
'@polka/url@1.0.0-next.29': {}
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies:
@@ -16144,8 +16224,6 @@ snapshots:
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
'@types/dagre@0.7.53': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
@@ -16214,6 +16292,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/set-cookie-parser@2.4.10':
dependencies:
'@types/node': 24.10.8
'@types/statuses@2.0.6': {}
'@types/tedious@4.0.14':
@@ -16433,39 +16515,37 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
'@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
playwright: 1.56.1
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
tinyrainbow: 3.1.0
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
'@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/utils': 4.0.18
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
tinyrainbow: 3.1.0
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
optional: true
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
@@ -16477,9 +16557,9 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
optionalDependencies:
'@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/expect@4.0.18':
dependencies:
@@ -16488,20 +16568,20 @@ snapshots:
'@vitest/spy': 4.0.18
'@vitest/utils': 4.0.18
chai: 6.2.2
tinyrainbow: 3.0.3
tinyrainbow: 3.1.0
'@vitest/mocker@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
'@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.14(@types/node@24.10.8)(typescript@5.5.4)
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
vite: 7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
'@vitest/pretty-format@4.0.18':
dependencies:
tinyrainbow: 3.0.3
tinyrainbow: 3.1.0
'@vitest/runner@4.0.18':
dependencies:
@@ -16519,7 +16599,7 @@ snapshots:
'@vitest/utils@4.0.18':
dependencies:
'@vitest/pretty-format': 4.0.18
tinyrainbow: 3.0.3
tinyrainbow: 3.1.0
'@webassemblyjs/ast@1.14.1':
dependencies:
@@ -16601,6 +16681,29 @@ snapshots:
'@xtuc/long@4.2.2': {}
'@xyflow/react@12.10.2(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@xyflow/system': 0.0.76
classcat: 5.0.5
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
zustand: 4.5.7(@types/react@19.2.8)(react@19.2.5)
transitivePeerDependencies:
- '@types/react'
- immer
'@xyflow/system@0.0.76':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -16943,6 +17046,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classcat@5.0.5: {}
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -17279,11 +17384,6 @@ snapshots:
d3: 7.9.0
lodash-es: 4.17.23
dagre@0.8.5:
dependencies:
graphlib: 2.1.8
lodash: 4.17.23
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {}
@@ -17969,8 +18069,18 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-string-truncated-width@3.0.3: {}
fast-string-width@3.0.2:
dependencies:
fast-string-truncated-width: 3.0.3
fast-uri@3.1.0: {}
fast-wrap-ansi@0.2.0:
dependencies:
fast-string-width: 3.0.2
fast-xml-parser@5.3.8:
dependencies:
strnum: 2.1.2
@@ -18177,11 +18287,7 @@ snapshots:
graceful-fs@4.2.11: {}
graphlib@2.1.8:
dependencies:
lodash: 4.17.23
graphql@16.12.0: {}
graphql@16.13.2: {}
hachure-fill@0.5.2: {}
@@ -18329,7 +18435,10 @@ snapshots:
property-information: 7.1.0
space-separated-tokens: 2.0.2
headers-polyfill@4.0.3: {}
headers-polyfill@5.0.1:
dependencies:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.0
hermes-estree@0.25.1: {}
@@ -19431,6 +19540,8 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.3
modern-screenshot@4.7.0: {}
module-details-from-path@1.0.4: {}
motion-dom@11.18.1:
@@ -19439,29 +19550,28 @@ snapshots:
motion-utils@11.18.1: {}
mrmime@2.0.1:
optional: true
mrmime@2.0.1: {}
ms@2.1.3: {}
msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4):
msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4):
dependencies:
'@inquirer/confirm': 5.1.21(@types/node@24.10.8)
'@inquirer/confirm': 6.0.12(@types/node@24.10.8)
'@mswjs/interceptors': 0.41.3
'@open-draft/deferred-promise': 2.2.0
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.12.0
headers-polyfill: 4.0.3
graphql: 16.13.2
headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.10.1
rettime: 0.11.8
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.0
type-fest: 5.4.1
tough-cookie: 6.0.1
type-fest: 5.6.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
@@ -19471,7 +19581,7 @@ snapshots:
mustache@4.2.0: {}
mute-stream@2.0.0: {}
mute-stream@3.0.0: {}
nanoid@3.3.11: {}
@@ -19831,7 +19941,6 @@ snapshots:
pixelmatch@7.1.0:
dependencies:
pngjs: 7.0.0
optional: true
pkce-challenge@5.0.1: {}
@@ -19849,8 +19958,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pngjs@7.0.0:
optional: true
pngjs@7.0.0: {}
points-on-curve@0.2.0: {}
@@ -20321,7 +20429,7 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
rettime@0.10.1: {}
rettime@0.11.8: {}
reusify@1.1.0: {}
@@ -20458,6 +20566,8 @@ snapshots:
server-only@0.0.1: {}
set-cookie-parser@3.1.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -20504,7 +20614,7 @@ snapshots:
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
msw: 2.12.14(@types/node@24.10.8)(typescript@5.5.4)
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
@@ -20646,7 +20756,6 @@ snapshots:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
optional: true
sisteransi@1.0.5: {}
@@ -20917,6 +21026,8 @@ snapshots:
tinyrainbow@3.0.3: {}
tinyrainbow@3.1.0: {}
tldts-core@7.0.19: {}
tldts@7.0.19:
@@ -20933,13 +21044,16 @@ snapshots:
dependencies:
commander: 2.20.3
totalist@3.0.1:
optional: true
totalist@3.0.1: {}
tough-cookie@6.0.0:
dependencies:
tldts: 7.0.19
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.19
tr46@0.0.3: {}
tr46@6.0.0:
@@ -20984,7 +21098,7 @@ snapshots:
type-fest@0.7.1: {}
type-fest@5.4.1:
type-fest@5.6.0:
dependencies:
tagged-tag: 1.0.0
@@ -21258,10 +21372,19 @@ snapshots:
terser: 5.46.0
yaml: 2.8.2
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2):
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.0.18):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
@@ -21283,7 +21406,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.10.8
'@vitest/browser-playwright': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
jsdom: 27.4.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- jiti
@@ -21444,12 +21567,6 @@ snapshots:
world-atlas@2.0.2: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -21497,8 +21614,6 @@ snapshots:
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {}
yoctocolors@2.1.2: {}
zod-to-json-schema@3.25.1(zod@3.25.76):
@@ -21517,6 +21632,13 @@ snapshots:
zod@4.1.11: {}
zustand@4.5.7(@types/react@19.2.8)(react@19.2.5):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.8
react: 19.2.5
zustand@5.0.8(@types/react@19.2.8)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
optionalDependencies:
'@types/react': 19.2.8
+2
View File
@@ -26,6 +26,8 @@ onlyBuiltDependencies:
- "@heroui/shared-utils"
# unrs-resolver: Rust module resolver (NAPI-RS). Verifies the correct native binding is available for the platform.
- unrs-resolver
# msw: Copies mockServiceWorker.js into the directories listed in package.json's `msw.workerDirectory` (here: `public/`) so the runtime worker stays in sync with the installed msw version. Pure file copy — no native binary, no network access. Required for vitest browser tests to intercept fetches via the service worker.
- msw
# --- Level 3: Trust Policy + Exotic Subdeps ---
# Fail when a package's trust evidence is downgraded (e.g., new publisher).
+349
View File
@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.13.4'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+2 -39
View File
@@ -192,8 +192,8 @@ export interface GraphNode {
export interface GraphEdge {
id: string;
source: string | object;
target: string | object;
source: string;
target: string;
type: string;
properties?: GraphNodeProperties;
}
@@ -232,40 +232,6 @@ export interface AttackPathQueryError {
status: number;
}
// Finding severity and status constants
export const FINDING_SEVERITIES = {
CRITICAL: "critical",
HIGH: "high",
MEDIUM: "medium",
LOW: "low",
INFO: "info",
} as const;
type FindingSeverity =
(typeof FINDING_SEVERITIES)[keyof typeof FINDING_SEVERITIES];
export const FINDING_STATUSES = {
PASS: "PASS",
FAIL: "FAIL",
MANUAL: "MANUAL",
} as const;
type FindingStatus = (typeof FINDING_STATUSES)[keyof typeof FINDING_STATUSES];
export interface RelatedFinding {
id: string;
title: string;
severity: FindingSeverity;
status: FindingStatus;
}
// Node Detail Types
export interface NodeDetailData extends GraphNode {
relatedFindings?: RelatedFinding[];
incomingEdges?: GraphEdge[];
outgoingEdges?: GraphEdge[];
}
// Wizard State Types
export interface WizardState {
currentStep: 1 | 2;
@@ -280,9 +246,6 @@ export interface GraphState {
selectedNodeId: string | null;
loading: boolean;
error: string | null;
zoomLevel: number;
panX: number;
panY: number;
}
// Provider Integration
+94
View File
@@ -0,0 +1,94 @@
// Global stylesheet (Tailwind + design tokens) is imported by the Next.js
// layouts in the real app. Tests render the page in isolation, bypassing the
// layout, so without this import Tailwind classes resolve to nothing — the
// page collapses to unstyled HTML and stacked elements end up overlapping
// the graph nodes, blocking Playwright clicks. Pull the stylesheet directly
// so the test bundle gets the same CSS the production page receives.
import "@/styles/globals.css";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { worker } from "./__tests__/msw/worker";
// Server Actions ("use server") are bundled by Vite as plain async functions
// — the directive is a Next.js compiler concept, not part of Vite. When the
// page invokes one, it runs in the browser and reaches `auth()` from
// next-auth, which calls `next/headers` (request-scoped AsyncLocalStorage
// only set up by Next's request handler) and throws "headers was called
// outside a request scope". That kills every action before it can hit
// MSW. Stub `auth.config` with a fake session so the action proceeds to
// `fetch()` and MSW takes over.
vi.mock("@/auth.config", () => ({
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
signIn: vi.fn(),
signOut: vi.fn(),
handlers: {},
}));
// Next.js's App Router context (`useRouter`, `useSearchParams`, `usePathname`)
// is not available in vitest browser — there's no Next runtime mounting the
// providers. We back the hooks with the real `window.location` so navigating
// via `history.replaceState` in tests is enough to drive the page.
vi.mock("next/navigation", () => {
const router = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(() => Promise.resolve()),
};
return {
useSearchParams: () => new URLSearchParams(window.location.search),
useRouter: () => router,
usePathname: () => window.location.pathname,
useParams: () => ({}),
redirect: vi.fn(),
notFound: vi.fn(),
};
});
beforeAll(async () => {
await worker.start({
serviceWorker: { url: "/mockServiceWorker.js" },
onUnhandledRequest: "error",
});
});
afterEach(() => {
worker.resetHandlers();
});
afterAll(() => {
worker.stop();
});
// React Flow's pan/drag handlers dispatch pointer events that access
// `event.view.document` on the node. When user-event synthesises these
// events the `view` property can be null, producing harmless
// "Cannot read properties of null (reading 'document')" errors.
// Swallow only that specific unhandled error; everything else propagates.
const isReactFlowNullViewError = (reason: unknown): boolean => {
const message =
reason instanceof Error
? reason.message
: typeof reason === "string"
? reason
: "";
return message.includes(
"Cannot read properties of null (reading 'document')",
);
};
window.addEventListener("error", (event) => {
if (isReactFlowNullViewError(event.error)) {
event.preventDefault();
event.stopImmediatePropagation();
}
});
window.addEventListener("unhandledrejection", (event) => {
if (isReactFlowNullViewError(event.reason)) {
event.preventDefault();
}
});
+173 -32
View File
@@ -1,39 +1,180 @@
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import path from "path";
import type { TestProjectConfiguration } from "vitest/config";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
restoreMocks: true,
mockReset: true,
unstubEnvs: true,
unstubGlobals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["**/*.test.{ts,tsx}"],
exclude: [
"node_modules",
".next",
"tests/**/*", // Playwright E2E tests
],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"node_modules",
".next",
"tests/**/*",
"**/*.test.{ts,tsx}",
"vitest.config.ts",
"vitest.setup.ts",
export default defineConfig(() => {
const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost/api/v1";
return {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
test: {
globals: true,
restoreMocks: true,
mockReset: true,
unstubEnvs: true,
unstubGlobals: true,
coverage: {
provider: "v8" as const,
reporter: ["text", "json", "html"],
exclude: [
"node_modules",
".next",
"tests/**/*",
"**/*.test.{ts,tsx}",
"**/*.browser.test.{ts,tsx}",
"vitest.config.ts",
"vitest.setup.ts",
"vitest.browser.setup.ts",
"__tests__/**/*",
],
},
projects: [
{
extends: true,
test: {
name: "unit",
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
include: ["**/*.test.{ts,tsx}"],
exclude: [
"node_modules",
".next",
"tests/**/*",
"**/*.browser.test.{ts,tsx}",
],
},
},
{
extends: true,
test: {
name: "browser",
setupFiles: ["./vitest.browser.setup.ts"],
include: ["**/*.browser.test.{ts,tsx}"],
exclude: ["node_modules", ".next", "tests/**/*"],
browser: {
enabled: true,
// Vitest's browser default viewport is 414×896 (phone-sized),
// which collapses the responsive layout: the legend stacks
// vertically and ends up overlapping the graph, so Playwright
// can't click nodes. Use a standard desktop viewport.
viewport: { width: 1280, height: 800 },
provider: playwright(),
headless: true,
instances: [{ browser: "chromium" }],
},
},
},
] as TestProjectConfiguration[],
},
define: {
"process.env.NEXT_PUBLIC_API_BASE_URL": JSON.stringify(apiBaseUrl),
// `next/dist/server/web/spec-extension/user-agent.js` references
// `__dirname` directly and is pulled in transitively via `next-auth`.
// Vite serves it to the browser where that global doesn't exist, so we
// replace it at bundle time. `optimizeDeps` alone doesn't help —
// pre-bundling doesn't patch the identifier.
__dirname: JSON.stringify("/"),
__filename: JSON.stringify("/__browser_test__.js"),
},
optimizeDeps: {
// Pre-bundle every dep that the attack-paths page transitively imports.
// Without this, Vite optimizes them on demand at the first request and
// reloads the page, killing the test run. Keep this list aligned with
// imports through the page's render tree.
include: [
// Test stack
"vitest-browser-react",
"msw/browser",
// Next runtime
"next/navigation",
"next/link",
"next/image",
"next/cache",
"next/server",
"next-auth",
"next-auth/react",
"next-auth/providers/credentials",
"next-themes",
// App component lib
"@heroui/react",
"@heroui/accordion",
"@heroui/breadcrumbs",
"@heroui/card",
"@heroui/chip",
"@heroui/divider",
"@heroui/input",
"@heroui/switch",
"@heroui/theme",
"@heroui/tooltip",
"@heroui/use-clipboard",
"@iconify/react",
// Radix
"@radix-ui/react-alert-dialog",
"@radix-ui/react-avatar",
"@radix-ui/react-checkbox",
"@radix-ui/react-collapsible",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-icons",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-radio-group",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-separator",
"@radix-ui/react-tabs",
"@radix-ui/react-toast",
"@radix-ui/react-tooltip",
"@radix-ui/react-slot",
// Graph
"@xyflow/react",
"@dagrejs/dagre",
// Forms / state
"react-hook-form",
"@hookform/resolvers/zod",
"zod",
"zustand",
"zustand/middleware",
// Styling helpers
"lucide-react",
"clsx",
"tailwind-merge",
"class-variance-authority",
"tailwind-variants",
// App-level deps the page (or its children) pull in
"@tanstack/react-table",
"@react-aria/ssr",
"@react-aria/visually-hidden",
"modern-screenshot",
"framer-motion",
"vaul",
"cmdk",
"react-markdown",
"jwt-decode",
"date-fns",
"js-yaml",
"@codemirror/language",
"@codemirror/state",
"@lezer/highlight",
"@uiw/react-codemirror",
"@sentry/nextjs",
"@extractus/feed-extractor",
],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
};
});