qwen-code/packages/vscode-ide-companion/src/webview/utils/imageHandler.test.ts
易良 87f03cf2e9
feat(vscode-ide-companion): add image paste support (#1978)
* feat(vscode-ide-companion): add image paste support

  - Add clipboard image paste functionality with drag-and-drop support
  - Implement image preview component with removal capability
  - Support multimodal content in ACP session manager for text and images
  - Save pasted images to temporary .gemini-clipboard directory
  - Add image attachment display in user messages
  - Update CSP to allow data: URIs for inline image display
  - Add comprehensive image utilities with size validation (max 10MB)
  - Include tests for image processing utilities

* refactor: simplify VS Code paste image implementation

- Remove dead code and redundant error handling
- Extract common isAuthError() helper function
- Simplify SessionMessageHandler methods (80% reduction)
- Change temp directory from .gemini-clipboard to clipboard (aligned with CLI)
- Keep multimodal image sending format (type: image + base64)

Stats:
- 6 files changed
- 367 insertions (+)
- 1176 deletions (-)

* refactor: align paste image handling

* chore: trim paste image diff

* refactor(vscode-ide-companion): remove unused attachments logic

- Remove unused ImageAttachment type imports
- Remove attachments field from TextMessage interface
- Remove attachments from message data sent to WebView
- Clean up debug console.log statements
- Simplify SessionMessageHandler handleSendMessage method

This removes dead code from the previous image paste implementation
that was no longer needed after switching to @path reference approach.

* refactor(vscode-ide-companion/webview): extract image handling into dedicated hooks and utils

- extract ImagePreview and ImageMessageRenderer components from App.tsx
- create useImageAttachments hook for managing image attachments
- create useImageResolution hook for image path resolution
- add imageAttachmentHandler for saving images to temp files
- add imageMessageUtils for message expansion and resolution
- add imagePathResolver for resolving image paths in webview
- integrate image resolution in useWebViewMessages
- extract shouldSendMessage utility from useMessageSubmit
- add getLocalResourceRoots in PanelManager for resource access

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: harden vscode image handling and webview hosts

* fix: remove this alias in acp connection

* feat: add path escaping utility functions and tests

* feat: add support for image attachments and improve prompt handling

* refactor(webview): Optimize editing mode switching function

* refactor(vscode-ide-companion): move path escaping utilities to local module

- Move escapePath and unescapePath functions from qwen-code-core to local utils
- Add pathEscaping.ts with shell special characters handling
- Update imports in imageFormats.ts, imageAttachmentHandler.ts, and imageMessageUtils.ts
- Add unit tests for path escaping round-trip and browser bundle verification
- Fix browser bundling issue by avoiding node-only module dependencies in webview

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor: consolidate image handling logic across vscode-ide-companion and webui

- Merge分散的 image hooks (useImageAttachments, useImageResolution, usePasteHandler) into unified useImage hook
- Replace image utils (imageMessageUtils, imagePathResolver, imageUtils) with imageHandler and imageSupport
- Remove clipboard image storage from core package
- Consolidate webui image components into ImageComponents.tsx
- Update imports and tests to reflect new structure

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore: drop unrelated core tool changes

* test: fix webview provider mocks and drop unrelated core diffs

* fix(cli): resolve original prompt through standard path in no_command case

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-20 13:47:09 +08:00

219 lines
6 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
normalizeImageAttachment,
escapePath,
unescapePath,
} from '../../utils/imageSupport.js';
const mockMkdir = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockWriteFile = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockReaddir = vi.hoisted(() => vi.fn().mockResolvedValue([]));
const mockStat = vi.hoisted(() => vi.fn());
const mockUnlink = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
writeFile: mockWriteFile,
readdir: mockReaddir,
stat: mockStat,
unlink: mockUnlink,
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: { getGlobalTempDir: () => '/mock/tmp' },
};
});
vi.mock('vscode', () => ({
workspace: {
workspaceFolders: [],
},
}));
import {
processImageAttachments,
saveImageToFile,
buildPromptBlocks,
} from './imageHandler.js';
describe('imageHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('decodes base64 data URL and writes correct buffer to disk', async () => {
const filePath = await saveImageToFile(
'data:image/png;base64,YWJj',
'image/png',
);
expect(filePath).toBeTruthy();
expect(mockMkdir).toHaveBeenCalledWith(
path.join('/mock/tmp', 'clipboard'),
{ recursive: true },
);
expect(mockWriteFile).toHaveBeenCalledOnce();
const [writtenPath, buffer] = mockWriteFile.mock.calls[0];
expect(buffer).toEqual(Buffer.from('abc'));
expect(path.basename(writtenPath)).toMatch(
/^clipboard-\d+-[a-f0-9-]+\.png$/,
);
});
it('decodes raw base64 (without data URL prefix)', async () => {
const filePath = await saveImageToFile('YWJj', 'image/png');
expect(filePath).toBeTruthy();
const [, buffer] = mockWriteFile.mock.calls[0];
expect(buffer).toEqual(Buffer.from('abc'));
});
it('prunes old clipboard images after saving', async () => {
mockReaddir.mockResolvedValueOnce(['clipboard-1.png', 'clipboard-2.png']);
mockStat
.mockResolvedValueOnce({ mtimeMs: 100 })
.mockResolvedValueOnce({ mtimeMs: 200 });
await saveImageToFile('data:image/png;base64,YWJj', 'image/png');
expect(mockReaddir).toHaveBeenCalled();
});
it('generates unique file names for images saved in the same millisecond', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
await saveImageToFile('data:image/png;base64,YWJj', 'image/png');
await saveImageToFile('data:image/png;base64,ZGVm', 'image/png');
const firstName = path.basename(mockWriteFile.mock.calls[0][0]);
const secondName = path.basename(mockWriteFile.mock.calls[1][0]);
expect(firstName).not.toBe(secondName);
});
it('returns null when file write throws', async () => {
mockWriteFile.mockRejectedValueOnce(new Error('disk full'));
const result = await saveImageToFile(
'data:image/png;base64,YWJj',
'image/png',
);
expect(result).toBeNull();
});
it('returns saved prompt image metadata for validated attachments', async () => {
const result = await processImageAttachments('Inspect this image', [
{
id: 'img-1',
name: 'pasted.png',
type: 'image/png',
size: 3,
data: 'data:image/png;base64,YWJj',
timestamp: Date.now(),
},
]);
expect(result.savedImageCount).toBe(1);
expect(result.promptImages).toEqual([
expect.objectContaining({
name: 'pasted.png',
mimeType: 'image/png',
path: expect.stringContaining(`${path.sep}clipboard-`),
}),
]);
expect(result.formattedText).toContain('@');
});
});
describe('buildPromptBlocks', () => {
it('builds ACP resource_link blocks from saved image attachments', () => {
expect(
buildPromptBlocks('Please inspect this screenshot.', [
{
path: '/tmp/My Images/pasted image.png',
name: 'pasted image.png',
mimeType: 'image/png',
},
]),
).toEqual([
{ type: 'text', text: 'Please inspect this screenshot.' },
{
type: 'resource_link',
name: 'pasted image.png',
mimeType: 'image/png',
uri: 'file:///tmp/My Images/pasted image.png',
},
]);
});
it('returns only resource links when the prompt has images only', () => {
expect(
buildPromptBlocks('', [
{
path: '/tmp/clipboard/pasted.webp',
name: 'pasted.webp',
mimeType: 'image/webp',
},
]),
).toEqual([
{
type: 'resource_link',
name: 'pasted.webp',
mimeType: 'image/webp',
uri: 'file:///tmp/clipboard/pasted.webp',
},
]);
});
});
describe('normalizeImageAttachment', () => {
it('rejects attachments with unsupported image mime types', () => {
expect(
normalizeImageAttachment({
id: 'img-1',
name: 'animated.gif',
type: 'image/gif',
size: 43,
data: 'data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=',
timestamp: Date.now(),
}),
).toBeNull();
});
it('rejects attachments whose decoded payload exceeds the enforced byte limit', () => {
expect(
normalizeImageAttachment(
{
id: 'img-2',
name: 'oversized.png',
type: 'image/png',
size: 1,
data: 'data:image/png;base64,QUJDREU=',
timestamp: Date.now(),
},
{ maxBytes: 4 },
),
).toBeNull();
});
});
describe('pathEscaping', () => {
it('round-trips shell-escaped file paths', () => {
const originalPath = '/tmp/My Images/(draft) final.png';
expect(unescapePath(escapePath(originalPath))).toBe(originalPath);
});
});