|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 5 | + |
| 6 | +const { |
| 7 | + assertOpaqueWorkspaceFileModelSafeMock, |
| 8 | + createWorkspaceFileSecretProvenanceFromRegistryMock, |
| 9 | + executeCopilotFileUseCaseMock, |
| 10 | + generateContentMock, |
| 11 | + resolveCopilotWorkspaceFileReferenceMock, |
| 12 | + writeCopilotWorkspaceFileByPathMock, |
| 13 | +} = vi.hoisted(() => ({ |
| 14 | + assertOpaqueWorkspaceFileModelSafeMock: vi.fn(), |
| 15 | + createWorkspaceFileSecretProvenanceFromRegistryMock: vi.fn(), |
| 16 | + executeCopilotFileUseCaseMock: vi.fn(), |
| 17 | + generateContentMock: vi.fn(), |
| 18 | + resolveCopilotWorkspaceFileReferenceMock: vi.fn(), |
| 19 | + writeCopilotWorkspaceFileByPathMock: vi.fn(), |
| 20 | +})) |
| 21 | + |
| 22 | +vi.mock('@google/genai', () => ({ |
| 23 | + GoogleGenAI: class GoogleGenAI { |
| 24 | + models = { generateContent: generateContentMock } |
| 25 | + }, |
| 26 | +})) |
| 27 | + |
| 28 | +vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ |
| 29 | + executeCopilotFileUseCase: (...args: unknown[]) => executeCopilotFileUseCaseMock(...args), |
| 30 | + resolveCopilotWorkspaceFileReference: (...args: unknown[]) => |
| 31 | + resolveCopilotWorkspaceFileReferenceMock(...args), |
| 32 | +})) |
| 33 | + |
| 34 | +vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ |
| 35 | + GenerateImage: { id: 'generate_image' }, |
| 36 | +})) |
| 37 | + |
| 38 | +vi.mock('@/lib/copilot/tools/server/model-input', () => ({ |
| 39 | + assertOpaqueWorkspaceFileModelSafe: (...args: unknown[]) => |
| 40 | + assertOpaqueWorkspaceFileModelSafeMock(...args), |
| 41 | +})) |
| 42 | + |
| 43 | +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ |
| 44 | + writeCopilotWorkspaceFileByPath: (...args: unknown[]) => |
| 45 | + writeCopilotWorkspaceFileByPathMock(...args), |
| 46 | +})) |
| 47 | + |
| 48 | +vi.mock('@/lib/core/config/api-keys', () => ({ |
| 49 | + getRotatingApiKey: vi.fn(() => 'api-key'), |
| 50 | +})) |
| 51 | + |
| 52 | +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ |
| 53 | + createWorkspaceFileSecretProvenanceFromRegistry: (...args: unknown[]) => |
| 54 | + createWorkspaceFileSecretProvenanceFromRegistryMock(...args), |
| 55 | +})) |
| 56 | + |
| 57 | +vi.mock('@/lib/workspace-files/application/operations', () => ({ |
| 58 | + fileOperations: { readContent: { id: 'file.readContent' } }, |
| 59 | +})) |
| 60 | + |
| 61 | +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ |
| 62 | + readWorkspaceFileContent: { execute: vi.fn() }, |
| 63 | +})) |
| 64 | + |
| 65 | +import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' |
| 66 | +import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' |
| 67 | + |
| 68 | +const context: ServerToolContext = { |
| 69 | + userId: 'user-1', |
| 70 | + workspaceId: 'workspace-1', |
| 71 | + toolCallId: 'tool-1', |
| 72 | + copilotToolExecution: true, |
| 73 | +} |
| 74 | + |
| 75 | +const referenceFile = { |
| 76 | + id: 'file-1', |
| 77 | + workspaceId: 'workspace-1', |
| 78 | + name: 'reference.png', |
| 79 | + key: 'workspace/workspace-1/reference.png', |
| 80 | + path: '/api/files/serve/reference.png', |
| 81 | + size: 9, |
| 82 | + type: 'image/png', |
| 83 | + uploadedBy: 'user-1', |
| 84 | + uploadedAt: new Date('2026-08-31T00:00:00.000Z'), |
| 85 | + updatedAt: new Date('2026-08-31T00:00:00.000Z'), |
| 86 | + storageContext: 'workspace' as const, |
| 87 | +} |
| 88 | + |
| 89 | +describe('generateImageServerTool reference inputs', () => { |
| 90 | + beforeEach(() => { |
| 91 | + vi.clearAllMocks() |
| 92 | + resolveCopilotWorkspaceFileReferenceMock.mockResolvedValue(referenceFile) |
| 93 | + assertOpaqueWorkspaceFileModelSafeMock.mockResolvedValue(undefined) |
| 94 | + executeCopilotFileUseCaseMock.mockResolvedValue({ |
| 95 | + file: referenceFile, |
| 96 | + content: Buffer.from('reference'), |
| 97 | + }) |
| 98 | + generateContentMock.mockResolvedValue({ |
| 99 | + candidates: [ |
| 100 | + { |
| 101 | + content: { |
| 102 | + parts: [{ inlineData: { data: 'Z2VuZXJhdGVk', mimeType: 'image/png' } }], |
| 103 | + }, |
| 104 | + }, |
| 105 | + ], |
| 106 | + }) |
| 107 | + createWorkspaceFileSecretProvenanceFromRegistryMock.mockResolvedValue({ |
| 108 | + safe: true, |
| 109 | + provenance: { status: 'exact', entries: [] }, |
| 110 | + }) |
| 111 | + writeCopilotWorkspaceFileByPathMock.mockResolvedValue({ |
| 112 | + id: 'output-1', |
| 113 | + name: 'generated-image.png', |
| 114 | + size: 9, |
| 115 | + contentType: 'image/png', |
| 116 | + vfsPath: 'files/generated-image.png', |
| 117 | + downloadUrl: '/api/files/serve/generated-image.png', |
| 118 | + mode: 'create', |
| 119 | + }) |
| 120 | + }) |
| 121 | + |
| 122 | + it('keeps inputs optional for text-to-image generation', async () => { |
| 123 | + const result = await generateImageServerTool.execute({ prompt: 'Draw a lighthouse' }, context) |
| 124 | + |
| 125 | + expect(result.success).toBe(true) |
| 126 | + expect(resolveCopilotWorkspaceFileReferenceMock).not.toHaveBeenCalled() |
| 127 | + expect(generateContentMock).toHaveBeenCalledWith( |
| 128 | + expect.objectContaining({ |
| 129 | + contents: [ |
| 130 | + expect.objectContaining({ |
| 131 | + parts: [ |
| 132 | + expect.objectContaining({ text: expect.stringContaining('Draw a lighthouse') }), |
| 133 | + ], |
| 134 | + }), |
| 135 | + ], |
| 136 | + }) |
| 137 | + ) |
| 138 | + }) |
| 139 | + |
| 140 | + it.each([{ inputs: {} }, { inputs: { files: [] } }])( |
| 141 | + 'rejects supplied inputs without files before calling the provider', |
| 142 | + async ({ inputs }) => { |
| 143 | + const result = await generateImageServerTool.execute( |
| 144 | + { prompt: 'Edit this image', inputs }, |
| 145 | + context |
| 146 | + ) |
| 147 | + |
| 148 | + expect(result).toEqual( |
| 149 | + expect.objectContaining({ |
| 150 | + success: false, |
| 151 | + message: expect.stringContaining('inputs.files'), |
| 152 | + }) |
| 153 | + ) |
| 154 | + expect(generateContentMock).not.toHaveBeenCalled() |
| 155 | + } |
| 156 | + ) |
| 157 | + |
| 158 | + it('loads a valid reference into inlineData before calling the provider', async () => { |
| 159 | + const result = await generateImageServerTool.execute( |
| 160 | + { |
| 161 | + prompt: 'Turn the sky purple', |
| 162 | + inputs: { files: [{ path: 'files/reference.png' }] }, |
| 163 | + }, |
| 164 | + context |
| 165 | + ) |
| 166 | + |
| 167 | + expect(result.success).toBe(true) |
| 168 | + expect(generateContentMock).toHaveBeenCalledWith( |
| 169 | + expect.objectContaining({ |
| 170 | + contents: [ |
| 171 | + expect.objectContaining({ |
| 172 | + parts: [ |
| 173 | + { inlineData: { mimeType: 'image/png', data: 'cmVmZXJlbmNl' } }, |
| 174 | + expect.objectContaining({ text: expect.stringContaining('Turn the sky purple') }), |
| 175 | + ], |
| 176 | + }), |
| 177 | + ], |
| 178 | + }) |
| 179 | + ) |
| 180 | + expect(result.message).toContain('edited') |
| 181 | + }) |
| 182 | + |
| 183 | + it('fails when a reference cannot be resolved before calling the provider', async () => { |
| 184 | + resolveCopilotWorkspaceFileReferenceMock.mockRejectedValue(new Error('File not found')) |
| 185 | + |
| 186 | + const result = await generateImageServerTool.execute( |
| 187 | + { |
| 188 | + prompt: 'Edit this image', |
| 189 | + inputs: { files: [{ path: 'files/missing.png' }] }, |
| 190 | + }, |
| 191 | + context |
| 192 | + ) |
| 193 | + |
| 194 | + expect(result).toEqual( |
| 195 | + expect.objectContaining({ |
| 196 | + success: false, |
| 197 | + message: expect.stringContaining('File not found'), |
| 198 | + }) |
| 199 | + ) |
| 200 | + expect(generateContentMock).not.toHaveBeenCalled() |
| 201 | + }) |
| 202 | + |
| 203 | + it('does not generate from a partial list when any reference is missing', async () => { |
| 204 | + resolveCopilotWorkspaceFileReferenceMock |
| 205 | + .mockResolvedValueOnce(referenceFile) |
| 206 | + .mockRejectedValueOnce(new Error('Second file not found')) |
| 207 | + |
| 208 | + const result = await generateImageServerTool.execute( |
| 209 | + { |
| 210 | + prompt: 'Combine these images', |
| 211 | + inputs: { |
| 212 | + files: [{ path: 'files/reference.png' }, { path: 'files/missing.png' }], |
| 213 | + }, |
| 214 | + }, |
| 215 | + context |
| 216 | + ) |
| 217 | + |
| 218 | + expect(result).toEqual( |
| 219 | + expect.objectContaining({ |
| 220 | + success: false, |
| 221 | + message: expect.stringContaining('Second file not found'), |
| 222 | + }) |
| 223 | + ) |
| 224 | + expect(generateContentMock).not.toHaveBeenCalled() |
| 225 | + }) |
| 226 | + |
| 227 | + it('fails when reference bytes cannot be read before calling the provider', async () => { |
| 228 | + executeCopilotFileUseCaseMock.mockRejectedValue(new Error('Unable to read file')) |
| 229 | + |
| 230 | + const result = await generateImageServerTool.execute( |
| 231 | + { |
| 232 | + prompt: 'Edit this image', |
| 233 | + inputs: { files: [{ path: 'files/reference.png' }] }, |
| 234 | + }, |
| 235 | + context |
| 236 | + ) |
| 237 | + |
| 238 | + expect(result).toEqual( |
| 239 | + expect.objectContaining({ |
| 240 | + success: false, |
| 241 | + message: expect.stringContaining('Unable to read file'), |
| 242 | + }) |
| 243 | + ) |
| 244 | + expect(generateContentMock).not.toHaveBeenCalled() |
| 245 | + }) |
| 246 | + |
| 247 | + it('explains how to save an uploaded image before using it as a reference', async () => { |
| 248 | + const result = await generateImageServerTool.execute( |
| 249 | + { |
| 250 | + prompt: 'Edit this image', |
| 251 | + inputs: { files: [{ path: 'uploads/reference.png' }] }, |
| 252 | + }, |
| 253 | + context |
| 254 | + ) |
| 255 | + |
| 256 | + expect(result).toEqual( |
| 257 | + expect.objectContaining({ |
| 258 | + success: false, |
| 259 | + message: expect.stringMatching(/save_upload[\s\S]*files\//), |
| 260 | + }) |
| 261 | + ) |
| 262 | + expect(resolveCopilotWorkspaceFileReferenceMock).not.toHaveBeenCalled() |
| 263 | + expect(generateContentMock).not.toHaveBeenCalled() |
| 264 | + }) |
| 265 | +}) |
0 commit comments