diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/__tests__/esi-upload.test.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/__tests__/esi-upload.test.tsx new file mode 100644 index 000000000..f37a3e1e2 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/__tests__/esi-upload.test.tsx @@ -0,0 +1,96 @@ +import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' +import { fireEvent, render, waitFor } from '@testing-library/react' + +// Mocked EsiPort surface — the two methods the upload flow touches. The +// `mock`-prefixed name is referenced lazily inside the factory (only when +// `useEsi()` is called at render time), so this works under both Vitest and +// the editor's Jest+vi shim — without `vi.hoisted`, which the shim lacks. +const mockEsi = { + parseAndSaveFile: vi.fn(), + loadRepositoryLight: vi.fn(), +} + +vi.mock('@root/middleware/shared/providers/platform-context', () => ({ + useEsi: () => mockEsi, +})) + +import { ESIUpload } from '../esi-upload' + +const SAMPLE_ITEM: ESIRepositoryItemLight = { + id: 'item-1', + filename: 'Beckhoff.xml', + vendor: { id: '0x0002', name: 'Beckhoff' }, + devices: [], + loadedAt: '2026-01-01T00:00:00.000Z', +} + +/** Build an XML File whose `.text()` resolves regardless of jsdom version. */ +function xmlFile(name = 'Beckhoff.xml', content = ''): File { + const file = new File([content], name, { type: 'text/xml' }) + Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) }) + return file +} + +/** Render the component and drive a single-file upload through the input. */ +function uploadFile(repository: ESIRepositoryItemLight[] = []) { + const onFilesLoaded = vi.fn() + const { container } = render() + const input = container.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [xmlFile()] } }) + return { onFilesLoaded } +} + +describe('ESIUpload — dedupAfterRetry handling', () => { + beforeEach(() => { + mockEsi.parseAndSaveFile.mockReset() + mockEsi.loadRepositoryLight.mockReset() + }) + + it('appends a newly added item without re-listing the repository', async () => { + mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, item: SAMPLE_ITEM }) + + const { onFilesLoaded } = uploadFile() + + await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled()) + expect(onFilesLoaded).toHaveBeenCalledWith([SAMPLE_ITEM], undefined) + expect(mockEsi.loadRepositoryLight).not.toHaveBeenCalled() + }) + + it('re-lists the repository when a dedupAfterRetry lands without an item', async () => { + mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, dedupAfterRetry: true }) + mockEsi.loadRepositoryLight.mockResolvedValueOnce({ success: true, items: [SAMPLE_ITEM] }) + + const { onFilesLoaded } = uploadFile() + + await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled()) + expect(mockEsi.loadRepositoryLight).toHaveBeenCalledTimes(1) + // The refreshed list is authoritative — the recovered row appears here. + expect(onFilesLoaded).toHaveBeenCalledWith([SAMPLE_ITEM], undefined) + }) + + it('falls back to the local list when the refresh itself fails', async () => { + const existing: ESIRepositoryItemLight[] = [{ ...SAMPLE_ITEM, id: 'old', filename: 'Old.xml' }] + mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, dedupAfterRetry: true }) + mockEsi.loadRepositoryLight.mockResolvedValueOnce({ success: false, error: 'list failed' }) + + const onFilesLoaded = vi.fn() + const { container } = render() + const input = container.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [xmlFile()] } }) + + await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled()) + expect(mockEsi.loadRepositoryLight).toHaveBeenCalledTimes(1) + // No new item was returned, so the fallback keeps the existing repository. + expect(onFilesLoaded).toHaveBeenCalledWith(existing, undefined) + }) + + it('skips a real duplicate silently without re-listing', async () => { + mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, duplicate: true }) + + const { onFilesLoaded } = uploadFile() + + await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled()) + expect(onFilesLoaded).toHaveBeenCalledWith([], undefined) + expect(mockEsi.loadRepositoryLight).not.toHaveBeenCalled() + }) +}) diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx index 42cd63c03..f051eacc2 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx @@ -55,6 +55,12 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadPr const newItems: ESIRepositoryItemLight[] = [] const errors: Array<{ filename: string; error: string }> = [] + // A dedup-after-retry result means the file was persisted on the backend + // but its row was missing from the upload response (and the adapter's own + // recovery lookup also failed). Honor the EsiPort contract by re-listing + // the repository after the batch so the file appears instead of silently + // vanishing — see EsiPort.parseAndSaveFile (`dedupAfterRetry`). + let needsRepositoryRefresh = false const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB @@ -84,8 +90,13 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadPr if (result.success && result.item) { newItems.push(result.item) + } else if (result.success && result.dedupAfterRetry) { + // Uploaded but absent from the response: a transient-failure retry + // hit the backend dedup and the adapter couldn't recover the row. + // Flag a repository refresh so the file surfaces after the batch. + needsRepositoryRefresh = true } else if (result.success) { - // Duplicate content already in the repository — skip silently. + // Real duplicate — content already in the repository. Skip silently. // See EsiPort.parseAndSaveFile for the duplicate-handling contract. } else { errors.push({ filename: file.name, error: result.error ?? 'Parse failed' }) @@ -102,6 +113,17 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadPr percentage: 100, }) + // A recovered-but-unlisted upload (dedupAfterRetry) is on the backend yet + // missing from `newItems`. Re-list the repository so it shows up; fall + // back to the locally accumulated list if the refresh itself fails. + if (needsRepositoryRefresh) { + const refreshed = await esi!.loadRepositoryLight() + if (refreshed.success && refreshed.items) { + onFilesLoaded(refreshed.items, errors.length > 0 ? errors : undefined) + return + } + } + onFilesLoaded([...repository, ...newItems], errors.length > 0 ? errors : undefined) }, [onFilesLoaded, repository, esi], diff --git a/src/middleware/shared/ports/esi-port.ts b/src/middleware/shared/ports/esi-port.ts index 240e73fc7..6e220d324 100644 --- a/src/middleware/shared/ports/esi-port.ts +++ b/src/middleware/shared/ports/esi-port.ts @@ -39,11 +39,17 @@ export interface EsiPort { * Dedup is filename-based: reimporting the same bytes under a different name * will add a new entry, and replacing a file's contents without renaming it * is reported as a duplicate. + * + * `dedupAfterRetry: true` indicates the duplicate response landed after a + * transient-failure retry — the first attempt likely persisted the file and + * its response was lost. Callers should treat this as "uploaded but absent + * from the response" rather than silently skipping, since the user expects + * the file to appear in the repository. */ parseAndSaveFile( filename: string, content: string, - ): Promise> + ): Promise> /** * Delete a single repository item and its associated XML file.