Skip to content

Commit 74566e8

Browse files
committed
Support for custom slash commands
1 parent 6216075 commit 74566e8

32 files changed

+1087
-51
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect } from "vitest"
2+
import { getCommands, getCommand, getCommandNames } from "../services/command/commands"
3+
import * as path from "path"
4+
5+
describe("Command Integration Tests", () => {
6+
const testWorkspaceDir = path.join(__dirname, "../../")
7+
8+
it("should discover command files in .roo/commands/", async () => {
9+
const commands = await getCommands(testWorkspaceDir)
10+
11+
// Should be able to discover commands (may be empty in test environment)
12+
expect(Array.isArray(commands)).toBe(true)
13+
14+
// If commands exist, verify they have valid properties
15+
commands.forEach((command) => {
16+
expect(command.name).toBeDefined()
17+
expect(typeof command.name).toBe("string")
18+
expect(command.source).toMatch(/^(project|global)$/)
19+
expect(command.content).toBeDefined()
20+
expect(typeof command.content).toBe("string")
21+
})
22+
})
23+
24+
it("should return command names correctly", async () => {
25+
const commandNames = await getCommandNames(testWorkspaceDir)
26+
27+
// Should return an array (may be empty in test environment)
28+
expect(Array.isArray(commandNames)).toBe(true)
29+
30+
// If command names exist, they should be strings
31+
commandNames.forEach((name) => {
32+
expect(typeof name).toBe("string")
33+
expect(name.length).toBeGreaterThan(0)
34+
})
35+
})
36+
37+
it("should load command content if commands exist", async () => {
38+
const commands = await getCommands(testWorkspaceDir)
39+
40+
if (commands.length > 0) {
41+
const firstCommand = commands[0]
42+
const loadedCommand = await getCommand(testWorkspaceDir, firstCommand.name)
43+
44+
expect(loadedCommand).toBeDefined()
45+
expect(loadedCommand?.name).toBe(firstCommand.name)
46+
expect(loadedCommand?.source).toMatch(/^(project|global)$/)
47+
expect(loadedCommand?.content).toBeDefined()
48+
expect(typeof loadedCommand?.content).toBe("string")
49+
}
50+
})
51+
52+
it("should handle non-existent commands gracefully", async () => {
53+
const nonExistentCommand = await getCommand(testWorkspaceDir, "non-existent-command")
54+
expect(nonExistentCommand).toBeUndefined()
55+
})
56+
})
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest"
2+
import { parseMentions } from "../core/mentions"
3+
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
4+
import { getCommand } from "../services/command/commands"
5+
6+
// Mock the dependencies
7+
vi.mock("../services/command/commands")
8+
vi.mock("../services/browser/UrlContentFetcher")
9+
10+
const MockedUrlContentFetcher = vi.mocked(UrlContentFetcher)
11+
const mockGetCommand = vi.mocked(getCommand)
12+
13+
describe("Command Mentions", () => {
14+
let mockUrlContentFetcher: any
15+
16+
beforeEach(() => {
17+
vi.clearAllMocks()
18+
19+
// Create a mock UrlContentFetcher instance
20+
mockUrlContentFetcher = {
21+
launchBrowser: vi.fn(),
22+
urlToMarkdown: vi.fn(),
23+
closeBrowser: vi.fn(),
24+
}
25+
26+
MockedUrlContentFetcher.mockImplementation(() => mockUrlContentFetcher)
27+
})
28+
29+
// Helper function to call parseMentions with required parameters
30+
const callParseMentions = async (text: string) => {
31+
return await parseMentions(
32+
text,
33+
"/test/cwd", // cwd
34+
mockUrlContentFetcher, // urlContentFetcher
35+
undefined, // fileContextTracker
36+
undefined, // rooIgnoreController
37+
true, // showRooIgnoredFiles
38+
true, // includeDiagnosticMessages
39+
50, // maxDiagnosticMessages
40+
undefined, // maxReadFileLine
41+
)
42+
}
43+
44+
describe("parseMentions with command support", () => {
45+
it("should parse command mentions and include content", async () => {
46+
const commandContent = "# Setup Environment\n\nRun the following commands:\n```bash\nnpm install\n```"
47+
mockGetCommand.mockResolvedValue({
48+
name: "setup",
49+
content: commandContent,
50+
source: "project",
51+
filePath: "/project/.roo/commands/setup.md",
52+
})
53+
54+
const input = "/setup Please help me set up the project"
55+
const result = await callParseMentions(input)
56+
57+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
58+
expect(result).toContain('<command name="setup">')
59+
expect(result).toContain(commandContent)
60+
expect(result).toContain("</command>")
61+
expect(result).toContain("Please help me set up the project")
62+
})
63+
64+
it("should only handle command at start of message", async () => {
65+
mockGetCommand.mockResolvedValue({
66+
name: "setup",
67+
content: "# Setup instructions",
68+
source: "project",
69+
filePath: "/project/.roo/commands/setup.md",
70+
})
71+
72+
// Only the first command should be recognized
73+
const input = "/setup the project\nThen /deploy later"
74+
const result = await callParseMentions(input)
75+
76+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
77+
expect(mockGetCommand).toHaveBeenCalledTimes(1) // Only called once
78+
expect(result).toContain('<command name="setup">')
79+
expect(result).toContain("# Setup instructions")
80+
expect(result).not.toContain('<command name="deploy">') // Second command not processed
81+
})
82+
83+
it("should handle non-existent command gracefully", async () => {
84+
mockGetCommand.mockResolvedValue(undefined)
85+
86+
const input = "/nonexistent command"
87+
const result = await callParseMentions(input)
88+
89+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "nonexistent")
90+
expect(result).toContain('<command name="nonexistent">')
91+
expect(result).toContain("not found")
92+
expect(result).toContain("</command>")
93+
})
94+
95+
it("should handle command loading errors", async () => {
96+
mockGetCommand.mockRejectedValue(new Error("Failed to load command"))
97+
98+
const input = "/error-command test"
99+
const result = await callParseMentions(input)
100+
101+
expect(result).toContain('<command name="error-command">')
102+
expect(result).toContain("Error loading command")
103+
expect(result).toContain("</command>")
104+
})
105+
106+
it("should handle command names with hyphens and underscores at start", async () => {
107+
mockGetCommand.mockResolvedValue({
108+
name: "setup-dev",
109+
content: "# Dev setup",
110+
source: "project",
111+
filePath: "/project/.roo/commands/setup-dev.md",
112+
})
113+
114+
const input = "/setup-dev for the project"
115+
const result = await callParseMentions(input)
116+
117+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup-dev")
118+
expect(result).toContain('<command name="setup-dev">')
119+
expect(result).toContain("# Dev setup")
120+
})
121+
122+
it("should preserve command content formatting", async () => {
123+
const commandContent = `# Complex Command
124+
125+
## Step 1
126+
Run this command:
127+
\`\`\`bash
128+
npm install
129+
\`\`\`
130+
131+
## Step 2
132+
- Check file1.js
133+
- Update file2.ts
134+
- Test everything
135+
136+
> **Note**: This is important!`
137+
138+
mockGetCommand.mockResolvedValue({
139+
name: "complex",
140+
content: commandContent,
141+
source: "project",
142+
filePath: "/project/.roo/commands/complex.md",
143+
})
144+
145+
const input = "/complex command"
146+
const result = await callParseMentions(input)
147+
148+
expect(result).toContain('<command name="complex">')
149+
expect(result).toContain("# Complex Command")
150+
expect(result).toContain("```bash")
151+
expect(result).toContain("npm install")
152+
expect(result).toContain("- Check file1.js")
153+
expect(result).toContain("> **Note**: This is important!")
154+
expect(result).toContain("</command>")
155+
})
156+
157+
it("should handle empty command content", async () => {
158+
mockGetCommand.mockResolvedValue({
159+
name: "empty",
160+
content: "",
161+
source: "project",
162+
filePath: "/project/.roo/commands/empty.md",
163+
})
164+
165+
const input = "/empty command"
166+
const result = await callParseMentions(input)
167+
168+
expect(result).toContain('<command name="empty">')
169+
expect(result).toContain("</command>")
170+
// Should still include the command tags even with empty content
171+
})
172+
})
173+
174+
describe("command mention regex patterns", () => {
175+
it("should match valid command mention patterns at start of message", () => {
176+
const commandRegex = /^\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
177+
178+
const validPatterns = ["/setup", "/build-prod", "/test_suite", "/my-command", "/command123"]
179+
180+
validPatterns.forEach((pattern) => {
181+
const match = pattern.match(commandRegex)
182+
expect(match).toBeTruthy()
183+
expect(match![0]).toBe(pattern)
184+
})
185+
})
186+
187+
it("should not match command patterns in middle of text", () => {
188+
const commandRegex = /^\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
189+
190+
const invalidPatterns = ["Please /setup", "Run /build now", "Use /deploy here"]
191+
192+
invalidPatterns.forEach((pattern) => {
193+
const match = pattern.match(commandRegex)
194+
expect(match).toBeFalsy()
195+
})
196+
})
197+
198+
it("should NOT match commands at start of new lines", () => {
199+
const commandRegex = /^\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
200+
201+
const multilineText = "First line\n/setup the project\nAnother line\n/deploy when ready"
202+
const matches = multilineText.match(commandRegex)
203+
204+
// Should not match any commands since they're not at the very start
205+
expect(matches).toBeFalsy()
206+
})
207+
208+
it("should only match command at very start of message", () => {
209+
const commandRegex = /^\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
210+
211+
const validText = "/setup the project\nThen do other things"
212+
const matches = validText.match(commandRegex)
213+
214+
expect(matches).toBeTruthy()
215+
expect(matches).toHaveLength(1)
216+
expect(matches![0]).toBe("/setup")
217+
})
218+
219+
it("should not match invalid command patterns", () => {
220+
const commandRegex = /^\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
221+
222+
const invalidPatterns = ["/ space", "/with space", "/with/slash", "//double", "/with@symbol"]
223+
224+
invalidPatterns.forEach((pattern) => {
225+
const match = pattern.match(commandRegex)
226+
if (match) {
227+
// If it matches, it should not be the full invalid pattern
228+
expect(match[0]).not.toBe(pattern)
229+
}
230+
})
231+
})
232+
})
233+
234+
describe("command mention text transformation", () => {
235+
it("should transform command mentions at start of message", async () => {
236+
const input = "/setup the project"
237+
const result = await callParseMentions(input)
238+
239+
expect(result).toContain("Command 'setup' (see below for command content)")
240+
})
241+
242+
it("should only process first command in message", async () => {
243+
const input = "/setup the project\nThen /deploy later"
244+
const result = await callParseMentions(input)
245+
246+
expect(result).toContain("Command 'setup' (see below for command content)")
247+
expect(result).not.toContain("Command 'deploy'") // Second command not processed
248+
})
249+
250+
it("should only match commands at very start of message", async () => {
251+
// At the beginning - should match
252+
let input = "/build the project"
253+
let result = await callParseMentions(input)
254+
expect(result).toContain("Command 'build'")
255+
256+
// In the middle - should NOT match
257+
input = "Please /build and test"
258+
result = await callParseMentions(input)
259+
expect(result).not.toContain("Command 'build'")
260+
expect(result).toContain("Please /build and test") // Original text preserved
261+
262+
// At the end - should NOT match
263+
input = "Run the /build"
264+
result = await callParseMentions(input)
265+
expect(result).not.toContain("Command 'build'")
266+
expect(result).toContain("Run the /build") // Original text preserved
267+
268+
// At start of new line - should NOT match
269+
input = "Some text\n/build the project"
270+
result = await callParseMentions(input)
271+
expect(result).not.toContain("Command 'build'")
272+
expect(result).toContain("Some text\n/build the project") // Original text preserved
273+
})
274+
})
275+
})

0 commit comments

Comments
 (0)