-
Notifications
You must be signed in to change notification settings - Fork 69
feat(ethercat): surface dedup-after-retry ESI uploads in the UI #849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from 1 commit
3bc8746
82f4e35
25acbfb
2389162
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = '<xml />'): 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(<ESIUpload onFilesLoaded={onFilesLoaded} repository={repository} />) | ||
| 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(<ESIUpload onFilesLoaded={onFilesLoaded} repository={existing} />) | ||
| 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() | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| import { cn } from '@root/frontend/utils/cn' | ||
| import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' | ||
| import { useEsi } from '@root/middleware/shared/providers/platform-context' | ||
|
|
@@ -55,6 +55,12 @@ | |
|
|
||
| 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 @@ | |
|
|
||
| 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 @@ | |
| 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) | ||
|
Comment on lines
+116
to
127
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check IPC bridge type for esiParseAndSaveFile
# Search for the bridge method signature
rg -n -A5 'esiParseAndSaveFile.*Promise' --type=tsRepository: Autonomy-Logic/openplc-editor Length of output: 55 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) Find all occurrences of dedupAfterRetry
rg -n --hidden --no-ignore-vcs "dedupAfterRetry" . || true
# 2) Find ESI port / loadRepositoryLight in the repo
rg -n --hidden --no-ignore-vcs "loadRepositoryLight" src || true
rg -n --hidden --no-ignore-vcs "EsiPort" src || true
# 3) Inspect main/renderer IPC bridge files for any ESI-related methods
rg -n --hidden --no-ignore-vcs "(esi|Esi).*Parse|esiParse|loadRepository" src/main/modules/ipc || true
rg -n --hidden --no-ignore-vcs "window\\.bridge\\." src/middleware/adapters/editor || true
# 4) Directly search for IPC method declarations mentioning ESI in renderer bridge
rg -n --hidden --no-ignore-vcs "ipc.*esi|esi.*ipc|loadRepositoryLight|RepositoryLight" src/main/modules/ipc/renderer.ts || trueRepository: Autonomy-Logic/openplc-editor Length of output: 50385 🏁 Script executed: #!/bin/bash
set -euo pipefail
# If dedupAfterRetry exists, print surrounding type declarations in main IPC renderer bridge
# (Use a small window to avoid huge output)
for pat in "dedupAfterRetry" "loadRepositoryLight" "RepositoryLight" "needsRepositoryRefresh" ; do
echo "---- pattern: $pat ----"
rg -n -A12 -B4 "$pat" src/main/modules/ipc/renderer.ts src/main/modules/ipc/main.ts || true
doneRepository: Autonomy-Logic/openplc-editor Length of output: 6062 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Print EsiPort contract for parseAndSaveFile (includes dedupAfterRetry)
sed -n '1,120p' src/middleware/shared/ports/esi-port.ts
# Print exact IPC renderer bridge signature for esiParseAndSaveFile
# (small window around the line indicated in the earlier grep)
sed -n '540,640p' src/main/modules/ipc/renderer.ts
# Print editor ESI adapter mapping for parseAndSaveFile
sed -n '1,140p' src/middleware/adapters/editor/esi-adapter.tsRepository: Autonomy-Logic/openplc-editor Length of output: 11447 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '1,220p' src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsxRepository: Autonomy-Logic/openplc-editor Length of output: 7707 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate the main-process handler implementation
rg -n "handleESIParseAndSaveFile" src/main/modules/ipc/main.ts
# Print the handler body
rg -n -A60 "handleESIParseAndSaveFile" src/main/modules/ipc/main.ts
# Locate backend ESI service parse method
rg -n "parseAndSaveFile" src/backend -S
# Print likely method signature / return type around dedupAfterRetry
rg -n -A40 -B5 "dedupAfterRetry" src/backend/editor src/backend/shared src/backend -S || trueRepository: Autonomy-Logic/openplc-editor Length of output: 6076 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Print the parseAndSaveFile implementation area
rg -n -A140 -B20 "async parseAndSaveFile" src/backend/editor/ethercat/esi-service.ts
# Also look for dedupAfterRetry usage in backend
rg -n "dedupAfterRetry" src/backend/editor/ethercat/esi-service.ts src/backend/shared -S || trueRepository: Autonomy-Logic/openplc-editor Length of output: 5383 Propagate
This makes the ESIUpload path that sets 🤖 Prompt for AI Agents |
||
| }, | ||
| [onFilesLoaded, repository, esi], | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 381
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 551
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 304
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 55
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 19516
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 1819
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 989
🏁 Script executed:
cat -n scripts/compare-surfaces.py | head -100Repository: Autonomy-Logic/openplc-editor
Length of output: 3414
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 144
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 140
🏁 Script executed:
cat -n scripts/compare-surfaces.py | tail -50Repository: Autonomy-Logic/openplc-editor
Length of output: 2028
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 55
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 278
Test file must be synced to openplc-web repository.
Test files are included in the byte-identical surface comparison (no exclusions exist in
compare-surfaces.py). This file exists in the editor repo but is missing from the web repo, which will cause the CI surface-sync check to fail.The sync check will report an error and block the merge unless:
#494) with this file is merged first, ORCoordinate with the web repository to ensure this test file is synced before merging.
🧰 Tools
🪛 GitHub Actions: CI / 3_sync _ Shared Surface Sync.txt
[error] 1-1: compare-surfaces.py reported a mismatch: file exists only in editor repo (not in web/src).
🪛 GitHub Actions: CI / sync _ Shared Surface Sync
[error] 1-1: [frontend] File exists only in editor repo
🤖 Prompt for AI Agents