mirror of
https://github.com/samsonjs/Peekaboo.git
synced 2026-04-22 14:05:58 +00:00
- Enhanced CaptureError types to include underlying system errors - Added comprehensive error logging in debug_logs for troubleshooting - Fixed duplicate error output from ApplicationFinder - Improved error details for app not found to show available applications - Updated test expectations to match new error message formats This ensures that errors from deep within ScreenCaptureKit and file operations are properly surfaced to users with full context in the debug logs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import path from "path";
|
|
import fs from "fs/promises";
|
|
import os from "os";
|
|
import { Logger } from "pino";
|
|
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import {
|
|
imageToolHandler,
|
|
listToolHandler,
|
|
imageToolSchema,
|
|
listToolSchema,
|
|
} from "../../src/tools"; // Adjusted import path for schemas
|
|
import { initializeSwiftCliPath } from "../../src/utils/peekaboo-cli";
|
|
import { Result } from "@modelcontextprotocol/sdk/types.js"; // Corrected SDK import path and type
|
|
|
|
// Define a more specific type for content items used in Peekaboo
|
|
interface PeekabooContentItem {
|
|
type: string;
|
|
text?: string;
|
|
imageUrl?: string;
|
|
data?: any;
|
|
}
|
|
|
|
interface PeekabooWindowItem {
|
|
app_name?: string; // Swift CLI might use app_name
|
|
owningApplication?: string;
|
|
kCGWindowOwnerName?: string; // For flexibility
|
|
window_title?: string; // Swift CLI might use window_title
|
|
windowName?: string;
|
|
windowID?: number; // Made optional to reflect reality
|
|
window_id?: number; // Allow for Swift CLI variant
|
|
windowLevel?: number; // Make optional
|
|
isOnScreen?: boolean; // Make optional
|
|
is_on_screen?: boolean; // Allow for Swift CLI variant
|
|
bounds?: {
|
|
// Make optional
|
|
X: number;
|
|
Y: number;
|
|
Width: number;
|
|
Height: number;
|
|
};
|
|
window_index?: number; // Added based on log
|
|
// Add any other potential fields observed from Swift CLI output
|
|
[key: string]: any; // Allow other fields to be present
|
|
}
|
|
|
|
// Ensure local TestToolResponse interface is removed or commented out
|
|
// interface TestToolResponse {
|
|
// isError?: boolean;
|
|
// content?: Array<{ type: string; text?: string; imageUrl?: string; data?: any }>;
|
|
// application_list?: Array<any>;
|
|
// saved_files?: Array<{ path: string; data?: string }>;
|
|
// _meta?: { backend_error_code?: string; [key: string]: any };
|
|
// [key: string]: any;
|
|
// }
|
|
|
|
// Initialize Swift CLI path (assuming 'peekaboo' binary is at project root)
|
|
const packageRootDir = path.resolve(__dirname, "..", ".."); // Adjust path from tests/integration to project root
|
|
initializeSwiftCliPath(packageRootDir);
|
|
|
|
const mockLogger: Logger = {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
fatal: vi.fn(),
|
|
child: vi.fn().mockReturnThis(),
|
|
flush: vi.fn(),
|
|
level: "info",
|
|
levels: { values: { info: 30 }, labels: { "30": "info" } },
|
|
} as unknown as Logger; // Still using unknown for simplicity if full mock is too verbose
|
|
|
|
describe("Swift CLI Integration Tests", () => {
|
|
describe("listToolHandler", () => {
|
|
it("should return server_status correctly", async () => {
|
|
const args = listToolSchema.parse({ item_type: "server_status" });
|
|
const response: Result = await listToolHandler(args, {
|
|
logger: mockLogger,
|
|
});
|
|
|
|
expect(response.isError).not.toBe(true);
|
|
expect(response.content).toBeDefined();
|
|
// Ensure content is an array and has at least one item before accessing it
|
|
if (
|
|
response.content &&
|
|
Array.isArray(response.content) &&
|
|
response.content.length > 0
|
|
) {
|
|
const firstContentItem = response.content[0] as PeekabooContentItem;
|
|
expect(firstContentItem.type).toBe("text");
|
|
expect(firstContentItem.text).toContain("Peekaboo MCP");
|
|
} else {
|
|
expect(
|
|
false,
|
|
"Response content was not in the expected format for server_status",
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("should call Swift CLI for running_applications and return a structured response", async () => {
|
|
const args = listToolSchema.parse({ item_type: "running_applications" });
|
|
const response: Result = await listToolHandler(args, {
|
|
logger: mockLogger,
|
|
});
|
|
|
|
if (response.isError) {
|
|
console.error(
|
|
"listToolHandler running_applications error:",
|
|
JSON.stringify(response),
|
|
);
|
|
}
|
|
expect(response.isError).not.toBe(true);
|
|
|
|
if (!response.isError) {
|
|
expect(response).toHaveProperty("application_list");
|
|
expect((response as any).application_list).toBeInstanceOf(Array);
|
|
// Optionally, check if at least one app is returned if any are expected to be running
|
|
if ((response as any).application_list.length === 0) {
|
|
console.warn(
|
|
"listToolHandler for running_applications returned an empty list.",
|
|
);
|
|
}
|
|
}
|
|
}, 15000);
|
|
|
|
it("should list windows for a known application (Finder) without details by default", async () => {
|
|
const args = listToolSchema.parse({
|
|
item_type: "application_windows",
|
|
app: "Finder",
|
|
// No include_window_details passed
|
|
});
|
|
const response: Result = await listToolHandler(args, {
|
|
logger: mockLogger,
|
|
});
|
|
|
|
if (response.isError) {
|
|
console.error(
|
|
"listToolHandler Finder windows error response:",
|
|
JSON.stringify(response),
|
|
);
|
|
}
|
|
expect(response.isError).not.toBe(true);
|
|
|
|
if (!response.isError) {
|
|
expect(response).toHaveProperty("window_list");
|
|
expect(response).toHaveProperty("target_application_info");
|
|
|
|
const targetAppInfo = (response as any).target_application_info;
|
|
expect(targetAppInfo).toBeDefined();
|
|
expect(targetAppInfo.app_name).toBe("Finder");
|
|
|
|
const windowList = (response as any)
|
|
.window_list as PeekabooWindowItem[];
|
|
expect(windowList).toBeInstanceOf(Array);
|
|
|
|
if (windowList.length > 0) {
|
|
const firstWindow = windowList[0];
|
|
// console.log('First window object from Finder (no details requested):', JSON.stringify(firstWindow, null, 2));
|
|
expect(firstWindow).toHaveProperty("window_title"); // Expect basic info
|
|
expect(firstWindow).toHaveProperty("window_index"); // Expect basic info
|
|
// Should NOT have detailed info unless requested
|
|
expect(firstWindow.windowID).toBeUndefined();
|
|
expect(firstWindow.window_id).toBeUndefined();
|
|
expect(firstWindow.bounds).toBeUndefined();
|
|
} else {
|
|
console.warn(
|
|
"listToolHandler for Finder windows returned an empty list. This might be normal.",
|
|
);
|
|
}
|
|
}
|
|
}, 15000);
|
|
|
|
it("should return an error when listing windows for a non-existent application", async () => {
|
|
const nonExistentApp = "DefinitelyNotAnApp123ABC";
|
|
const args = listToolSchema.parse({
|
|
item_type: "application_windows",
|
|
app: nonExistentApp,
|
|
});
|
|
const response: Result = await listToolHandler(args, {
|
|
logger: mockLogger,
|
|
});
|
|
|
|
expect(response.isError).toBe(true);
|
|
if (
|
|
response.content &&
|
|
Array.isArray(response.content) &&
|
|
response.content.length > 0
|
|
) {
|
|
const firstContentItem = response.content[0] as PeekabooContentItem;
|
|
// Expect the specific failure message from the handler when Swift CLI fails
|
|
expect(firstContentItem.text?.toLowerCase()).toMatch(
|
|
/list operation failed: (swift cli execution failed|an unknown error occurred|.*could not be found|no running applications found matching identifier|application with identifier.*not found or is not running)/i,
|
|
);
|
|
}
|
|
}, 15000);
|
|
|
|
describe("List Tool Leniency", () => {
|
|
it("should default to 'running_applications' when item_type is empty", async () => {
|
|
const args = listToolSchema.parse({ item_type: "" });
|
|
const response: Result = await listToolHandler(args, { logger: mockLogger });
|
|
expect(response.isError).not.toBe(true);
|
|
expect((response as any).application_list).toBeInstanceOf(Array);
|
|
});
|
|
|
|
it("should default to 'running_applications' when no args are provided", async () => {
|
|
const args = listToolSchema.parse({});
|
|
const response: Result = await listToolHandler(args, { logger: mockLogger });
|
|
expect(response.isError).not.toBe(true);
|
|
expect((response as any).application_list).toBeInstanceOf(Array);
|
|
});
|
|
|
|
it("should default to 'application_windows' when only 'app' is provided", async () => {
|
|
const args = listToolSchema.parse({ app: "Finder" });
|
|
const response: Result = await listToolHandler(args, { logger: mockLogger });
|
|
expect(response.isError).not.toBe(true);
|
|
expect((response as any).window_list).toBeInstanceOf(Array);
|
|
expect((response as any).target_application_info.app_name).toBe("Finder");
|
|
});
|
|
|
|
it("should default to 'application_windows' when item_type is empty and 'app' is provided", async () => {
|
|
const args = listToolSchema.parse({ item_type: "", app: "Finder" });
|
|
const response: Result = await listToolHandler(args, { logger: mockLogger });
|
|
expect(response.isError).not.toBe(true);
|
|
expect((response as any).window_list).toBeInstanceOf(Array);
|
|
expect((response as any).target_application_info.app_name).toBe("Finder");
|
|
});
|
|
|
|
it("should default to 'application_windows' and accept details when only 'app' and 'details' are provided", async () => {
|
|
const args = listToolSchema.parse({
|
|
app: "Finder",
|
|
include_window_details: ["bounds", "ids"],
|
|
});
|
|
const response: Result = await listToolHandler(args, { logger: mockLogger });
|
|
|
|
expect(response.isError).not.toBe(true);
|
|
const windowList = (response as any).window_list;
|
|
expect(windowList).toBeInstanceOf(Array);
|
|
if (windowList.length > 0) {
|
|
expect(windowList[0]).toHaveProperty("bounds");
|
|
expect(windowList[0]).toHaveProperty("window_id");
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("imageToolHandler", () => {
|
|
let tempImagePath: string;
|
|
|
|
beforeEach(() => {
|
|
tempImagePath = path.join(
|
|
os.tmpdir(),
|
|
`peekaboo-test-image-${Date.now()}.png`,
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await fs.unlink(tempImagePath);
|
|
} catch (error) {
|
|
// Ignore
|
|
}
|
|
});
|
|
|
|
it("should attempt to capture screen and save to a file", async () => {
|
|
const args = imageToolSchema.parse({
|
|
mode: "screen",
|
|
path: tempImagePath,
|
|
format: "png",
|
|
return_data: false,
|
|
});
|
|
const response: Result = await imageToolHandler(args, {
|
|
logger: mockLogger,
|
|
});
|
|
|
|
if (response.isError) {
|
|
let errorText = "";
|
|
if (
|
|
response.content &&
|
|
Array.isArray(response.content) &&
|
|
response.content.length > 0
|
|
) {
|
|
const firstContentItem = response.content[0] as PeekabooContentItem;
|
|
errorText = firstContentItem.text?.toLowerCase() ?? "";
|
|
}
|
|
const metaErrorCode = (response._meta as any)?.backend_error_code;
|
|
// console.log('Image tool error response:', JSON.stringify(response));
|
|
|
|
expect(
|
|
errorText.includes("permission") ||
|
|
errorText.includes("denied") ||
|
|
metaErrorCode === "PERMISSION_DENIED_SCREEN_RECORDING" ||
|
|
errorText.includes("capture failed"),
|
|
).toBeTruthy();
|
|
|
|
await expect(fs.access(tempImagePath)).rejects.toThrow();
|
|
} else {
|
|
expect(response.isError).toBeUndefined();
|
|
expect(response).toHaveProperty("saved_files");
|
|
const successResponse = response as Result & {
|
|
saved_files?: { path: string }[];
|
|
};
|
|
expect(successResponse.saved_files).toBeInstanceOf(Array);
|
|
if (
|
|
successResponse.saved_files &&
|
|
successResponse.saved_files.length > 0
|
|
) {
|
|
// With new path handling, the CLI appends screen identifiers for multiple screen capture
|
|
// The actual path will be something like tempImagePath with _1_timestamp added
|
|
const actualPath = successResponse.saved_files[0]?.path;
|
|
expect(actualPath).toBeDefined();
|
|
// Check that the path starts with the base path (without extension) and ends with .png
|
|
const basePath = tempImagePath.replace(/\.png$/, '');
|
|
expect(actualPath).toMatch(new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_\\d+_\\d{8}_\\d{6}\\.png$`));
|
|
|
|
// Verify the actual file exists at the returned path
|
|
await expect(fs.access(actualPath!)).resolves.toBeUndefined();
|
|
}
|
|
}
|
|
}, 20000);
|
|
});
|
|
});
|