diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f7becd95..d5442978 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -23,6 +23,7 @@ "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "fastest-levenshtein": "^1.0.16", "lucide-react": "^0.541.0", "next-themes": "^0.4.6", "ramda": "^0.31.3", diff --git a/apps/frontend/src/__tests__/text-insertion.test.ts b/apps/frontend/src/__tests__/text-insertion.test.ts new file mode 100644 index 00000000..a6c2228a --- /dev/null +++ b/apps/frontend/src/__tests__/text-insertion.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest" +import type { ValkeyCommand } from "@/types/valkey-commands" +import { insertCommandIntoText, extractCommandFromText } from "@/utils/text-insertion" + +describe("text-insertion", () => { + describe("extractCommandFromText", () => { + it("should extract command from simple text", () => { + const result = extractCommandFromText("GET", 3) + expect(result).toBe("GET") + }) + + it("should extract partial command", () => { + const result = extractCommandFromText("GE", 2) + expect(result).toBe("GE") + }) + + it("should extract command with arguments", () => { + const result = extractCommandFromText("GET mykey", 3) + expect(result).toBe("GET") + }) + + it("should extract command from multi-line text", () => { + const result = extractCommandFromText("SET key1 value1\nGET", 19) + expect(result).toBe("GET") + }) + + it("should handle whitespace before command", () => { + const result = extractCommandFromText(" GET", 5) + expect(result).toBe("GET") + }) + + it("should return empty string for empty text", () => { + const result = extractCommandFromText("", 0) + expect(result).toBe("") + }) + }) + + describe("insertCommandIntoText", () => { + const getCommand: ValkeyCommand = { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + tier: "read", + } + + const pingCommand: ValkeyCommand = { + name: "PING", + syntax: "PING", + category: "connection", + description: "Ping the server", + parameters: [], + tier: "read", + } + + it("should insert command without placeholder", () => { + const result = insertCommandIntoText("G", 1, getCommand) + expect(result.newText).toBe("GET") + expect(result.newCursorPosition).toBe(3) + }) + + it("should preserve existing arguments", () => { + const result = insertCommandIntoText("G mykey", 1, getCommand) + expect(result.newText).toBe("GET mykey") + expect(result.newCursorPosition).toBe(9) + }) + + it("should insert command without parameters", () => { + const result = insertCommandIntoText("PIN", 3, pingCommand) + expect(result.newText).toBe("PING") + expect(result.newCursorPosition).toBe(4) + }) + + it("should handle multi-line text", () => { + const result = insertCommandIntoText("SET key1 value1\nG", 17, getCommand) + expect(result.newText).toBe("SET key1 value1\nGET") + expect(result.newCursorPosition).toBe(19) + }) + + it("should handle whitespace before command", () => { + const result = insertCommandIntoText(" G", 3, getCommand) + expect(result.newText).toBe(" GET") + expect(result.newCursorPosition).toBe(5) + }) + + it("should replace partial command on same line", () => { + const result = insertCommandIntoText("GE", 2, getCommand) + expect(result.newText).toBe("GET") + expect(result.newCursorPosition).toBe(3) + }) + }) +}) diff --git a/apps/frontend/src/hooks/useIsConnected.test.tsx b/apps/frontend/src/__tests__/useIsConnected.test.tsx similarity index 98% rename from apps/frontend/src/hooks/useIsConnected.test.tsx rename to apps/frontend/src/__tests__/useIsConnected.test.tsx index 38753aa5..07a6dc7e 100644 --- a/apps/frontend/src/hooks/useIsConnected.test.tsx +++ b/apps/frontend/src/__tests__/useIsConnected.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook } from "@testing-library/react" import { Provider } from "react-redux" import { CONNECTED, CONNECTING, DISCONNECTED, ERROR } from "@common/src/constants" -import useIsConnected from "./useIsConnected" +import useIsConnected from "../hooks/useIsConnected" import { setupTestStore } from "@/test/utils/test-utils" import { mockConnectionState } from "@/test/utils/mocks" import { standaloneConnectFulfilled } from "@/state/valkey-features/connection/connectionSlice" diff --git a/apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx b/apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx similarity index 99% rename from apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx rename to apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx index cebb779b..a5387b0c 100644 --- a/apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx +++ b/apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx @@ -3,7 +3,7 @@ import { renderHook } from "@testing-library/react" import { Provider } from "react-redux" import { BrowserRouter } from "react-router" import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { useValkeyConnectionNavigation } from "./useValkeyConnectionNavigation" +import { useValkeyConnectionNavigation } from "../hooks/useValkeyConnectionNavigation" import { setupTestStore } from "@/test/utils/test-utils" import { mockConnectionState } from "@/test/utils/mocks" diff --git a/apps/frontend/src/hooks/useWebSocketNavigation.test.tsx b/apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx similarity index 98% rename from apps/frontend/src/hooks/useWebSocketNavigation.test.tsx rename to apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx index f11c67c9..c215506b 100644 --- a/apps/frontend/src/hooks/useWebSocketNavigation.test.tsx +++ b/apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx @@ -3,7 +3,7 @@ import { renderHook } from "@testing-library/react" import { Provider } from "react-redux" import { BrowserRouter } from "react-router" import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { useWebSocketNavigation } from "./useWebSocketNavigation" +import { useWebSocketNavigation } from "../hooks/useWebSocketNavigation" import { setupTestStore } from "@/test/utils/test-utils" import { mockWebSocketState } from "@/test/utils/mocks" diff --git a/apps/frontend/src/__tests__/valkey-command-matching.filtering.test.ts b/apps/frontend/src/__tests__/valkey-command-matching.filtering.test.ts new file mode 100644 index 00000000..dac3e31e --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-command-matching.filtering.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest" +import { + getCommands, + searchCommands, + matchCommands +} from "../utils/valkey-command-matching" + +describe("Valkey Command Filtering", () => { + describe("getCommands", () => { + it("should return only read and mutating commands when adminMode is false", () => { + const commands = getCommands({ adminMode: false }) + + commands.forEach((command) => { + expect(["read", "mutating"]).toContain(command.tier) + }) + }) + + it("should return all commands including admin when adminMode is true", () => { + const allCommands = getCommands({ adminMode: true }) + const nonAdminCommands = getCommands({ adminMode: false }) + + expect(allCommands.length).toBeGreaterThan(nonAdminCommands.length) + + const hasAdminCommands = allCommands.some((c) => c.tier === "admin") + expect(hasAdminCommands).toBe(true) + }) + + it("should default to adminMode false when no options provided", () => { + const commands = getCommands() + + commands.forEach((command) => { + expect(["read", "mutating"]).toContain(command.tier) + }) + }) + }) + + describe("searchCommands", () => { + it("should not return admin commands when adminMode is false", () => { + const results = searchCommands("FLUSH", { adminMode: false }) + + results.forEach((result) => { + expect(result.command.tier).not.toBe("admin") + }) + }) + + it("should return admin commands when adminMode is true", () => { + const results = searchCommands("FLUSH", { adminMode: true }) + + const hasFlushDb = results.some((r) => r.command.name === "FLUSHDB") + const hasFlushAll = results.some((r) => r.command.name === "FLUSHALL") + + expect(hasFlushDb || hasFlushAll).toBe(true) + }) + + it("should respect maxResults parameter", () => { + const results = searchCommands("S", { maxResults: 5 }) + + expect(results.length).toBeLessThanOrEqual(5) + }) + + it("should return empty array for empty query", () => { + const results = searchCommands("", { adminMode: true }) + + expect(results).toEqual([]) + }) + }) + + describe("matchCommands", () => { + it("should filter by adminMode parameter", () => { + const withoutAdmin = matchCommands("CONFIG", 10, false) + const withAdmin = matchCommands("CONFIG", 10, true) + + expect(withAdmin.length).toBeGreaterThanOrEqual(withoutAdmin.length) + }) + + it("should prioritize prefix matches", () => { + const results = matchCommands("GET", 10, false) + + if (results.length > 0) { + expect(results[0].command.name).toBe("GET") + expect(results[0].matchType).toBe("prefix") + } + }) + + it("should return contains matches", () => { + const results = matchCommands("SCAN", 10, false) + + const hasScan = results.some((r) => r.command.name === "SCAN") + const hasHscan = results.some((r) => r.command.name === "HSCAN") + const hasSscan = results.some((r) => r.command.name === "SSCAN") + const hasZscan = results.some((r) => r.command.name === "ZSCAN") + + expect(hasScan || hasHscan || hasSscan || hasZscan).toBe(true) + }) + }) +}) diff --git a/apps/frontend/src/__tests__/valkey-command-matching.test.ts b/apps/frontend/src/__tests__/valkey-command-matching.test.ts new file mode 100644 index 00000000..0f060310 --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-command-matching.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest" +import { matchCommands } from "../utils/valkey-command-matching" + +describe("Valkey Command Matching", () => { + it("should return empty array for empty query", () => { + const results = matchCommands("") + expect(results).toEqual([]) + }) + + it("should return empty array for whitespace query", () => { + const results = matchCommands(" ") + expect(results).toEqual([]) + }) + + it("should find exact prefix matches", () => { + const results = matchCommands("GET") + expect(results.length).toBeGreaterThan(0) + expect(results[0].command.name).toBe("GET") + expect(results[0].matchType).toBe("prefix") + }) + + it("should prioritize prefix matches over contains matches", () => { + const results = matchCommands("SET") + const setCommand = results.find((r) => r.command.name === "SET") + const hsetCommand = results.find((r) => r.command.name === "HSET") + + expect(setCommand).toBeDefined() + expect(hsetCommand).toBeDefined() + + if (setCommand && hsetCommand) { + const setIndex = results.indexOf(setCommand) + const hsetIndex = results.indexOf(hsetCommand) + expect(setIndex).toBeLessThan(hsetIndex) + } + }) + + it("should limit results to maxResults parameter", () => { + const results = matchCommands("S", 5) + expect(results.length).toBeLessThanOrEqual(5) + }) + + it("should find contains matches", () => { + const results = matchCommands("PUSH") + expect(results.length).toBeGreaterThan(0) + + const lpushResult = results.find((r) => r.command.name === "LPUSH") + const rpushResult = results.find((r) => r.command.name === "RPUSH") + + expect(lpushResult).toBeDefined() + expect(rpushResult).toBeDefined() + expect(lpushResult?.matchType).toBe("contains") + expect(rpushResult?.matchType).toBe("contains") + }) + + it("should handle case insensitive matching", () => { + const results = matchCommands("get") + expect(results.length).toBeGreaterThan(0) + expect(results[0].command.name).toBe("GET") + }) + + it("should include highlight ranges for matches", () => { + const results = matchCommands("GET") + expect(results[0].highlightRanges).toBeDefined() + expect(results[0].highlightRanges.length).toBeGreaterThan(0) + expect(results[0].highlightRanges[0]).toEqual([0, 3]) + }) +}) diff --git a/apps/frontend/src/__tests__/valkey-commands.validation.test.ts b/apps/frontend/src/__tests__/valkey-commands.validation.test.ts new file mode 100644 index 00000000..fe8d24b4 --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-commands.validation.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest" +import valkeyCommands from "../data/valkey-commands.json" +import type { ValkeyCommand } from "@/types/valkey-commands" + +describe("Valkey Command Database Validation", () => { + const commands = valkeyCommands as ValkeyCommand[] + + it("should have all required fields for every command", () => { + commands.forEach((command) => { + expect(command).toHaveProperty("name") + expect(command).toHaveProperty("syntax") + expect(command).toHaveProperty("category") + expect(command).toHaveProperty("description") + expect(command).toHaveProperty("parameters") + expect(command).toHaveProperty("tier") + + expect(typeof command.name).toBe("string") + expect(typeof command.syntax).toBe("string") + expect(typeof command.category).toBe("string") + expect(typeof command.description).toBe("string") + expect(Array.isArray(command.parameters)).toBe(true) + expect(["read", "mutating", "admin"]).toContain(command.tier) + }) + }) + + it("should have non-empty name, syntax, and description", () => { + commands.forEach((command) => { + expect(command.name.length).toBeGreaterThan(0) + expect(command.syntax.length).toBeGreaterThan(0) + expect(command.description.length).toBeGreaterThan(0) + }) + }) + + it("should have valid categories", () => { + const validCategories = [ + "connection", + "server", + "generic", + "string", + "hash", + "list", + "set", + "sorted_set", + "scan", + "admin", + ] + + commands.forEach((command) => { + expect(validCategories).toContain(command.category) + }) + }) + + it("should have required parameters present in syntax", () => { + commands.forEach((command) => { + const requiredParams = command.parameters.filter((p) => p.required) + + requiredParams.forEach((param) => { + // Check if either the parameter name or a generic version is in the syntax + const paramName = param.name + const paramInSyntax = command.syntax.includes(paramName) || + command.syntax.includes(param.placeholder || param.name) + if (!paramInSyntax) { + console.log(`Command: ${command.name}, Missing param: ${param.name} (placeholder: ${param.placeholder}), Syntax: ${command.syntax}`) + } + expect(paramInSyntax).toBe(true) + }) + }) + }) + + it("should show repeatable parameters with [...] in syntax", () => { + commands.forEach((command) => { + const repeatableParams = command.parameters.filter((p) => p.repeatable) + + repeatableParams.forEach((param) => { + const placeholder = param.placeholder || param.name + const hasRepeatableSyntax = + command.syntax.includes(`${placeholder} [${placeholder} ...]`) || + command.syntax.includes(`[${placeholder} ...]`) + + expect(hasRepeatableSyntax).toBe(true) + }) + }) + }) + + it("should have valid parameter types", () => { + const validTypes = ["key", "value", "option", "number", "pattern", "cursor"] + + commands.forEach((command) => { + command.parameters.forEach((param) => { + expect(validTypes).toContain(param.type) + }) + }) + }) + + it("should have proper tier distribution", () => { + const readCommands = commands.filter((c) => c.tier === "read") + const mutatingCommands = commands.filter((c) => c.tier === "mutating") + const adminCommands = commands.filter((c) => c.tier === "admin") + + // Should have commands in each tier + expect(readCommands.length).toBeGreaterThan(0) + expect(mutatingCommands.length).toBeGreaterThan(0) + expect(adminCommands.length).toBeGreaterThan(0) + + // Read tier should have the most commands (inspection/exploration) + expect(readCommands.length).toBeGreaterThanOrEqual(mutatingCommands.length) + }) + + it("should have dangerous commands in admin tier", () => { + const dangerousCommands = ["FLUSHDB", "FLUSHALL", "CONFIG SET"] + + dangerousCommands.forEach((cmdName) => { + const command = commands.find((c) => c.name === cmdName) + if (command) { + expect(command.tier).toBe("admin") + } + }) + }) + + it("should have UNLINK in mutating tier (safer than DEL)", () => { + const unlink = commands.find((c) => c.name === "UNLINK") + expect(unlink).toBeDefined() + expect(unlink?.tier).toBe("mutating") + }) + + it("should have inspection commands in read tier", () => { + const inspectionCommands = ["GET", "HGET", "LRANGE", "SMEMBERS", "ZRANGE", "TYPE", "TTL", "SCAN"] + + inspectionCommands.forEach((cmdName) => { + const command = commands.find((c) => c.name === cmdName) + if (command) { + expect(command.tier).toBe("read") + } + }) + }) + + it("should have unique command names", () => { + const names = commands.map((c) => c.name) + const uniqueNames = new Set(names) + expect(names.length).toBe(uniqueNames.size) + }) + + it("should have uppercase command names", () => { + commands.forEach((command) => { + expect(command.name).toBe(command.name.toUpperCase()) + }) + }) + + it("should have syntax starting with command name", () => { + commands.forEach((command) => { + expect(command.syntax.startsWith(command.name)).toBe(true) + }) + }) +}) diff --git a/apps/frontend/src/components/send-command/SendCommand.tsx b/apps/frontend/src/components/send-command/SendCommand.tsx index e262377c..09b00f4b 100644 --- a/apps/frontend/src/components/send-command/SendCommand.tsx +++ b/apps/frontend/src/components/send-command/SendCommand.tsx @@ -1,5 +1,5 @@ import { CopyIcon, GitCompareIcon, RotateCwIcon, SquareTerminal } from "lucide-react" -import React, { useRef, useState } from "react" +import { useState } from "react" import { useSelector } from "react-redux" import { useParams } from "react-router" import { toast } from "sonner" @@ -14,18 +14,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import DiffCommands from "@/components/send-command/DiffCommands.tsx" import Response from "@/components/send-command/Response.tsx" import { useAppDispatch } from "@/hooks/hooks.ts" +import { CommandInputWithAutocomplete } from "@/components/ui/command-input-with-autocomplete" export function SendCommand() { const dispatch = useAppDispatch() const [text, setText] = useState("") const [commandIndex, setCommandIndex] = useState(0) - const [compareWith, setCompareWith] = useState(null) + const [compareWith, setCompareWith] = useState(null) const [keysFilter, setKeysFilter] = useState("") const [historyFilter, setHistoryFilter] = useState("") const { id } = useParams() - const allCommands = useSelector(selectAllCommands(id as string)) || [] + const allCommands = (useSelector(selectAllCommands(id as string)) || []) as CommandMetadata[] const { error, response } = useSelector(getNth(commandIndex, id as string)) as CommandMetadata const onSubmit = (command?: string) => { @@ -34,26 +35,12 @@ export function SendCommand() { setText("") } - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - if (text.trim().length > 0) { - onSubmit() - } - } else if (e.key === "Escape") { - e.preventDefault() - setText("") - } - } - - const canDiff = (index) => { // can diff only the same command, i.e. info vs info + const canDiff = (index: number) => { const currentCommand = allCommands[commandIndex] const targetCommand = allCommands[index] return currentCommand.command.toLowerCase() === targetCommand.command.toLowerCase() } - const textareaRef = useRef(null as HTMLTextAreaElement) - return ( -
-