Peekaboo/tests/unit/tools/multiple-app-matches.test.ts
Peter Steinberger 4afd15279c feat: Capture all windows from multiple exact app matches instead of erroring
When multiple applications have exact matches (e.g., "claude" and "Claude"), the system now:
- Captures all windows from all matching applications instead of throwing an ambiguous match error
- Maintains sequential window indices across all matched applications
- Preserves original application names in saved file metadata
- Only returns errors for truly ambiguous fuzzy matches

This provides more useful behavior for common scenarios where users have multiple apps with
similar names (different case, etc.) and want to capture windows from all of them.

Updates:
- Added `captureWindowsFromMultipleApps` method to handle multi-app capture logic
- Modified error handling in both single window and multi-window capture modes
- Updated documentation (spec.md, CHANGELOG.md) to reflect new behavior
- Comprehensive test suite covering various multiple match scenarios

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-08 08:00:44 +01:00

315 lines
No EOL
9.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { imageToolHandler } from "../../../src/tools/image";
import { executeSwiftCli } from "../../../src/utils/peekaboo-cli";
import { resolveImagePath } from "../../../src/utils/image-cli-args";
import { mockSwiftCli } from "../../mocks/peekaboo-cli.mock";
import { pino } from "pino";
// Mock the Swift CLI utility
vi.mock("../../../src/utils/peekaboo-cli");
// Mock image-cli-args module
vi.mock("../../../src/utils/image-cli-args", async () => {
const actual = await vi.importActual("../../../src/utils/image-cli-args");
return {
...actual,
resolveImagePath: vi.fn(),
};
});
const mockExecuteSwiftCli = executeSwiftCli as vi.MockedFunction<typeof executeSwiftCli>;
const mockResolveImagePath = resolveImagePath as vi.MockedFunction<typeof resolveImagePath>;
const mockLogger = pino({ level: "silent" });
const mockContext = { logger: mockLogger };
const MOCK_TEMP_DIR = "/tmp/peekaboo-img-XXXXXX";
describe("Multiple App Matches", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveImagePath.mockResolvedValue({
effectivePath: MOCK_TEMP_DIR,
tempDirUsed: MOCK_TEMP_DIR,
});
});
it("should capture all windows when multiple exact app matches exist", async () => {
// Simulate capturing "claude" when both "claude" and "Claude" apps exist
const mockMultipleAppResponse = {
success: true,
data: {
saved_files: [
{
path: "/tmp/claude_window_0_20250608_120000.png",
item_label: "claude",
window_title: "Chat - Claude",
window_id: 1001,
window_index: 0,
mime_type: "image/png"
},
{
path: "/tmp/claude_window_1_20250608_120001.png",
item_label: "claude",
window_title: "Settings - Claude",
window_id: 1002,
window_index: 1,
mime_type: "image/png"
},
{
path: "/tmp/Claude_window_2_20250608_120002.png",
item_label: "Claude",
window_title: "Main Window - Claude",
window_id: 2001,
window_index: 2,
mime_type: "image/png"
},
{
path: "/tmp/Claude_window_3_20250608_120003.png",
item_label: "Claude",
window_title: "Preferences - Claude",
window_id: 2002,
window_index: 3,
mime_type: "image/png"
}
]
},
messages: [],
debug_logs: ["Multiple applications match 'claude', capturing all windows from all matches"]
};
mockExecuteSwiftCli.mockResolvedValue(mockMultipleAppResponse);
const input = {
app_target: "claude",
format: "png" as const
};
const result = await imageToolHandler(input, mockContext);
// Should succeed and return all windows from both apps
expect(result.isError).toBeUndefined();
expect(result.saved_files).toHaveLength(4);
// Verify we got windows from both "claude" and "Claude" apps
const claudeLowerItems = result.saved_files?.filter(f => f.item_label === "claude") || [];
const claudeUpperItems = result.saved_files?.filter(f => f.item_label === "Claude") || [];
expect(claudeLowerItems).toHaveLength(2);
expect(claudeUpperItems).toHaveLength(2);
// Verify all windows have sequential indices
const windowIndices = result.saved_files?.map(f => f.window_index).sort() || [];
expect(windowIndices).toEqual([0, 1, 2, 3]);
// Should have called Swift CLI with the app name
expect(mockExecuteSwiftCli).toHaveBeenCalledWith(
expect.arrayContaining(["--app", "claude"]),
mockLogger,
expect.any(Object)
);
});
it("should handle single window mode with multiple app matches", async () => {
// When using window mode (not multi mode), should still capture from all matching apps
const mockSingleWindowResponse = {
success: true,
data: {
saved_files: [
{
path: "/tmp/claude_Chat_20250608_120000.png",
item_label: "claude",
window_title: "Chat - Claude",
window_id: 1001,
window_index: 0,
mime_type: "image/png"
},
{
path: "/tmp/Claude_Main_20250608_120001.png",
item_label: "Claude",
window_title: "Main Window - Claude",
window_id: 2001,
window_index: 1,
mime_type: "image/png"
}
]
},
messages: [],
debug_logs: ["Multiple applications match 'claude', capturing all windows from all matches"]
};
mockExecuteSwiftCli.mockResolvedValue(mockSingleWindowResponse);
const input = {
app_target: "claude:WINDOW_INDEX:0", // Requesting specific window but multiple apps match
format: "png" as const
};
const result = await imageToolHandler(input, mockContext);
expect(result.isError).toBeUndefined();
expect(result.saved_files).toHaveLength(2);
// Should have called Swift CLI with specific window parameters
expect(mockExecuteSwiftCli).toHaveBeenCalledWith(
expect.arrayContaining(["--app", "claude", "--window-index", "0"]),
mockLogger,
expect.any(Object)
);
});
it("should handle case where some matching apps have no windows", async () => {
const mockPartialWindowResponse = {
success: true,
data: {
saved_files: [
{
path: "/tmp/Claude_window_0_20250608_120000.png",
item_label: "Claude",
window_title: "Main Window - Claude",
window_id: 2001,
window_index: 0,
mime_type: "image/png"
}
]
},
messages: [],
debug_logs: [
"Multiple applications match 'claude', capturing all windows from all matches",
"No windows found for app: claude"
]
};
mockExecuteSwiftCli.mockResolvedValue(mockPartialWindowResponse);
const input = {
app_target: "claude",
format: "png" as const
};
const result = await imageToolHandler(input, mockContext);
expect(result.isError).toBeUndefined();
expect(result.saved_files).toHaveLength(1);
expect(result.saved_files?.[0].item_label).toBe("Claude");
});
it("should handle case where no matching apps have windows", async () => {
const mockNoWindowsResponse = {
success: false,
error: {
message: "No windows found for any matching applications of 'claude'",
code: "WINDOW_NOT_FOUND",
details: "No windows found for any matching applications"
}
};
mockExecuteSwiftCli.mockResolvedValue(mockNoWindowsResponse);
const input = {
app_target: "claude",
format: "png" as const
};
const result = await imageToolHandler(input, mockContext);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("No windows found for any matching applications of 'claude'");
});
it("should maintain proper file naming for multiple apps", async () => {
const mockNamingResponse = {
success: true,
data: {
saved_files: [
{
path: "/tmp/VSCode_window_0_20250608_120000.png",
item_label: "Visual Studio Code",
window_title: "main.ts - peekaboo",
window_id: 3001,
window_index: 0,
mime_type: "image/png"
},
{
path: "/tmp/vscode_window_1_20250608_120001.png",
item_label: "vscode",
window_title: "Extension Host",
window_id: 4001,
window_index: 1,
mime_type: "image/png"
}
]
}
};
mockExecuteSwiftCli.mockResolvedValue(mockNamingResponse);
const input = {
app_target: "vscode", // Matches both "Visual Studio Code" and "vscode"
format: "png" as const
};
const result = await imageToolHandler(input, mockContext);
expect(result.isError).toBeUndefined();
expect(result.saved_files).toHaveLength(2);
// Verify proper naming conventions are maintained
const file1 = result.saved_files?.[0];
const file2 = result.saved_files?.[1];
expect(file1?.path).toContain("VSCode_window_0");
expect(file2?.path).toContain("vscode_window_1");
// Verify sequential indexing across apps
expect(file1?.window_index).toBe(0);
expect(file2?.window_index).toBe(1);
});
it("should preserve individual app identification in saved files", async () => {
const mockAppIdResponse = {
success: true,
data: {
saved_files: [
{
path: "/tmp/finder_window_0_20250608_120000.png",
item_label: "Finder",
window_title: "Desktop",
window_id: 5001,
window_index: 0,
mime_type: "image/png"
},
{
path: "/tmp/FINDER_window_1_20250608_120001.png",
item_label: "FINDER",
window_title: "Applications",
window_id: 5002,
window_index: 1,
mime_type: "image/png"
}
]
}
};
mockExecuteSwiftCli.mockResolvedValue(mockAppIdResponse);
const input = {
app_target: "finder",
format: "png" as const
};
const result = await imageToolHandler(input, mockContext);
expect(result.isError).toBeUndefined();
expect(result.saved_files).toHaveLength(2);
// Each saved file should preserve its source app's actual name
expect(result.saved_files?.[0].item_label).toBe("Finder");
expect(result.saved_files?.[1].item_label).toBe("FINDER");
// But window indices should be sequential across all matches
expect(result.saved_files?.[0].window_index).toBe(0);
expect(result.saved_files?.[1].window_index).toBe(1);
});
});