mirror of
https://github.com/samsonjs/Peekaboo.git
synced 2026-04-21 13:55:50 +00:00
✅ **Merge Conflicts Resolved** - Merged latest changes from main branch - Resolved conflicts in docs/spec.md by keeping comprehensive specification - Added PEEKABOO_CLI_TIMEOUT environment variable documentation 🧪 **Test Suite Updates for Linux Compatibility** - Added platform-specific test skipping for Swift-dependent tests - Created tests/setup.ts for global test configuration - Updated vitest.config.ts with platform detection - Modified integration tests to skip on non-macOS platforms: - tests/integration/peekaboo-cli-integration.test.ts - tests/integration/image-tool.test.ts - tests/integration/analyze-tool.test.ts 📦 **New Test Scripts** - `npm run test:unit` - Run only unit tests (any platform) - `npm run test:typescript` - Run TypeScript tests, skip Swift (Linux-friendly) - `npm run test:typescript:watch` - Watch mode for TypeScript-only tests 🌍 **Platform Support** - **macOS**: All tests run (unit + integration + Swift) - **Linux/CI**: Only TypeScript tests run (Swift tests auto-skipped) - **Environment Variables**: - `SKIP_SWIFT_TESTS=true` - Force skip Swift tests - `CI=true` - Auto-skip Swift tests in CI 📚 **Documentation Updates** - Added comprehensive testing section to README.md - Documented platform-specific test behavior - Added environment variable documentation for test control This allows the TypeScript parts of Peekaboo to be tested on Linux while maintaining full test coverage on macOS.
324 lines
12 KiB
TypeScript
324 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
|
|
|
|
// Conditionally skip Swift-dependent tests on non-macOS platforms
|
|
const describeSwiftTests = globalThis.shouldSkipSwiftTests ? describe.skip : describe;
|
|
|
|
describeSwiftTests("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)/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);
|
|
});
|
|
});
|