From dd680eb638f30f9239d2380513ca5b01ed12a843 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Jun 2025 08:09:47 +0100 Subject: [PATCH] feat: Improve window title matching and error messages for URLs with ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users search for windows with URLs containing ports (e.g., 'http://example.com:8080'), the system now provides much better debugging information when the window isn't found. Key improvements: - Enhanced window not found errors now list all available window titles - Added specific guidance for URL-based searches (try without protocol) - New CaptureError.windowTitleNotFound with detailed debugging info - Comprehensive test coverage for colon parsing in app targets - Better error messages help users understand why matching failed Example improved error: "Window with title containing 'http://example.com:8080' not found in Google Chrome. Available windows: 'example.com:8080 - Google Chrome', 'New Tab - Google Chrome'. Note: For URLs, try without the protocol (e.g., 'example.com:8080' instead of 'http://example.com:8080')." This addresses the common issue where browsers display simplified URLs in window titles without the protocol, making it easier for users to find the correct matching pattern. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../Sources/peekaboo/ImageCommand.swift | 21 +- peekaboo-cli/Sources/peekaboo/Models.swift | 9 + .../improved-window-error-messages.test.ts | 223 ++++++++++++++++++ .../unit/tools/window-title-matching.test.ts | 181 ++++++++++++++ tests/unit/utils/colon-parsing.test.ts | 116 +++++++++ 5 files changed, 540 insertions(+), 10 deletions(-) create mode 100644 tests/unit/tools/improved-window-error-messages.test.ts create mode 100644 tests/unit/tools/window-title-matching.test.ts create mode 100644 tests/unit/utils/colon-parsing.test.ts diff --git a/peekaboo-cli/Sources/peekaboo/ImageCommand.swift b/peekaboo-cli/Sources/peekaboo/ImageCommand.swift index a55cc2c..012c942 100644 --- a/peekaboo-cli/Sources/peekaboo/ImageCommand.swift +++ b/peekaboo-cli/Sources/peekaboo/ImageCommand.swift @@ -302,7 +302,14 @@ struct ImageCommand: ParsableCommand { let targetWindow: WindowData if let windowTitle { guard let window = windows.first(where: { $0.title.contains(windowTitle) }) else { - throw CaptureError.windowNotFound + // Create detailed error message with available window titles for debugging + let availableTitles = windows.map { "\"\($0.title)\"" }.joined(separator: ", ") + let searchTerm = windowTitle + let appName = targetApp.localizedName ?? "Unknown" + + Logger.shared.debug("Window not found. Searched for '\(searchTerm)' in \(appName). Available windows: \(availableTitles)") + + throw CaptureError.windowTitleNotFound(searchTerm, appName, availableTitles) } targetWindow = window } else if let windowIndex { @@ -314,7 +321,9 @@ struct ImageCommand: ParsableCommand { targetWindow = windows[0] // frontmost window } - let fileName = FileNameGenerator.generateFileName(appName: targetApp.localizedName, windowTitle: targetWindow.title, format: format) + let fileName = FileNameGenerator.generateFileName( + appName: targetApp.localizedName, windowTitle: targetWindow.title, format: format + ) let filePath = OutputPathResolver.getOutputPath(basePath: path, fileName: fileName) try captureWindow(targetWindow, to: filePath) @@ -463,7 +472,6 @@ struct ImageCommand: ParsableCommand { } } - private func captureWindow(_ window: WindowData, to path: String) throws(CaptureError) { do { let semaphore = DispatchSemaphore(value: 0) @@ -494,11 +502,4 @@ struct ImageCommand: ParsableCommand { throw CaptureError.windowCaptureFailed(error) } } - - - - - - - } diff --git a/peekaboo-cli/Sources/peekaboo/Models.swift b/peekaboo-cli/Sources/peekaboo/Models.swift index 55964c1..1df15ec 100644 --- a/peekaboo-cli/Sources/peekaboo/Models.swift +++ b/peekaboo-cli/Sources/peekaboo/Models.swift @@ -107,6 +107,7 @@ enum CaptureError: Error, LocalizedError { case invalidDisplayID case captureCreationFailed(Error?) case windowNotFound + case windowTitleNotFound(String, String, String) // searchTerm, appName, availableTitles case windowCaptureFailed(Error?) case fileWriteError(String, Error?) case appNotFound(String) @@ -135,6 +136,13 @@ enum CaptureError: Error, LocalizedError { return message case .windowNotFound: return "The specified window could not be found." + case let .windowTitleNotFound(searchTerm, appName, availableTitles): + var message = "Window with title containing '\(searchTerm)' not found in \(appName)." + if !availableTitles.isEmpty { + message += " Available windows: \(availableTitles)." + } + message += " Note: For URLs, try without the protocol (e.g., 'example.com:8080' instead of 'http://example.com:8080')." + return message case let .windowCaptureFailed(underlyingError): var message = "Failed to capture the specified window." if let error = underlyingError { @@ -182,6 +190,7 @@ enum CaptureError: Error, LocalizedError { case .invalidDisplayID: 13 case .captureCreationFailed: 14 case .windowNotFound: 15 + case .windowTitleNotFound: 21 case .windowCaptureFailed: 16 case .fileWriteError: 17 case .appNotFound: 18 diff --git a/tests/unit/tools/improved-window-error-messages.test.ts b/tests/unit/tools/improved-window-error-messages.test.ts new file mode 100644 index 0000000..210daa7 --- /dev/null +++ b/tests/unit/tools/improved-window-error-messages.test.ts @@ -0,0 +1,223 @@ +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 { 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; +const mockResolveImagePath = resolveImagePath as vi.MockedFunction; + +const mockLogger = pino({ level: "silent" }); +const mockContext = { logger: mockLogger }; + +describe("Improved Window Error Messages", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockResolveImagePath.mockResolvedValue({ + effectivePath: "/tmp/test", + tempDirUsed: undefined, + }); + }); + + it("should provide helpful error message with available window titles when window not found", async () => { + // Mock detailed window not found error with available titles + const mockDetailedWindowNotFoundResponse = { + success: false, + error: { + message: "Window with title containing 'http://example.com:8080' not found in Google Chrome. Available windows: \"example.com:8080 - Google Chrome\", \"New Tab - Google Chrome\". Note: For URLs, try without the protocol (e.g., 'example.com:8080' instead of 'http://example.com:8080').", + code: "WINDOW_NOT_FOUND", + details: "Window title matching failed with suggested alternatives" + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockDetailedWindowNotFoundResponse); + + const input = { + app_target: "Google Chrome:WINDOW_TITLE:http://example.com:8080", + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + // Should fail with detailed error message + expect(result.isError).toBe(true); + + const errorText = result.content[0].text; + expect(errorText).toContain("Window with title containing 'http://example.com:8080' not found"); + expect(errorText).toContain("Available windows:"); + expect(errorText).toContain("example.com:8080 - Google Chrome"); + expect(errorText).toContain("New Tab - Google Chrome"); + expect(errorText).toContain("try without the protocol"); + expect(errorText).toContain("'example.com:8080' instead of 'http://example.com:8080'"); + }); + + it("should handle case where app has no windows matching title", async () => { + const mockNoMatchingWindowsResponse = { + success: false, + error: { + message: "Window with title containing 'nonexistent-page' not found in Safari. Available windows: \"Apple - Google Search - Safari\", \"GitHub - Safari\". Note: For URLs, try without the protocol (e.g., 'example.com:8080' instead of 'http://example.com:8080').", + code: "WINDOW_NOT_FOUND", + details: "No windows match the specified title" + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockNoMatchingWindowsResponse); + + const input = { + app_target: "Safari:WINDOW_TITLE:nonexistent-page", + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + expect(result.isError).toBe(true); + + const errorText = result.content[0].text; + expect(errorText).toContain("Window with title containing 'nonexistent-page' not found in Safari"); + expect(errorText).toContain("Available windows:"); + expect(errorText).toContain("Apple - Google Search - Safari"); + expect(errorText).toContain("GitHub - Safari"); + }); + + it("should provide guidance for URL-based searches", async () => { + const mockURLGuidanceResponse = { + success: false, + error: { + message: "Window with title containing 'https://localhost:3000/app' not found in Firefox. Available windows: \"localhost:3000/app - Mozilla Firefox\", \"about:blank - Mozilla Firefox\". Note: For URLs, try without the protocol (e.g., 'example.com:8080' instead of 'http://example.com:8080').", + code: "WINDOW_NOT_FOUND", + details: "URL matching guidance provided" + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockURLGuidanceResponse); + + const input = { + app_target: "Firefox:WINDOW_TITLE:https://localhost:3000/app", + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + expect(result.isError).toBe(true); + + const errorText = result.content[0].text; + expect(errorText).toContain("localhost:3000/app - Mozilla Firefox"); + expect(errorText).toContain("Note: For URLs, try without the protocol"); + }); + + it("should handle case where no similar windows exist", async () => { + const mockNoSimilarWindowsResponse = { + success: false, + error: { + message: "Window with title containing 'very-specific-search' not found in Code. Available windows: \"ImageCommand.swift - peekaboo\", \"main.swift - peekaboo\". Note: For URLs, try without the protocol (e.g., 'example.com:8080' instead of 'http://example.com:8080').", + code: "WINDOW_NOT_FOUND", + details: "No similar windows found" + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockNoSimilarWindowsResponse); + + const input = { + app_target: "Code:WINDOW_TITLE:very-specific-search", + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + expect(result.isError).toBe(true); + + const errorText = result.content[0].text; + expect(errorText).toContain("Window with title containing 'very-specific-search' not found in Code"); + expect(errorText).toContain("ImageCommand.swift - peekaboo"); + expect(errorText).toContain("main.swift - peekaboo"); + }); + + it("should handle successful window matching after applying guidance", async () => { + // Test successful case when user follows the guidance + const mockSuccessfulMatchResponse = { + success: true, + data: { + saved_files: [ + { + path: "/tmp/chrome_window.png", + item_label: "Google Chrome", + window_title: "example.com:8080 - Google Chrome", + window_id: 12345, + window_index: 0, + mime_type: "image/png" + } + ] + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockSuccessfulMatchResponse); + + const input = { + app_target: "Google Chrome:WINDOW_TITLE:example.com:8080", + 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].window_title).toBe("example.com:8080 - Google Chrome"); + }); + + it("should provide appropriate guidance for different URL patterns", async () => { + const urlPatterns = [ + { + input: "http://localhost:8080", + suggestion: "localhost:8080" + }, + { + input: "https://api.example.com:443/v1", + suggestion: "api.example.com:443/v1" + }, + { + input: "ftp://files.example.com:21", + suggestion: "files.example.com:21" + } + ]; + + for (const pattern of urlPatterns) { + vi.clearAllMocks(); + + const mockResponse = { + success: false, + error: { + message: `Window with title containing '${pattern.input}' not found in Browser. Available windows: \"${pattern.suggestion} - Browser\". Note: For URLs, try without the protocol (e.g., 'example.com:8080' instead of 'http://example.com:8080').`, + code: "WINDOW_NOT_FOUND", + details: "URL pattern guidance" + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockResponse); + + const input = { + app_target: `Browser:WINDOW_TITLE:${pattern.input}`, + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain(`Window with title containing '${pattern.input}' not found`); + expect(result.content[0].text).toContain(`${pattern.suggestion} - Browser`); + expect(result.content[0].text).toContain("try without the protocol"); + } + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/window-title-matching.test.ts b/tests/unit/tools/window-title-matching.test.ts new file mode 100644 index 0000000..cb4585c --- /dev/null +++ b/tests/unit/tools/window-title-matching.test.ts @@ -0,0 +1,181 @@ +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 { 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; +const mockResolveImagePath = resolveImagePath as vi.MockedFunction; + +const mockLogger = pino({ level: "silent" }); +const mockContext = { logger: mockLogger }; + +describe("Window Title Matching Issues", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockResolveImagePath.mockResolvedValue({ + effectivePath: "/tmp/test", + tempDirUsed: undefined, + }); + }); + + it("should handle window not found error for URL-based titles", async () => { + // Mock the exact scenario from the issue - window not found + const mockWindowNotFoundResponse = { + success: false, + error: { + message: "The specified window could not be found.", + code: "WINDOW_NOT_FOUND", + details: "Window matching criteria was not found" + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockWindowNotFoundResponse); + + const input = { + app_target: "Google Chrome:WINDOW_TITLE:http://example.com:8080", + path: "/tmp/multiple_colons.png", + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + // Should fail with window not found error + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("The specified window could not be found"); + expect(result._meta?.backend_error_code).toBe("WINDOW_NOT_FOUND"); + + // Verify correct arguments were passed to Swift CLI + expect(mockExecuteSwiftCli).toHaveBeenCalledWith( + expect.arrayContaining([ + "--app", "Google Chrome", + "--mode", "window", + "--window-title", "http://example.com:8080" + ]), + mockLogger, + expect.any(Object) + ); + }); + + it("should suggest debugging when window title matching fails", async () => { + // Test that includes some debugging suggestions in the response + const mockWindowNotFoundWithDetails = { + success: false, + error: { + message: "The specified window could not be found.", + code: "WINDOW_NOT_FOUND", + details: "Window with title containing 'http://example.com:8080' not found in Google Chrome" + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockWindowNotFoundWithDetails); + + const input = { + app_target: "Google Chrome:WINDOW_TITLE:http://example.com:8080", + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("The specified window could not be found"); + }); + + it("should handle successful window matching with URLs", async () => { + // Test successful case where the window IS found + const mockSuccessResponse = { + success: true, + data: { + saved_files: [ + { + path: "/tmp/chrome_window.png", + item_label: "Google Chrome", + window_title: "example.com:8080 - Google Chrome", + window_id: 12345, + window_index: 0, + mime_type: "image/png" + } + ] + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockSuccessResponse); + + const input = { + app_target: "Google Chrome:WINDOW_TITLE:example.com:8080", + 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].window_title).toContain("example.com:8080"); + }); + + it("should demonstrate different URL formats that might appear in window titles", async () => { + // Various formats Chrome might show in window titles + const urlFormats = [ + "http://example.com:8080", + "example.com:8080", + "localhost:8080", + "127.0.0.1:8080", + "https://example.com:8443/path" + ]; + + for (const urlFormat of urlFormats) { + vi.clearAllMocks(); + + const mockResponse = { + success: true, + data: { + saved_files: [ + { + path: `/tmp/window_${urlFormat.replace(/[:/]/g, '_')}.png`, + item_label: "Browser", + window_title: `${urlFormat} - Browser`, + window_id: 123, + window_index: 0, + mime_type: "image/png" + } + ] + } + }; + + mockExecuteSwiftCli.mockResolvedValue(mockResponse); + + const input = { + app_target: `Browser:WINDOW_TITLE:${urlFormat}`, + format: "png" as const + }; + + const result = await imageToolHandler(input, mockContext); + + // Should succeed for all URL formats + expect(result.isError).toBeUndefined(); + + // Verify correct arguments were passed + expect(mockExecuteSwiftCli).toHaveBeenCalledWith( + expect.arrayContaining([ + "--app", "Browser", + "--window-title", urlFormat + ]), + mockLogger, + expect.any(Object) + ); + } + }); +}); \ No newline at end of file diff --git a/tests/unit/utils/colon-parsing.test.ts b/tests/unit/utils/colon-parsing.test.ts new file mode 100644 index 0000000..7fb894e --- /dev/null +++ b/tests/unit/utils/colon-parsing.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { buildSwiftCliArgs } from "../../../src/utils/image-cli-args"; + +describe("App Target Colon Parsing", () => { + it("should correctly parse window title with URLs containing ports", () => { + const input = { + app_target: "Google Chrome:WINDOW_TITLE:http://example.com:8080", + format: "png" as const + }; + + const args = buildSwiftCliArgs(input, "/tmp/test.png"); + + // Should contain the correct app name + expect(args).toContain("--app"); + const appIndex = args.indexOf("--app"); + expect(args[appIndex + 1]).toBe("Google Chrome"); + + // Should be in window mode + expect(args).toContain("--mode"); + const modeIndex = args.indexOf("--mode"); + expect(args[modeIndex + 1]).toBe("window"); + + // Should contain the window title argument with the full URL including port + expect(args).toContain("--window-title"); + const titleIndex = args.indexOf("--window-title"); + expect(args[titleIndex + 1]).toBe("http://example.com:8080"); + }); + + it("should handle URLs with multiple colons correctly", () => { + const input = { + app_target: "Safari:WINDOW_TITLE:https://user:pass@example.com:8443/path?param=value", + format: "png" as const + }; + + const args = buildSwiftCliArgs(input, "/tmp/test.png"); + + expect(args).toContain("--window-title"); + const titleIndex = args.indexOf("--window-title"); + expect(args[titleIndex + 1]).toBe("https://user:pass@example.com:8443/path?param=value"); + }); + + it("should handle window titles with colons in file paths", () => { + const input = { + app_target: "TextEdit:WINDOW_TITLE:C:\\Users\\test\\file.txt", + format: "png" as const + }; + + const args = buildSwiftCliArgs(input, "/tmp/test.png"); + + expect(args).toContain("--window-title"); + const titleIndex = args.indexOf("--window-title"); + expect(args[titleIndex + 1]).toBe("C:\\Users\\test\\file.txt"); + }); + + it("should handle simple window titles without additional colons", () => { + const input = { + app_target: "TextEdit:WINDOW_TITLE:My Document.txt", + format: "png" as const + }; + + const args = buildSwiftCliArgs(input, "/tmp/test.png"); + + expect(args).toContain("--window-title"); + const titleIndex = args.indexOf("--window-title"); + expect(args[titleIndex + 1]).toBe("My Document.txt"); + }); + + it("should handle window index correctly (no colons in value)", () => { + const input = { + app_target: "Google Chrome:WINDOW_INDEX:0", + format: "png" as const + }; + + const args = buildSwiftCliArgs(input, "/tmp/test.png"); + + expect(args).toContain("--window-index"); + const indexIdx = args.indexOf("--window-index"); + expect(args[indexIdx + 1]).toBe("0"); + }); + + it("should handle colons in app names gracefully", () => { + // Edge case: what if app name itself contains colons? + const input = { + app_target: "App:Name:WINDOW_TITLE:Title", + format: "png" as const + }; + + const args = buildSwiftCliArgs(input, "/tmp/test.png"); + + // This case is ambiguous - current logic takes first part as app name + expect(args).toContain("--app"); + const appIndex = args.indexOf("--app"); + expect(args[appIndex + 1]).toBe("App"); + + // "Name" is not a valid specifier, so no window-specific flags should be added + // It should default to main window (no --window-title or --window-index flags) + expect(args).not.toContain("--window-title"); + expect(args).not.toContain("--window-index"); + expect(args).toContain("--mode"); + const modeIndex = args.indexOf("--mode"); + expect(args[modeIndex + 1]).toBe("window"); + }); + + it("should handle timestamp-like patterns in titles", () => { + const input = { + app_target: "Log Viewer:WINDOW_TITLE:2023-01-01 12:30:45", + format: "png" as const + }; + + const args = buildSwiftCliArgs(input, "/tmp/test.png"); + + expect(args).toContain("--window-title"); + const titleIndex = args.indexOf("--window-title"); + expect(args[titleIndex + 1]).toBe("2023-01-01 12:30:45"); + }); +}); \ No newline at end of file