diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 3893ea77..06b8b283 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -33,9 +33,20 @@ jobs: npm install npm run build NEXTAUTH_SECRET=SECRET npm start & npx playwright test --reporter=dot,list + - name: Ensure required directories exist + run: | + mkdir -p playwright-report + mkdir -p playwright-report/artifacts - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} + if: always() with: name: playwright-report path: playwright-report/ retention-days: 30 + - name: Upload failed test screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: failed-test-screenshots + path: playwright-report/artifacts/ + retention-days: 30 diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 83ed01be..9ab6da79 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -6,7 +6,6 @@ import { cn } from "@/lib/utils" import { prepareArg } from "../utils" interface Props extends React.InputHTMLAttributes { - value?: string graph: Graph onValueChange: (node: PathNode) => void handleSubmit?: (node: any) => void @@ -16,7 +15,7 @@ interface Props extends React.InputHTMLAttributes { scrollToBottom?: () => void } -export default function Input({ value, onValueChange, handleSubmit, graph, icon, node, className, parentClassName, scrollToBottom, ...props }: Props) { +export default function Input({ onValueChange, handleSubmit, graph, icon, node, className, parentClassName, scrollToBottom, ...props }: Props) { const [open, setOpen] = useState(false) const [options, setOptions] = useState([]) @@ -38,15 +37,15 @@ export default function Input({ value, onValueChange, handleSubmit, graph, icon, let isLastRequest = true const timeout = setTimeout(async () => { - if (!value || node?.id) { - if (!value) { + if (!node?.name) { + if (!node?.name) { setOptions([]) } setOpen(false) return } - const result = await fetch(`/api/repo/${prepareArg(graph.Id)}/?prefix=${prepareArg(value)}`, { + const result = await fetch(`/api/repo/${prepareArg(graph.Id)}/?prefix=${prepareArg(node?.name)}`, { method: 'POST' }) @@ -66,7 +65,7 @@ export default function Input({ value, onValueChange, handleSubmit, graph, icon, setOptions(completions || []) - if (completions?.length > 0) { + if (completions?.length > 0 && !node?.id) { setOpen(true) } else { setOpen(false) @@ -77,7 +76,7 @@ export default function Input({ value, onValueChange, handleSubmit, graph, icon, clearTimeout(timeout) isLastRequest = false } - }, [value, graph.Id]) + }, [node?.name, graph.Id]) const handleKeyDown = (e: React.KeyboardEvent) => { const container = containerRef.current @@ -87,6 +86,7 @@ export default function Input({ value, onValueChange, handleSubmit, graph, icon, const option = options.find((o, i) => i === selectedOption) if (!option) return if (handleSubmit) { + onValueChange({ name: option.properties.name, id: option.id }) handleSubmit(option) } else { if (!open) return @@ -144,7 +144,7 @@ export default function Input({ value, onValueChange, handleSubmit, graph, icon, }} onKeyDown={handleKeyDown} className={cn("w-full border p-2 rounded-md pointer-events-auto", className)} - value={value || ""} + value={node?.name || ""} onChange={(e) => { const newVal = e.target.value const invalidChars = /[%*()\-\[\]{};:"|~]/; diff --git a/app/components/chat.tsx b/app/components/chat.tsx index f11f2476..66ad6a95 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -4,11 +4,12 @@ import Image from "next/image"; import { AlignLeft, ArrowDown, ArrowRight, ChevronDown, Lightbulb, Undo2 } from "lucide-react"; import { Path } from "../page"; import Input from "./Input"; -import { Graph, GraphData } from "./model"; +import { Graph, GraphData, Node } from "./model"; import { cn } from "@/lib/utils"; import { TypeAnimation } from "react-type-animation"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { prepareArg } from "../utils"; +import { NodeObject } from "react-force-graph-2d"; type PathData = { nodes: any[] @@ -24,50 +25,6 @@ enum MessageTypes { Text, } -const EDGE_STYLE = { - "line-color": "gray", - "target-arrow-color": "gray", - "opacity": 0.5, -} - - -const PATH_EDGE_STYLE = { - width: 0.5, - "line-style": "dashed", - "line-color": "#FF66B3", - "arrow-scale": 0.3, - "target-arrow-color": "#FF66B3", - "opacity": 1 -} - -const SELECTED_PATH_EDGE_STYLE = { - width: 1, - "line-style": "solid", - "line-color": "#FF66B3", - "arrow-scale": 0.6, - "target-arrow-color": "#FF66B3", -}; - -const NODE_STYLE = { - "border-width": 0.5, - "color": "gray", - "border-color": "black", - "background-color": "gray", - "opacity": 0.5 -} - -const PATH_NODE_STYLE = { - "border-width": 0.5, - "border-color": "#FF66B3", - "border-opacity": 1, -} - -const SELECTED_PATH_NODE_STYLE = { - "border-width": 1, - "border-color": "#FF66B3", - "border-opacity": 1, -}; - interface Message { type: MessageTypes; text?: string; @@ -84,6 +41,8 @@ interface Props { isPathResponse: boolean | undefined setIsPathResponse: (isPathResponse: boolean | undefined) => void setData: Dispatch> + setNodesToZoom: Dispatch> + chartRef: any } const SUGGESTIONS = [ @@ -105,7 +64,7 @@ const RemoveLastPath = (messages: Message[]) => { return messages } -export function Chat({ repo, path, setPath, graph, selectedPathId, isPathResponse, setIsPathResponse, setData }: Props) { +export function Chat({ repo, path, setPath, graph, selectedPathId, isPathResponse, setIsPathResponse, setData, setNodesToZoom, chartRef }: Props) { // Holds the messages in the chat const [messages, setMessages] = useState([]); @@ -131,8 +90,7 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons const p = paths.find((path) => [...path.links, ...path.nodes].some((e: any) => e.id === selectedPathId)) if (!p) return - - handelSetSelectedPath(p) + handleSetSelectedPath(p) }, [selectedPathId]) // Scroll to the bottom of the chat on new message @@ -153,7 +111,10 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons setPaths([]) }, [isPathResponse]) - const handelSetSelectedPath = (p: PathData) => { + const handleSetSelectedPath = (p: PathData) => { + const chart = chartRef.current + + if (!chart) return setSelectedPath(prev => { if (prev) { if (isPathResponse && paths.some((path) => [...path.nodes, ...path.links].every((e: any) => [...prev.nodes, ...prev.links].some((e: any) => e.id === e.id)))) { @@ -206,6 +167,7 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons }); } setData({ ...graph.Elements }) + setNodesToZoom([...p.nodes]) } // A function that handles the change event of the url input box @@ -262,6 +224,10 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons } const handleSubmit = async () => { + const chart = chartRef.current + + if (!chart) return + setSelectedPath(undefined) if (!path?.start?.id || !path.end?.id) return @@ -297,6 +263,7 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons setPath(undefined) setIsPathResponse(true) setData({ ...graph.Elements }) + setNodesToZoom([...formattedPaths.flatMap(path => path.nodes)]) } const getTip = (disabled = false) => @@ -373,7 +340,7 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons parentClassName="w-full" graph={graph} onValueChange={({ name, id }) => setPath(prev => ({ start: { name, id }, end: prev?.end }))} - value={path?.start?.name} + value={path?.start?.name || ""} placeholder="Start typing starting point" type="text" icon={} @@ -383,7 +350,7 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons setPath(prev => ({ end: { name, id }, start: prev?.start }))} placeholder="Start typing end point" type="text" @@ -420,12 +387,12 @@ export function Chat({ repo, path, setPath, graph, selectedPathId, isPathRespons } if (selectedPath?.nodes.every(node => p?.nodes.some((n) => n.id === node.id)) && selectedPath.nodes.length === p.nodes.length) return - + if (!isPathResponse) { setIsPathResponse(undefined) - + } - handelSetSelectedPath(p) + handleSetSelectedPath(p) }} >

#{i + 1}

diff --git a/app/components/code-graph.tsx b/app/components/code-graph.tsx index 83d27c88..7c62b37b 100644 --- a/app/components/code-graph.tsx +++ b/app/components/code-graph.tsx @@ -1,9 +1,9 @@ import { Dispatch, RefObject, SetStateAction, useContext, useEffect, useRef, useState } from "react"; -import { GraphData, Node } from "./model"; +import { GraphData, Link, Node } from "./model"; import { GraphContext } from "./provider"; import { Toolbar } from "./toolbar"; import { Labels } from "./labels"; -import { GitFork, Search, X } from "lucide-react"; +import { Download, GitFork, Search, X } from "lucide-react"; import ElementMenu from "./elementMenu"; import Combobox from "./combobox"; import { toast } from '@/components/ui/use-toast'; @@ -20,12 +20,14 @@ const GraphView = dynamic(() => import('./graphView')); interface Props { data: GraphData, setData: Dispatch>, - onFetchGraph: (graphName: string) => void, + onFetchGraph: (graphName: string) => Promise, onFetchNode: (nodeIds: number[]) => Promise, options: string[] setOptions: Dispatch> isShowPath: boolean setPath: Dispatch> + nodesToZoom: Node[] | undefined + setNodesToZoom: Dispatch> chartRef: RefObject selectedValue: string selectedPathId: number | undefined @@ -43,6 +45,8 @@ export function CodeGraph({ setOptions, isShowPath, setPath, + nodesToZoom, + setNodesToZoom, chartRef, selectedValue, setSelectedPathId, @@ -54,7 +58,7 @@ export function CodeGraph({ let graph = useContext(GraphContext) const [url, setURL] = useState(""); - const [selectedObj, setSelectedObj] = useState(); + const [selectedObj, setSelectedObj] = useState(); const [selectedObjects, setSelectedObjects] = useState([]); const [position, setPosition] = useState(); const [graphName, setGraphName] = useState(""); @@ -81,7 +85,7 @@ export function CodeGraph({ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Delete') { if (selectedObj && selectedObjects.length === 0) return - handelRemove([...selectedObjects.map(obj => obj.id), selectedObj?.id].filter(id => id !== undefined)); + handleRemove([...selectedObjects.map(obj => obj.id), selectedObj?.id].filter(id => id !== undefined)); } }; @@ -144,9 +148,10 @@ export function CodeGraph({ } run() + }, [graphName]) - function handleSelectedValue(value: string) { + async function handleSelectedValue(value: string) { setGraphName(value) onFetchGraph(value) } @@ -165,81 +170,96 @@ export function CodeGraph({ } const deleteNeighbors = (nodes: Node[]) => { + if (nodes.length === 0) return; + const expandedNodes: Node[] = [] + graph.Elements = { - nodes: graph.Elements.nodes.map(node => { + nodes: graph.Elements.nodes.filter(node => { + if (!node.collapsed) return true + const isTarget = graph.Elements.links.some(link => link.target.id === node.id && nodes.some(n => n.id === link.source.id)); - if (!isTarget || !node.collapsed) return node + if (!isTarget) return true - if (node.expand) { - node.expand = false - deleteNeighbors([node]) + const deleted = graph.NodesMap.delete(Number(node.id)) + + if (deleted && node.expand) { + expandedNodes.push(node) } - graph.NodesMap.delete(Number(node.id)) - }).filter(node => node !== undefined), + return false + }), links: graph.Elements.links } + deleteNeighbors(expandedNodes) + graph.removeLinks() } const handleExpand = async (nodes: Node[], expand: boolean) => { + const chart = chartRef.current; + if (!chart) return; if (expand) { - const elements = await onFetchNode(nodes.map(n => n.id)) + const elements = await onFetchNode(nodes.map(n => n.id)); if (elements.nodes.length === 0) { toast({ title: `No neighbors found`, description: `No neighbors found`, - }) - return + }); + return; } + + nodes.forEach((node) => { + node.expand = expand; + }); + + setNodesToZoom([...elements.nodes, ...nodes]); + setSelectedObj(undefined); + setData({ ...graph.Elements }); } else { - const deleteNodes = nodes.filter(n => n.expand) + const deleteNodes = nodes.filter(n => n.expand); if (deleteNodes.length > 0) { deleteNeighbors(deleteNodes); } - } - - nodes.forEach((node) => { - node.expand = expand - }) - - setSelectedObj(undefined) - setData({ ...graph.Elements }) - } - const handelSearchSubmit = (node: any) => { - const n = { name: node.properties.name, id: node.id } + nodes.forEach((node) => { + node.expand = expand; + }); - let chartNode = graph.Elements.nodes.find(n => n.id == node.id) - - if (!chartNode?.visible) { - if (!chartNode) { - chartNode = graph.extend({ nodes: [node], edges: [] }).nodes[0] - } else { - chartNode.visible = true - setCooldownTicks(undefined) - setCooldownTime(1000) - } - graph.visibleLinks(true, [chartNode.id]) + setSelectedObj(undefined); + setData({ ...graph.Elements }); } + }; - setSearchNode(n) - setData({ ...graph.Elements }) - + const handleSearchSubmit = (node: any) => { const chart = chartRef.current if (chart) { - chart.centerAt(chartNode.x, chartNode.y, 1000); + let chartNode = graph.Elements.nodes.find(n => n.id == node.id) + + if (!chartNode?.visible) { + if (!chartNode) { + chartNode = graph.extend({ nodes: [node], edges: [] }).nodes[0] + } else { + chartNode.visible = true + setCooldownTicks(undefined) + setCooldownTime(1000) + } + graph.visibleLinks(true, [chartNode!.id]) + setData({ ...graph.Elements }) + } + + setSearchNode(chartNode) + setNodesToZoom([chartNode!]); } } - const handelRemove = (ids: number[]) => { + const handleRemove = (ids: number[]) => { graph.Elements.nodes.forEach(node => { if (!ids.includes(node.id)) return node.visible = false @@ -250,6 +270,33 @@ export function CodeGraph({ setData({ ...graph.Elements }) } + const handleDownloadImage = async () => { + try { + const canvas = document.querySelector('.force-graph-container canvas') as HTMLCanvasElement; + if (!canvas) { + toast({ + title: "Error", + description: "Canvas not found", + variant: "destructive", + }); + return; + } + + const dataURL = canvas.toDataURL('image/webp'); + const link = document.createElement('a'); + link.href = dataURL; + link.download = `${graphName}.webp`; + link.click(); + } catch (error) { + console.error('Error downloading graph image:', error); + toast({ + title: "Error", + description: "Failed to download image. Please try again.", + variant: "destructive", + }); + } + }; + return (
@@ -269,10 +316,9 @@ export function CodeGraph({
setSearchNode({ name })} + onValueChange={(node) => setSearchNode(node)} icon={} - handleSubmit={handelSearchSubmit} + handleSubmit={handleSearchSubmit} node={searchNode} /> @@ -343,6 +389,12 @@ export function CodeGraph({ className="pointer-events-auto" chartRef={chartRef} /> +
>; + obj: Node | Link | undefined; + setObj: Dispatch>; url: string; } const excludedProperties = [ "category", + "label", "color", "expand", "collapsed", @@ -19,7 +21,10 @@ const excludedProperties = [ "isPathStartEnd", "visible", "index", + "curve", "__indexColor", + "isPathSelected", + "__controlPoints", "x", "y", "vx", @@ -30,20 +35,16 @@ const excludedProperties = [ export default function DataPanel({ obj, setObj, url }: Props) { - const containerRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(0); - - useEffect(() => { - if (containerRef.current) { - setContainerHeight(containerRef.current.clientHeight); - } - }, [containerRef.current]); + debugger if (!obj) return null; - const label = `${obj.category}: ${obj.name}` + const type = "category" in obj + const label = type ? `${obj.category}: ${obj.name}` : obj.label const object = Object.entries(obj).filter(([k]) => !excludedProperties.includes(k)) + console.log(obj) + return (
@@ -52,7 +53,7 @@ export default function DataPanel({ obj, setObj, url }: Props) {
-
+
{ object.map(([key, value]) => (
@@ -73,40 +74,86 @@ export default function DataPanel({ obj, setObj, url }: Props) { > {value} - :

{value}

+ : typeof value === "object" ? + !excludedProperties.includes(k)))} + theme={{ + base00: '#343434', // background + base01: '#000000', + base02: '#CE9178', + base03: '#CE9178', // open values + base04: '#CE9178', + base05: '#CE9178', + base06: '#CE9178', + base07: '#CE9178', + base08: '#CE9178', + base09: '#b5cea8', // numbers + base0A: '#CE9178', + base0B: '#CE9178', // close values + base0C: '#CE9178', + base0D: '#99E4E5', // * keys + base0E: '#ae81ff', + base0F: '#cc6633' + }} + valueRenderer={(valueAsString, value, keyPath) => { + if (keyPath === "src") { + return + {value as string} + + } + return {value as string} + }} + /> + : {value} }
)) }
) diff --git a/app/components/elementMenu.tsx b/app/components/elementMenu.tsx index d6fd8d99..ece96f1d 100644 --- a/app/components/elementMenu.tsx +++ b/app/components/elementMenu.tsx @@ -1,26 +1,26 @@ "use client" import { Dispatch, RefObject, SetStateAction, useEffect, useState } from "react"; -import { Node } from "./model"; +import { Link, Node } from "./model"; import { ChevronsLeftRight, Copy, EyeOff, Globe, Maximize2, Minimize2, Waypoints } from "lucide-react"; import DataPanel from "./dataPanel"; import { Path } from "../page"; import { Position } from "./graphView"; interface Props { - obj: Node | undefined; + obj: Node | Link | undefined; objects: Node[]; setPath: Dispatch>; handleRemove: (nodes: number[]) => void; position: Position | undefined; url: string; - handelExpand: (nodes: Node[], expand: boolean) => void; + handleExpand: (nodes: Node[], expand: boolean) => void; parentRef: RefObject; } -export default function ElementMenu({ obj, objects, setPath, handleRemove, position, url, handelExpand, parentRef }: Props) { - const [currentObj, setCurrentObj] = useState(); +export default function ElementMenu({ obj, objects, setPath, handleRemove, position, url, handleExpand, parentRef }: Props) { + const [currentObj, setCurrentObj] = useState(); const [containerWidth, setContainerWidth] = useState(0); useEffect(() => { @@ -45,6 +45,7 @@ export default function ElementMenu({ obj, objects, setPath, handleRemove, posit left: Math.max(-34, Math.min(position.x - 33 - containerWidth / 2, (parentRef?.current?.clientWidth || 0) + 32 - containerWidth)), top: Math.min(position.y - 153, (parentRef?.current?.clientHeight || 0) - 9), }} + id="elementMenu" > { objects.some(o => o.id === obj.id) && objects.length > 1 ? @@ -68,25 +69,30 @@ export default function ElementMenu({ obj, objects, setPath, handleRemove, posit : <> - + { + "category" in obj && + <> + + + } - { - window.open(objURL, '_blank'); - }} - > - - + { + "category" in obj && + <> + { + window.open(objURL, '_blank'); + }} + > + + + + } - - + { + "category" in obj && + <> + + + + } } diff --git a/app/components/graphView.tsx b/app/components/graphView.tsx index b754fd82..77865c35 100644 --- a/app/components/graphView.tsx +++ b/app/components/graphView.tsx @@ -1,8 +1,8 @@ +'use client' -import ForceGraph2D from 'react-force-graph-2d'; +import ForceGraph2D, { NodeObject } from 'react-force-graph-2d'; import { Graph, GraphData, Link, Node } from './model'; -import { Dispatch, RefObject, SetStateAction, useEffect, useRef } from 'react'; -import { toast } from '@/components/ui/use-toast'; +import { Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from 'react'; import { Path } from '../page'; export interface Position { @@ -12,18 +12,18 @@ export interface Position { interface Props { data: GraphData - setData: Dispatch> graph: Graph chartRef: RefObject - selectedObj: Node | undefined - setSelectedObj: Dispatch> + selectedObj: Node | Link | undefined + setSelectedObj: Dispatch> selectedObjects: Node[] setSelectedObjects: Dispatch> setPosition: Dispatch> - onFetchNode: (nodeIds: number[]) => Promise - deleteNeighbors: (nodes: Node[]) => void + handleExpand: (nodes: Node[], expand: boolean) => void isShowPath: boolean setPath: Dispatch> + nodesToZoom: Node[] | undefined + setNodesToZoom: Dispatch> isPathResponse: boolean | undefined selectedPathId: number | undefined setSelectedPathId: (selectedPathId: number) => void @@ -39,7 +39,6 @@ const PADDING = 2; export default function GraphView({ data, - setData, graph, chartRef, selectedObj, @@ -47,10 +46,11 @@ export default function GraphView({ selectedObjects, setSelectedObjects, setPosition, - onFetchNode, - deleteNeighbors, + handleExpand, isShowPath, setPath, + nodesToZoom, + setNodesToZoom, isPathResponse, selectedPathId, setSelectedPathId, @@ -61,6 +61,30 @@ export default function GraphView({ }: Props) { const parentRef = useRef(null) + const lastClick = useRef<{ date: Date, name: string }>({ date: new Date(), name: "" }) + const [parentWidth, setParentWidth] = useState(0) + const [parentHeight, setParentHeight] = useState(0) + + useEffect(() => { + const handleResize = () => { + if (!parentRef.current) return + setParentWidth(parentRef.current.clientWidth) + setParentHeight(parentRef.current.clientHeight) + } + + window.addEventListener('resize', handleResize) + + const observer = new ResizeObserver(handleResize) + + if (parentRef.current) { + observer.observe(parentRef.current) + } + + return () => { + window.removeEventListener('resize', handleResize) + observer.disconnect() + } + }, [parentRef]) useEffect(() => { setCooldownTime(4000) @@ -78,24 +102,13 @@ export default function GraphView({ setSelectedObjects([]) } - const handelNodeClick = (node: Node, evt: MouseEvent) => { - if (isShowPath) { - setPath(prev => { - if (!prev?.start?.name || (prev.end?.name && prev.end?.name !== "")) { - return ({ start: { id: Number(node.id), name: node.name } }) - } else { - return ({ end: { id: Number(node.id), name: node.name }, start: prev.start }) - } - }) - return - } - - if (evt.ctrlKey) { + const handleRightClick = (node: Node | Link, evt: MouseEvent) => { + if (evt.ctrlKey && "category" in node) { if (selectedObjects.some(obj => obj.id === node.id)) { setSelectedObjects(selectedObjects.filter(obj => obj.id !== node.id)) return } else { - setSelectedObjects([...selectedObjects, node]) + setSelectedObjects([...selectedObjects, node as Node]) } } else { setSelectedObjects([]) @@ -105,40 +118,39 @@ export default function GraphView({ setPosition({ x: evt.clientX, y: evt.clientY }) } - const handelLinkClick = (link: Link, evt: MouseEvent) => { + const handleLinkClick = (link: Link, evt: MouseEvent) => { unsetSelectedObjects(evt) if (!isPathResponse || link.id === selectedPathId) return setSelectedPathId(link.id) } - const handelNodeRightClick = async (node: Node) => { - const expand = !node.expand - if (expand) { - const elements = await onFetchNode([node.id]) - - if (elements.nodes.length === 0) { - toast({ - title: `No neighbors found`, - description: `No neighbors found`, - }) - return - } - } else { - deleteNeighbors([node]); - } + const handleNodeClick = async (node: Node) => { + const now = new Date() + const { date, name } = lastClick.current - node.expand = expand + const isDoubleClick = now.getTime() - date.getTime() < 1000 && name === node.name + lastClick.current = { date: now, name: node.name } - setSelectedObj(undefined) - setData({ ...graph.Elements }) + if (isDoubleClick) { + handleExpand([node], !node.expand) + } else if (isShowPath) { + setPath(prev => { + if (!prev?.start?.name || (prev.end?.name && prev.end?.name !== "")) { + return ({ start: { id: Number(node.id), name: node.name } }) + } else { + return ({ end: { id: Number(node.id), name: node.name }, start: prev.start }) + } + }) + return + } } return (
(l.source.id === start.id && l.target.id === end.id) || (l.target.id === start.id && l.source.id === end.id)) - const index = sameNodesLinks.findIndex(l => l.id === link.id) || 0 - const even = index % 2 === 0 - let curve - if (start.id === end.id) { - if (even) { - curve = Math.floor(-(index / 2)) - 3 - } else { - curve = Math.floor((index + 1) / 2) + 2 - } - - link.curve = curve * 0.1 - const radius = NODE_SIZE * link.curve * 6.2; const angleOffset = -Math.PI / 4; // 45 degrees offset for text alignment const textX = start.x + radius * Math.cos(angleOffset); @@ -244,14 +243,6 @@ export default function GraphView({ ctx.translate(textX, textY); ctx.rotate(-angleOffset); } else { - if (even) { - curve = Math.floor(-(index / 2)) - } else { - curve = Math.floor((index + 1) / 2) - } - - link.curve = curve * 0.1 - const midX = (start.x + end.x) / 2 + (end.y - start.y) * (link.curve / 2); const midY = (start.y + end.y) / 2 + (start.x - end.x) * (link.curve / 2); @@ -275,18 +266,40 @@ export default function GraphView({ ctx.fillText(link.label, 0, 0); ctx.restore() }} - onNodeClick={handelNodeClick} + onNodeClick={handleNodeClick} onNodeDragEnd={(n, translate) => setPosition(prev => { return prev && { x: prev.x + translate.x * chartRef.current.zoom(), y: prev.y + translate.y * chartRef.current.zoom() } })} - onNodeRightClick={handelNodeRightClick} - onLinkClick={handelLinkClick} + onNodeRightClick={handleRightClick} + onLinkRightClick={handleRightClick} + onLinkClick={handleLinkClick} onBackgroundRightClick={unsetSelectedObjects} onBackgroundClick={unsetSelectedObjects} onZoom={() => unsetSelectedObjects()} onEngineStop={() => { setCooldownTicks(0) setCooldownTime(0) + + const chart = chartRef.current; + if (!chart || !nodesToZoom || nodesToZoom.length === 0) return; + + // Get canvas dimensions + const canvas = document.querySelector('.force-graph-container canvas') as HTMLCanvasElement; + if (!canvas) return; + + // Calculate padding as 10% of the smallest canvas dimension, with minimum of 40px + const minDimension = Math.min(canvas.width, canvas.height); + const padding = Math.max(minDimension * 0.1, 40); + + console.log('Canvas dimensions:', canvas.width, canvas.height); + console.log('nodesToZoom', nodesToZoom.map(n => n.id)); + + chart.zoomToFit(1000, padding, (n: NodeObject) => { + const isMatch = nodesToZoom.some(node => node.id === n.id); + console.log('Checking node:', n.id, 'isMatch:', isMatch); + return isMatch; + }); + setNodesToZoom(undefined); }} cooldownTicks={cooldownTicks} cooldownTime={cooldownTime} diff --git a/app/components/model.ts b/app/components/model.ts index 44fd658d..d38bc3a2 100644 --- a/app/components/model.ts +++ b/app/components/model.ts @@ -30,8 +30,6 @@ export type Link = LinkObject { + const start = link.source + const end = link.target + const sameNodesLinks = this.Elements.links.filter(l => (l.source.id === start.id && l.target.id === end.id) || (l.target.id === start.id && l.source.id === end.id)) + const index = sameNodesLinks.findIndex(l => l.id === link.id) ?? 0 + const even = index % 2 === 0 + let curve + + if (start.id === end.id) { + if (even) { + curve = Math.floor(-(index / 2)) - 3 + } else { + curve = Math.floor((index + 1) / 2) + 2 + } + } else { + if (even) { + curve = Math.floor(-(index / 2)) + } else { + curve = Math.floor((index + 1) / 2) + } + + } + + link.curve = curve * 0.1 + }) + + return newElements } diff --git a/app/components/toolbar.tsx b/app/components/toolbar.tsx index f718d741..97355a50 100644 --- a/app/components/toolbar.tsx +++ b/app/components/toolbar.tsx @@ -19,7 +19,14 @@ export function Toolbar({ chartRef, className }: Props) { const handleCenterClick = () => { const chart = chartRef.current if (chart) { - chart.zoomToFit(1000, 40) + // Get canvas dimensions + const canvas = document.querySelector('.force-graph-container canvas') as HTMLCanvasElement; + if (!canvas) return; + + // Calculate padding as 10% of the smallest canvas dimension, with minimum of 40px + const minDimension = Math.min(canvas.width, canvas.height); + const padding = minDimension * 0.1 + chart.zoomToFit(1000, padding) } } diff --git a/app/page.tsx b/app/page.tsx index a58f1a56..4551f0e2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Chat } from './components/chat'; -import { Graph, GraphData } from './components/model'; +import { Graph, GraphData, Node } from './components/model'; import { BookOpen, Github, HomeIcon, X } from 'lucide-react'; import Link from 'next/link'; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; @@ -62,6 +62,7 @@ export default function Home() { const [options, setOptions] = useState([]); const [path, setPath] = useState(); const [isSubmit, setIsSubmit] = useState(false); + const [nodesToZoom, setNodesToZoom] = useState(); const chartRef = useRef() useEffect(() => { @@ -269,6 +270,8 @@ export default function Home() { onFetchGraph={onFetchGraph} onFetchNode={onFetchNode} setPath={setPath} + nodesToZoom={nodesToZoom} + setNodesToZoom={setNodesToZoom} isShowPath={!!path} selectedValue={selectedValue} selectedPathId={selectedPathId} @@ -281,6 +284,7 @@ export default function Home() { + setNodesToZoom={setNodesToZoom} + /> diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..2e4ac22b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3.9" + +services: + falkordb: + image: falkordb/falkordb:latest + ports: + - "6379:6379" + - "3001:3000" + volumes: + - ./:/data/ + stdin_open: true # Keep the container's STDIN open + tty: true # Allocate a pseudo-TTY + + code-graph-frontend: + image: falkordb/code-graph-frontend:latest + ports: + - "3000:3000" + depends_on: + - code-graph-backend + environment: + - BACKEND_URL=http://code-graph-backend:5000 # Backend service URL + - SECRET_TOKEN=Vespa + + code-graph-backend: + image: falkordb/code-graph-backend:latest + ports: + - "4000:5000" + depends_on: + - falkordb + environment: + - FALKORDB_HOST=falkordb + - FALKORDB_PORT=6379 + - OPENAI_API_KEY=YOUR_OPENAI_API_KEY + - SECRET_TOKEN=Vespa + - FLASK_RUN_HOST=0.0.0.0 + - FLASK_RUN_PORT=5000 diff --git a/e2e/config/testData.ts b/e2e/config/testData.ts index dab5ebf7..3cb008e2 100644 --- a/e2e/config/testData.ts +++ b/e2e/config/testData.ts @@ -20,7 +20,7 @@ export const nodesPath: { firstNode: string; secondNode: string }[] = [ ]; export const nodes: { nodeName: string; }[] = [ - { nodeName: "import_data"}, + { nodeName: "ask"}, { nodeName: "add_edge" }, { nodeName: "test_kg_delete"}, { nodeName: "list_graphs"} diff --git a/e2e/logic/POM/codeGraph.ts b/e2e/logic/POM/codeGraph.ts index 9910ded3..7cc9ccd7 100644 --- a/e2e/logic/POM/codeGraph.ts +++ b/e2e/logic/POM/codeGraph.ts @@ -1,6 +1,6 @@ import { Locator, Page } from "playwright"; import BasePage from "../../infra/ui/basePage"; -import { delay, waitToBeEnabled } from "../utils"; +import { waitForElementToBeVisible, waitForStableText, waitToBeEnabled } from "../utils"; declare global { interface Window { @@ -152,6 +152,14 @@ export default class CodeGraph extends BasePage { return this.page.locator("//main[@data-name='main-chat']/*[last()-1]/p"); } + private get responseLoadingImg(): Locator { + return this.page.locator("//img[@alt='Waiting for response']"); + } + + private get waitingForResponseIndicator(): Locator { + return this.page.locator('img[alt="Waiting for response"]'); + } + /* Canvas Locators*/ private get canvasElement(): Locator { @@ -189,6 +197,10 @@ export default class CodeGraph extends BasePage { private get nodeDetailsPanel(): Locator { return this.page.locator("//div[@data-name='node-details-panel']"); } + + private get elementMenu(): Locator { + return this.page.locator("//div[@id='elementMenu']"); + } private get nodedetailsPanelHeader(): Locator { return this.page.locator("//div[@data-name='node-details-panel']/header/p"); @@ -216,6 +228,10 @@ export default class CodeGraph extends BasePage { private get copyToClipboardNodePanelDetails(): Locator { return this.page.locator(`//div[@data-name='node-details-panel']//button[@title='Copy src to clipboard']`); } + + private get nodeToolTip(): Locator { + return this.page.locator("//div[contains(@class, 'graph-tooltip')]"); + } /* NavBar functionality */ async clickOnFalkorDbLogo(): Promise { @@ -237,65 +253,81 @@ export default class CodeGraph extends BasePage { } async clickCreateNewProjectBtn(): Promise { + const isVisible = await waitForElementToBeVisible(this.createNewProjectBtn); + if (!isVisible) throw new Error("'Create New Project' button is not visible!"); await this.createNewProjectBtn.click(); } - + async isCreateNewProjectDialog(): Promise { - return await this.createNewProjectDialog.isVisible(); + return await waitForElementToBeVisible(this.createNewProjectDialog); } - - async clickonTipBtn(): Promise { + + async clickOnTipBtn(): Promise { await this.tipBtn.click(); } async isTipMenuVisible(): Promise { - await delay(500); - return await this.genericMenu.isVisible(); + await this.page.waitForTimeout(500); + return await waitForElementToBeVisible(this.genericMenu); } - - async clickonTipMenuCloseBtn(): Promise { + + async clickOnTipMenuCloseBtn(): Promise { + const isVisible = await waitForElementToBeVisible(this.tipMenuCloseBtn); + if (!isVisible) throw new Error("'Tip Menu Close' button is not visible!"); await this.tipMenuCloseBtn.click(); } + /* Chat functionality */ - async clickOnshowPathBtn(): Promise { + async clickOnShowPathBtn(): Promise { await this.showPathBtn.click(); } - async clickAskquestionBtn(): Promise { + async clickAskQuestionBtn(): Promise { + const isVisible = await waitForElementToBeVisible(this.askquestionBtn); + if (!isVisible) throw new Error("'Ask Question' button is not visible!"); await this.askquestionBtn.click(); } - + async sendMessage(message: string) { - await waitToBeEnabled(this.askquestionInput); + await waitToBeEnabled(this.askquestionBtn); await this.askquestionInput.fill(message); await this.askquestionBtn.click(); } - + async clickOnLightBulbBtn(): Promise { await this.lightbulbBtn.click(); } async getTextInLastChatElement(): Promise{ - await delay(2500); - return (await this.lastElementInChat.textContent())!; + await this.waitingForResponseIndicator.waitFor({ state: 'hidden' }); + return await waitForStableText(this.lastElementInChat); } - async getLastChatElementButtonCount(): Promise{ + async getLastChatElementButtonCount(): Promise { + const isVisible = await waitForElementToBeVisible(this.lastChatElementButtonCount); + if (!isVisible) return null; return await this.lastChatElementButtonCount.count(); } - async scrollToTop() { + async scrollToTop(): Promise { + const isVisible = await waitForElementToBeVisible(this.chatContainer); + if (!isVisible) throw new Error("Chat container is not visible!"); + await this.chatContainer.evaluate((chat) => { - chat.scrollTop = 0; + chat.scrollTop = 0; }); } async getScrollMetrics() { - const scrollTop = await this.chatContainer.evaluate((el) => el.scrollTop); - const scrollHeight = await this.chatContainer.evaluate((el) => el.scrollHeight); - const clientHeight = await this.chatContainer.evaluate((el) => el.clientHeight); - return { scrollTop, scrollHeight, clientHeight }; + const isVisible = await waitForElementToBeVisible(this.chatContainer); + if (!isVisible) throw new Error("Chat container is not visible!"); + + return await this.chatContainer.evaluate((el) => ({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight + })); } async isAtBottom(): Promise { @@ -311,33 +343,43 @@ export default class CodeGraph extends BasePage { await this.selectInputForShowPath(inputNum).fill(node); await this.selectFirstPathOption(inputNum).click(); } - + async isNodeVisibleInLastChatPath(node: string): Promise { - await this.locateNodeInLastChatPath(node).waitFor({ state: 'visible' }); - return await this.locateNodeInLastChatPath(node).isVisible(); + const nodeLocator = this.locateNodeInLastChatPath(node); + return await waitForElementToBeVisible(nodeLocator); } async isNotificationError(): Promise { - await delay(500); + await this.page.waitForTimeout(500); return await this.notificationError.isVisible(); } async clickOnNotificationErrorCloseBtn(): Promise { + const isVisible = await waitForElementToBeVisible(this.notificationErrorCloseBtn); + if (!isVisible) throw new Error("Notification error close button is not visible!"); await this.notificationErrorCloseBtn.click(); } - + async clickOnQuestionOptionsMenu(): Promise { + const isVisible = await waitForElementToBeVisible(this.questionOptionsMenu); + if (!isVisible) throw new Error("Question options menu is not visible!"); await this.questionOptionsMenu.click(); } - + async selectAndGetQuestionInOptionsMenu(questionNumber: string): Promise { - await this.selectQuestionInMenu(questionNumber).click(); - return await this.selectQuestionInMenu(questionNumber).innerHTML(); + const question = this.selectQuestionInMenu(questionNumber); + const isVisible = await waitForElementToBeVisible(question); + if (!isVisible) throw new Error(`Question ${questionNumber} in menu is not visible!`); + + await question.click(); + return await question.innerHTML(); } - + async getLastQuestionInChat(): Promise { + const isVisible = await waitForElementToBeVisible(this.lastQuestionInChat); + if (!isVisible) throw new Error("Last question in chat is not visible!"); return await this.lastQuestionInChat.innerText(); - } + } /* CodeGraph functionality */ async selectGraph(graph: string | number): Promise { @@ -349,6 +391,7 @@ export default class CodeGraph extends BasePage { await this.selectGraphInComboBoxByName(graph).waitFor({ state : 'visible'}) await this.selectGraphInComboBoxByName(graph).click(); } + await this.page.waitForTimeout(2000); // graph animation delay } async createProject(url : string): Promise { @@ -377,8 +420,9 @@ export default class CodeGraph extends BasePage { } async selectSearchBarOptionBtn(buttonNum: string): Promise { - await delay(1000); - await this.searchBarOptionBtn(buttonNum).click(); + const button = this.searchBarOptionBtn(buttonNum); + await button.waitFor({ state : "visible"}) + await button.click(); } async getSearchBarInputValue(): Promise { @@ -408,16 +452,37 @@ export default class CodeGraph extends BasePage { async clickCenter(): Promise { await this.centerBtn.click(); - await delay(2000); //animation delay + await this.page.waitForTimeout(2000); //animation delay } async clickOnRemoveNodeViaElementMenu(): Promise { - await this.elementMenuButton("Remove").click(); + const button = this.elementMenuButton("Remove"); + const isVisible = await waitForElementToBeVisible(button); + if (!isVisible) throw new Error("'Remove' button is not visible!"); + await button.click(); } - async nodeClick(x: number, y: number): Promise { + async nodeClick(x: number, y: number): Promise { + for (let attempt = 1; attempt <= 2; attempt++) { + await this.canvasElement.hover({ position: { x, y } }); + await this.page.waitForTimeout(500); + + if (await waitForElementToBeVisible(this.nodeToolTip)) { + await this.canvasElement.click({ position: { x, y }, button: 'right' }); + return; + } + await this.page.waitForTimeout(1000); + } + + throw new Error("Tooltip not visible after multiple attempts!"); + } + + async nodeClicktest(x: number, y: number): Promise { + console.log(`Clicking node at (${x}, ${y})`); + await this.page.waitForTimeout(500); await this.canvasElement.hover({ position: { x, y } }); - await this.canvasElement.click({ position: { x, y } }); + await this.canvasElement.click({ position: { x, y }, button: 'right' }); + await this.page.waitForTimeout(5000); } async selectCodeGraphCheckbox(checkbox: string): Promise { @@ -447,17 +512,27 @@ export default class CodeGraph extends BasePage { } async isNodeDetailsPanel(): Promise { + await this.page.waitForTimeout(500); return this.nodeDetailsPanel.isVisible(); } async clickOnViewNode(): Promise { - await this.elementMenuButton("View Node").click(); + const button = this.elementMenuButton("View Node"); + const isButtonVisible = await waitForElementToBeVisible(button); + if (!isButtonVisible) throw new Error("'View Node' button is not visible!"); + await button.click(); } async getNodeDetailsHeader(): Promise { - await this.elementMenuButton("View Node").click(); - const text = await this.nodedetailsPanelHeader.innerHTML(); - return text; + const isMenuVisible = await waitForElementToBeVisible(this.elementMenu); + if (!isMenuVisible) throw new Error("Element menu did not appear!"); + + await this.clickOnViewNode(); + + const isHeaderVisible = await waitForElementToBeVisible(this.nodedetailsPanelHeader); + if (!isHeaderVisible) throw new Error("Node details panel header did not appear!"); + + return this.nodedetailsPanelHeader.innerHTML(); } async clickOnNodeDetailsCloseBtn(): Promise{ @@ -471,14 +546,17 @@ export default class CodeGraph extends BasePage { } async clickOnCopyToClipboardNodePanelDetails(): Promise { + const isButtonVisible = await waitForElementToBeVisible(this.copyToClipboardNodePanelDetails); + if (!isButtonVisible) throw new Error("'copy to clipboard button is not visible!"); await this.copyToClipboardNodePanelDetails.click(); - await delay(1000) return await this.page.evaluate(() => navigator.clipboard.readText()); } async clickOnCopyToClipboard(): Promise { - await this.elementMenuButton("Copy src to clipboard").click(); - await delay(1000) + const button = this.elementMenuButton("Copy src to clipboard"); + const isVisible = await waitForElementToBeVisible(button); + if (!isVisible) throw new Error("View Node button is not visible!"); + await button.click(); return await this.page.evaluate(() => navigator.clipboard.readText()); } @@ -487,16 +565,22 @@ export default class CodeGraph extends BasePage { } async getNodeDetailsPanelElements(): Promise { - await this.elementMenuButton("View Node").click(); - await delay(500) + const button = this.elementMenuButton("View Node"); + const isVisible = await waitForElementToBeVisible(button); + if (!isVisible) throw new Error("View Node button is not visible!"); + await button.click(); + + const isPanelVisible = await waitForElementToBeVisible(this.nodedetailsPanelElements.first()); + if (!isPanelVisible) throw new Error("Node details panel did not appear!"); + const elements = await this.nodedetailsPanelElements.all(); return Promise.all(elements.map(element => element.innerHTML())); } async getGraphDetails(): Promise { await this.canvasElementBeforeGraphSelection.waitFor({ state: 'detached' }); - await delay(2000) - await this.page.waitForFunction(() => !!window.graph); + await this.page.waitForFunction(() => window.graph && window.graph.elements.nodes.length > 0); + await this.page.waitForTimeout(2000); //canvas animation const graphData = await this.page.evaluate(() => { return window.graph; @@ -506,30 +590,48 @@ export default class CodeGraph extends BasePage { } async transformNodeCoordinates(graphData: any): Promise { - const { canvasLeft, canvasTop, canvasWidth, canvasHeight, transform } = await this.canvasElement.evaluate((canvas: HTMLCanvasElement) => { - const rect = canvas.getBoundingClientRect(); - const ctx = canvas.getContext('2d'); - const transform = ctx?.getTransform()!; - return { - canvasLeft: rect.left, - canvasTop: rect.top, - canvasWidth: rect.width, - canvasHeight: rect.height, - transform, - }; - }); + let maxRetries = 3; + let transform = null; + let canvasRect = null; + await this.page.waitForFunction(() => window.graph && window.graph.elements?.nodes?.length > 0); + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + await this.page.waitForTimeout(1000); + + const result = await this.canvasElement.evaluate((canvas: HTMLCanvasElement) => { + const rect = canvas.getBoundingClientRect(); + const ctx = canvas.getContext('2d'); + return { + canvasLeft: rect.left, + canvasTop: rect.top, + canvasWidth: rect.width, + canvasHeight: rect.height, + transform: ctx?.getTransform() || null, + }; + }); + + if (!result.transform) { + console.warn(`Attempt ${attempt}: Transform not available yet, retrying...`); + continue; + } + + transform = result.transform; + canvasRect = result; + break; + } + + if (!transform) throw new Error("Canvas transform data not available after multiple attempts!"); - const screenCoordinates = graphData.elements.nodes.map((node: any) => { - const adjustedX = node.x * transform.a + transform.e; + return graphData.elements.nodes.map((node: any) => { + const adjustedX = node.x * transform.a + transform.e; const adjustedY = node.y * transform.d + transform.f; - const screenX = canvasLeft + adjustedX - 35; - const screenY = canvasTop + adjustedY - 190; + const screenX = canvasRect!.canvasLeft + adjustedX - 35; + const screenY = canvasRect!.canvasTop + adjustedY - 190; - return {...node, screenX, screenY,}; + return { ...node, screenX, screenY }; }); - - return screenCoordinates; } + async getCanvasScaling(): Promise<{ scaleX: number; scaleY: number }> { const { scaleX, scaleY } = await this.canvasElement.evaluate((canvas: HTMLCanvasElement) => { diff --git a/e2e/logic/utils.ts b/e2e/logic/utils.ts index 6ba3475d..17b575d8 100644 --- a/e2e/logic/utils.ts +++ b/e2e/logic/utils.ts @@ -2,19 +2,50 @@ import { Locator } from "@playwright/test"; export const delay = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); -export const waitToBeEnabled = async (locator: Locator, timeout: number = 5000): Promise => { - const startTime = Date.now(); +export const waitToBeEnabled = async (locator: Locator, timeout: number = 5000): Promise => { + const elementHandle = await locator.elementHandle(); + if (!elementHandle) throw new Error("Element not found"); - while (Date.now() - startTime < timeout) { - if (await locator.isEnabled()) { - return true; + await locator.page().waitForFunction( + (el) => el && !(el as HTMLElement).hasAttribute("disabled"), + elementHandle, + { timeout } + ); +}; + +export const waitForStableText = async (locator: Locator, timeout: number = 5000): Promise => { + const elementHandle = await locator.elementHandle(); + if (!elementHandle) throw new Error("Element not found"); + + let previousText = ""; + let stableText = ""; + const pollingInterval = 300; + const maxChecks = timeout / pollingInterval; + + for (let i = 0; i < maxChecks; i++) { + stableText = await locator.textContent() ?? ""; + if (stableText === previousText && stableText.trim().length > 0) { + return stableText; } - await new Promise(resolve => setTimeout(resolve, 100)); + previousText = stableText; + await locator.page().waitForTimeout(pollingInterval); } - return false; + return stableText; }; +export const waitForElementToBeVisible = async (locator:Locator,time=400,retry=5):Promise => { + + while(retry > 0){ + if(await locator.isVisible()){ + return true + } + retry = retry-1 + await delay(time) + } + return false +} + export function findNodeByName(nodes: { name: string }[], nodeName: string): any { return nodes.find((node) => node.name === nodeName); } \ No newline at end of file diff --git a/e2e/tests/canvas.spec.ts b/e2e/tests/canvas.spec.ts index 3701ac86..e54858d8 100644 --- a/e2e/tests/canvas.spec.ts +++ b/e2e/tests/canvas.spec.ts @@ -43,15 +43,14 @@ test.describe("Canvas tests", () => { test(`Verify center graph button centers nodes in canvas`, async () => { const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); await codeGraph.selectGraph(GRAPH_ID); + await codeGraph.clickCenter(); const initialGraph = await codeGraph.getCanvasScaling(); - await codeGraph.clickZoomOut(); await codeGraph.clickZoomOut(); await codeGraph.clickCenter(); const updatedGraph = await codeGraph.getCanvasScaling(); expect(Math.abs(initialGraph.scaleX - updatedGraph.scaleX)).toBeLessThanOrEqual(0.1); expect(Math.abs(initialGraph.scaleY - updatedGraph.scaleY)).toBeLessThanOrEqual(0.1); - }) nodes.slice(0,2).forEach((node) => { @@ -101,7 +100,7 @@ test.describe("Canvas tests", () => { test(`Verify "Clear graph" button resets canvas view for path ${path.firstNode} and ${path.secondNode}`, async () => { const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); await codeGraph.selectGraph(GRAPH_ID); - await codeGraph.clickOnshowPathBtn(); + await codeGraph.clickOnShowPathBtn(); await codeGraph.insertInputForShowPath("1", path.firstNode); await codeGraph.insertInputForShowPath("2", path.secondNode); const initialGraph = await codeGraph.getGraphDetails(); @@ -186,7 +185,7 @@ test.describe("Canvas tests", () => { test(`Verify successful node path connection in canvas between ${firstNode} and ${secondNode} via UI`, async () => { const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); await codeGraph.selectGraph(GRAPH_ID); - await codeGraph.clickOnshowPathBtn(); + await codeGraph.clickOnShowPathBtn(); await codeGraph.insertInputForShowPath("1", firstNode); await codeGraph.insertInputForShowPath("2", secondNode); const result = await codeGraph.getGraphDetails(); @@ -201,7 +200,7 @@ test.describe("Canvas tests", () => { test(`Validate node path connection in canvas ui and confirm via api for path ${path.firstNode} and ${path.secondNode}`, async () => { const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); await codeGraph.selectGraph(GRAPH_ID); - await codeGraph.clickOnshowPathBtn(); + await codeGraph.clickOnShowPathBtn(); await codeGraph.insertInputForShowPath("1", path.firstNode); await codeGraph.insertInputForShowPath("2", path.secondNode); const result = await codeGraph.getGraphDetails(); diff --git a/e2e/tests/chat.spec.ts b/e2e/tests/chat.spec.ts index 1f245f11..d81ac33d 100644 --- a/e2e/tests/chat.spec.ts +++ b/e2e/tests/chat.spec.ts @@ -31,7 +31,7 @@ test.describe("Chat tests", () => { await chat.selectGraph(GRAPH_ID); const isLoadingArray: boolean[] = []; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 3; i++) { await chat.sendMessage(Node_Question); const isLoading: boolean = await chat.getpreviousQuestionLoadingImage(); isLoadingArray.push(isLoading); @@ -39,29 +39,59 @@ test.describe("Chat tests", () => { const prevIsLoading = isLoadingArray[i - 1]; expect(prevIsLoading).toBe(false); } + await delay(3000); } }); test("Verify auto-scroll and manual scroll in chat", async () => { const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); await chat.selectGraph(GRAPH_ID); - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 3; i++) { await chat.sendMessage(Node_Question); + await delay(3000); } - await delay(500); + await delay(500); // delay for scroll await chat.scrollToTop(); const { scrollTop } = await chat.getScrollMetrics(); expect(scrollTop).toBeLessThanOrEqual(1); - await chat.sendMessage("Latest Message"); - await delay(500); + await chat.sendMessage(Node_Question); + await delay(500); // delay for scroll expect(await chat.isAtBottom()).toBe(true); }); + test(`Validate consistent UI responses for repeated questions in chat`, async () => { + const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); + await chat.selectGraph(GRAPH_ID); + const responses: string[] = []; + for (let i = 0; i < 3; i++) { + await chat.sendMessage(Node_Question); + const result = await chat.getTextInLastChatElement(); + const number = result.match(/\d+/g)?.[0]!; + responses.push(number); + await delay(3000); //delay before asking next question + } + const identicalResponses = responses.every((value) => value === responses[0]); + expect(identicalResponses).toBe(true); + }); + + test(`Validate UI response matches API response for a given question in chat`, async () => { + const api = new ApiCalls(); + const apiResponse = await api.askQuestion(PROJECT_NAME, Node_Question); + const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); + await chat.selectGraph(GRAPH_ID); + await delay(3000); + await chat.sendMessage(Node_Question); + const uiResponse = await chat.getTextInLastChatElement(); + const number = uiResponse.match(/\d+/g)?.[0]!; + + expect(number).toEqual(apiResponse.result.response.match(/\d+/g)?.[0]); + }); + nodesPath.forEach((path) => { test(`Verify successful node path connection between two nodes in chat for ${path.firstNode} and ${path.secondNode}`, async () => { const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); await chat.selectGraph(GRAPH_ID); - await chat.clickOnshowPathBtn(); + await chat.clickOnShowPathBtn(); await chat.insertInputForShowPath("1", path.firstNode); await chat.insertInputForShowPath("2", path.secondNode); expect(await chat.isNodeVisibleInLastChatPath(path.firstNode)).toBe(true); @@ -73,7 +103,7 @@ test.describe("Chat tests", () => { test(`Verify unsuccessful node path connection between two nodes in chat for ${path.firstNode} and ${path.secondNode}`, async () => { const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); await chat.selectGraph(GRAPH_ID); - await chat.clickOnshowPathBtn(); + await chat.clickOnShowPathBtn(); await chat.insertInputForShowPath("1", path.secondNode); await chat.insertInputForShowPath("2", path.firstNode); await delay(500); @@ -84,7 +114,7 @@ test.describe("Chat tests", () => { test("Validate error notification and its closure when sending an empty question in chat", async () => { const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); await chat.selectGraph(GRAPH_ID); - await chat.clickAskquestionBtn(); + await chat.clickAskQuestionBtn(); expect(await chat.isNotificationError()).toBe(true); await chat.clickOnNotificationErrorCloseBtn(); expect(await chat.isNotificationError()).toBe(false); @@ -98,33 +128,8 @@ test.describe("Chat tests", () => { await chat.clickOnQuestionOptionsMenu(); const selectedQuestion = await chat.selectAndGetQuestionInOptionsMenu(questionNumber.toString()); expect(selectedQuestion).toEqual(await chat.getLastQuestionInChat()) + const result = await chat.getTextInLastChatElement(); + expect(result).toBeDefined(); }); } - - test(`Validate consistent UI responses for repeated questions in chat`, async () => { - const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); - await chat.selectGraph(GRAPH_ID); - const responses: string[] = []; - for (let i = 0; i < 3; i++) { - await chat.sendMessage(Node_Question); - const result = await chat.getTextInLastChatElement(); - const number = result.match(/\d+/g)?.[0]!; - responses.push(number); - } - const identicalResponses = responses.every((value) => value === responses[0]); - expect(identicalResponses).toBe(true); - }); - - test(`Validate UI response matches API response for a given question in chat`, async () => { - const chat = await browser.createNewPage(CodeGraph, urls.baseUrl); - await chat.selectGraph(GRAPH_ID); - - await chat.sendMessage(Node_Question); - const uiResponse = await chat.getTextInLastChatElement(); - const number = uiResponse.match(/\d+/g)?.[0]!; - - const api = new ApiCalls(); - const apiResponse = await api.askQuestion(PROJECT_NAME, Node_Question); - expect(number).toEqual(apiResponse.result.response.match(/\d+/g)?.[0]); - }); }); diff --git a/e2e/tests/navBar.spec.ts b/e2e/tests/navBar.spec.ts index 9f294a75..f282ca0a 100644 --- a/e2e/tests/navBar.spec.ts +++ b/e2e/tests/navBar.spec.ts @@ -41,9 +41,9 @@ test.describe(' Navbar tests', () => { test("Validate Tip popup visibility and closure functionality", async () => { const navBar = await browser.createNewPage(CodeGraph, urls.baseUrl); - await navBar.clickonTipBtn(); + await navBar.clickOnTipBtn(); expect(await navBar.isTipMenuVisible()).toBe(true); - await navBar.clickonTipMenuCloseBtn(); + await navBar.clickOnTipMenuCloseBtn(); expect(await navBar.isTipMenuVisible()).toBe(false); }); }); diff --git a/e2e/tests/nodeDetailsPanel.spec.ts b/e2e/tests/nodeDetailsPanel.spec.ts index aa59a6fe..3aea588c 100644 --- a/e2e/tests/nodeDetailsPanel.spec.ts +++ b/e2e/tests/nodeDetailsPanel.spec.ts @@ -25,6 +25,7 @@ test.describe("Node details panel tests", () => { const graphData = await codeGraph.getGraphDetails(); const convertCoordinates = await codeGraph.transformNodeCoordinates(graphData); const targetNode = findNodeByName(convertCoordinates, node.nodeName); + expect(targetNode).toBeDefined(); await codeGraph.nodeClick(targetNode.screenX, targetNode.screenY); await codeGraph.clickOnViewNode(); expect(await codeGraph.isNodeDetailsPanel()).toBe(true) @@ -78,7 +79,7 @@ test.describe("Node details panel tests", () => { test(`Validate view node panel keys via api for ${node.nodeName}`, async () => { const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); await codeGraph.selectGraph(GRAPH_ID); - const graphData = await codeGraph.getGraphDetails(); + const graphData = await codeGraph.getGraphDetails(); const convertCoordinates = await codeGraph.transformNodeCoordinates(graphData); const node1 = findNodeByName(convertCoordinates, node.nodeName); const api = new ApiCalls(); diff --git a/package-lock.json b/package-lock.json index 83462b39..11fe98ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,10 @@ "@radix-ui/react-tooltip": "^1.1.4", "@types/react-gtm-module": "^2.0.4", "autoprefixer": "^10.4.20", + "canvas2svg": "^1.0.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", + "html2canvas": "^1.4.1", "lucide-react": "^0.441.0", "next": "^15.1.2", "playwright": "^1.49.1", @@ -28,6 +30,7 @@ "react-dom": "^18", "react-force-graph-2d": "^1.25.8", "react-gtm-module": "^2.0.11", + "react-json-tree": "^0.19.0", "react-resizable-panels": "^2.0.20", "react-syntax-highlighter": "^15.6.1", "react-type-animation": "^3.2.0", @@ -2664,6 +2667,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==" + }, "node_modules/@types/node": { "version": "22.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", @@ -2677,14 +2685,12 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3275,6 +3281,14 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/bezier-js": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", @@ -3457,6 +3471,11 @@ "node": ">=12" } }, + "node_modules/canvas2svg": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/canvas2svg/-/canvas2svg-1.0.16.tgz", + "integrity": "sha512-r3ryHprzDOtAsFuczw+/DKkLR3XexwIlJWnJ+71I9QF7V9scYaV5JZgYDoCUlYtT3ARnOpDcm/hDNZYbWMRHqA==" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3566,7 +3585,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "optional": true, "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" @@ -3597,7 +3615,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "optional": true, "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" @@ -3642,6 +3659,14 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3658,7 +3683,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -5238,6 +5262,18 @@ "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -5355,8 +5391,7 @@ "node_modules/is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "optional": true + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/is-async-function": { "version": "2.0.0", @@ -6763,6 +6798,17 @@ "node": ">=0.10.0" } }, + "node_modules/react-base16-styling": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.10.0.tgz", + "integrity": "sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==", + "dependencies": { + "@types/lodash": "^4.17.0", + "color": "^4.2.3", + "csstype": "^3.1.3", + "lodash-es": "^4.17.21" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -6803,6 +6849,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-json-tree": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.19.0.tgz", + "integrity": "sha512-PqT1WRVcWP+RROsZPQfNEKIC1iM/ZMfY4g5jN6oDnXp5593PPRAYgoHcgYCDjflAHQMtxl8XGdlTwIBdEGUXvw==", + "dependencies": { + "@types/lodash": "^4.17.0", + "react-base16-styling": "^0.10.0" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-kapsule": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.6.tgz", @@ -7328,7 +7387,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "optional": true, "dependencies": { "is-arrayish": "^0.3.1" } @@ -7711,6 +7769,14 @@ "node": ">=6" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -7999,6 +8065,14 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0a789aa3..7554b92c 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,10 @@ "@radix-ui/react-tooltip": "^1.1.4", "@types/react-gtm-module": "^2.0.4", "autoprefixer": "^10.4.20", + "canvas2svg": "^1.0.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", + "html2canvas": "^1.4.1", "lucide-react": "^0.441.0", "next": "^15.1.2", "playwright": "^1.49.1", @@ -29,6 +31,7 @@ "react-dom": "^18", "react-force-graph-2d": "^1.25.8", "react-gtm-module": "^2.0.11", + "react-json-tree": "^0.19.0", "react-resizable-panels": "^2.0.20", "react-syntax-highlighter": "^15.6.1", "react-type-animation": "^3.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 7a5292d5..8b5b1550 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,13 +16,14 @@ export default defineConfig({ /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, + forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 2 : undefined, + workers: process.env.CI ? 3 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: [['html', { outputFolder: 'playwright-report' }]], + outputDir: 'playwright-report/artifacts', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -30,6 +31,7 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + screenshot: 'only-on-failure', }, /* Configure projects for major browsers */