diff --git a/e2e/config/librechat.e2e.yaml b/e2e/config/librechat.e2e.yaml index 7f779d82f7..e6d3af8dd0 100644 --- a/e2e/config/librechat.e2e.yaml +++ b/e2e/config/librechat.e2e.yaml @@ -40,15 +40,24 @@ mcpServers: description: Local HTTP MCP fixture for allowlist-override e2e tests. timeout: 30000 -# Unified file upload routing. Spreadsheet/csv files resolve to llmDeliveryPath -# 'none' (kept out of LLM context + provider delivery; reachable only via tools -# like code interpreter). Exercised by e2e/specs/mock/unified-upload.spec.ts. +# Per-endpoint file upload config so both upload UXs get e2e coverage: +# - Mock Provider A keeps the legacy 3-way dropdown (chat.spec.ts exercises +# "Upload to Provider"); legacyFileUploadUX also forces llmDeliveryPath +# 'provider', so that test's CSV still reaches the model. +# - Mock Provider B uses the unified single button with spreadsheet/csv routed +# to llmDeliveryPath 'none' (unified-upload.spec.ts). fileConfig: - defaultLLMDeliveryPath: - overrides: - 'text/csv': 'none' - 'text/plain': 'none' - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'none' + endpoints: + 'Mock Provider A': + legacyFileUploadUX: true + 'Mock Provider B': + defaultLLMDeliveryPath: + overrides: + 'text/csv': 'none' + 'text/plain': 'none' + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'none' + # Routed to the provider: still delivered to the model AND shown in chat. + 'text/markdown': 'provider' endpoints: custom: diff --git a/e2e/specs/mock/unified-upload.spec.ts b/e2e/specs/mock/unified-upload.spec.ts index 3746cf19fb..674de82663 100644 --- a/e2e/specs/mock/unified-upload.spec.ts +++ b/e2e/specs/mock/unified-upload.spec.ts @@ -4,6 +4,7 @@ import { MOCK_ENDPOINTS, NEW_CHAT_PATH, fetchJson, + isAgentsStream, getAccessToken, selectMockEndpoint, } from './helpers'; @@ -11,19 +12,23 @@ import { /** * Unified file upload — per-mime-type delivery routing (PR #12626). * + * Runs against Mock Provider B, configured for unified mode in + * e2e/config/librechat.e2e.yaml (Mock Provider A stays on the legacy dropdown + * for chat.spec.ts's upload-to-provider test). + * * What this proves end-to-end (real backend + DB), and what it deliberately can't: * - The composer renders ONE attach button (unified mode), not the legacy 3-way - * dropdown — `legacyFileUploadUX` is unset in e2e/config/librechat.e2e.yaml. - * - A spreadsheet/csv upload routes through `processAgentFileUpload` and persists - * `llmDeliveryPath: 'none'` per the configured `defaultLLMDeliveryPath` override, - * i.e. it is NOT delivered to the LLM as text/provider — reachable only by tools. + * dropdown. + * - A `none`-routed upload (csv) persists `llmDeliveryPath: 'none'` and is kept + * out of LLM delivery — reachable only by tools. + * - A `provider`-routed upload (markdown) is STILL delivered to the model AND + * shown as an attachment chip — unified mode doesn't lose upload-to-provider. * - The mock harness has no code-execution environment, so the "available to the * code interpreter at tool-execute time" half is covered by jest * (`packages/api/src/agents/resources.test.ts`), not here. * - * `.xlsx` follows the identical code path (same `defaultLLMDeliveryPath` override, - * same standard-storage branch); csv is used here so the uploaded bytes match the - * declared mime type without synthesizing a binary spreadsheet. + * `.xlsx` follows the identical `none` code path; csv/markdown are used so the + * uploaded bytes match the declared mime type without synthesizing binaries. */ const uniqueName = (p: string) => `${p}-${Date.now()}-${Math.floor(Math.random() * 1e4)}`; @@ -33,8 +38,10 @@ type UploadedFile = { filename?: string; type?: string; llmDeliveryPath?: string const isFilesUpload = (url: string, method: string) => method === 'POST' && /\/api\/files(?:\?|$)/.test(new URL(url).pathname); -async function uploadViaUnifiedButton(page: Page, fileName: string) { - const csv = 'name,score\nalice,1\nbob,2\n'; +async function uploadViaUnifiedButton( + page: Page, + file: { name: string; mimeType: string; content: string }, +) { const uploadResponse = page.waitForResponse((r) => isFilesUpload(r.url(), r.request().method()), { timeout: 30000, }); @@ -43,9 +50,9 @@ async function uploadViaUnifiedButton(page: Page, fileName: string) { page.locator('#attach-file-button').click(), ]); await fileChooser.setFiles({ - name: fileName, - mimeType: 'text/csv', - buffer: Buffer.from(csv, 'utf8'), + name: file.name, + mimeType: file.mimeType, + buffer: Buffer.from(file.content, 'utf8'), }); return uploadResponse; } @@ -55,8 +62,8 @@ test.describe('unified file upload', () => { test.setTimeout(120000); await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); - // Default model needs a real key; pick a mock endpoint so the composer is live. - await selectMockEndpoint(page, MOCK_ENDPOINTS[0]); + // Default model needs a real key; Mock Provider B is the unified-mode endpoint. + await selectMockEndpoint(page, MOCK_ENDPOINTS[1]); // Unified mode: one attach button, and the legacy multi-option dropdown trigger // is not rendered at all. @@ -66,7 +73,11 @@ test.describe('unified file upload', () => { // Upload-time routing: the configured override (csv -> none) must persist, so // the file is kept out of LLM delivery and left for tools (code interpreter). const fileName = `${uniqueName('data')}.csv`; - const response = await uploadViaUnifiedButton(page, fileName); + const response = await uploadViaUnifiedButton(page, { + name: fileName, + mimeType: 'text/csv', + content: 'name,score\nalice,1\nbob,2\n', + }); expect(response.ok()).toBeTruthy(); const uploaded = (await response.json()) as UploadedFile; @@ -80,4 +91,52 @@ test.describe('unified file upload', () => { expect(persisted, `uploaded file "${fileName}" should persist`).toBeTruthy(); expect(persisted?.llmDeliveryPath).toBe('none'); }); + + test('single attach button still delivers a provider-routed upload and shows it in chat', async ({ + page, + }) => { + test.setTimeout(120000); + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await selectMockEndpoint(page, MOCK_ENDPOINTS[1]); + + // Same single unified button — no legacy dropdown. + await expect(page.locator('#attach-file-button')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('#attach-file-menu-button')).toHaveCount(0); + + // markdown is overridden to `provider` for Mock Provider B: it should be + // delivered to the model (unlike `none`) while still attaching to the chat. + const fileName = `${uniqueName('doc')}.md`; + const response = await uploadViaUnifiedButton(page, { + name: fileName, + mimeType: 'text/markdown', + content: '# E2E provider doc\n\nrouted to the provider via unified upload\n', + }); + expect(response.ok()).toBeTruthy(); + expect(((await response.json()) as UploadedFile).llmDeliveryPath).toBe('provider'); + + // (a) shows as an attachment chip in the composer before sending. + await expect(page.getByRole('button', { name: fileName })).toBeVisible({ timeout: 15000 }); + + // (b) reaches the model input: the mock LLM echoes a pass marker only when the + // provider file is present in the request content (see e2e/setup/fake-model.js). + const input = page.getByRole('textbox', { name: 'Message input' }); + await input.click(); + await input.fill(`E2E_ASSERT_PROVIDER_FILE:${fileName}`); + const [stream] = await Promise.all([ + page.waitForResponse(isAgentsStream, { timeout: 30000 }), + page.getByTestId('send-button').click(), + ]); + expect(stream.ok()).toBeTruthy(); + + await expect( + page + .getByTestId('messages-view') + .getByText(`E2E provider file assertion passed: ${fileName}`), + ).toBeVisible({ timeout: 20000 }); + + // chip persists on the sent message. + await expect( + page.getByTestId('messages-view').getByRole('button', { name: fileName }), + ).toBeVisible(); + }); });