From 0d7af8962fceb06e909710b3755edcdc9340c501 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:10:20 -0600 Subject: [PATCH] fix(desktop): tolerate legacy Kanban task attachment responses --- .../src/plugins/kanban/drawer.test.tsx | 129 ++++++++++++++++++ apps/desktop/src/plugins/kanban/drawer.tsx | 12 +- apps/desktop/src/plugins/kanban/types.ts | 3 +- 3 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/plugins/kanban/drawer.test.tsx diff --git a/apps/desktop/src/plugins/kanban/drawer.test.tsx b/apps/desktop/src/plugins/kanban/drawer.test.tsx new file mode 100644 index 0000000000..4e9f06afd5 --- /dev/null +++ b/apps/desktop/src/plugins/kanban/drawer.test.tsx @@ -0,0 +1,129 @@ +import type { PluginRestOptions } from '@hermes/plugin-sdk' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Test harness supplies the host's locale registration, as plugin loading does. +// eslint-disable-next-line no-restricted-imports +import { registerPluginLocales } from '@/i18n/plugin-i18n' + +import { bindApi, taskKey } from './api' +import { TaskDrawer } from './drawer' +import { en, KANBAN_LOCALES } from './i18n' +import type { KanbanTaskDetail } from './types' + +vi.mock('@/hermes', () => ({ setApiRequestProfile: vi.fn() })) + +const legacyDetail: Omit = { + task: { id: 't_example', title: 'Example task', body: 'Keep this description readable.', status: 'todo' }, + comments: [{ id: 1, author: 'test', body: 'Keep this comment readable.', created_at: 0 }], + events: [], + links: { parents: [], children: [] }, + runs: [] +} + +let detail: object +let client: QueryClient +let disposeApi: () => void +let disposeLocales: () => void + +const rest = vi.fn(async (path: string, options?: PluginRestOptions): Promise => { + if (path === '/tasks/t_example/attachments' && options?.method === 'POST') { + detail = { ...legacyDetail, attachments: [{ id: 1, filename: options.upload?.filename }] } + + return { ok: true } + } + + if (path === '/tasks/t_example') { + return detail + } + + if (path.startsWith('/tasks/t_example/log?')) { + return { exists: false, content: '', size_bytes: 0, truncated: false } + } + + if (path === '/profiles') { + return { profiles: [] } + } + + if (path === '/orchestration') { + return { default_assignee: '' } + } + + throw new Error(`Unexpected REST request: ${path}`) +}) + +beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + disposeLocales = registerPluginLocales('kanban', KANBAN_LOCALES) + disposeApi = bindApi( + async (path: string, options?: PluginRestOptions) => (await rest(path, options)) as T, + { get: (_key, fallback) => fallback, set: vi.fn(), remove: vi.fn() }, + () => vi.fn() + ) +}) + +afterEach(() => { + cleanup() + client.clear() + disposeApi() + disposeLocales() + vi.clearAllMocks() +}) + +function openDrawer() { + return render( + + + + ) +} + +describe('task attachment compatibility', () => { + it.each([{}, { attachments: null }])( + 'keeps older task details usable without attachment controls (%j)', + async extra => { + detail = { ...legacyDetail, ...extra } + openDrawer() + + expect(await screen.findByRole('heading', { name: legacyDetail.task.title })).toBeTruthy() + expect(screen.getByText(legacyDetail.task.body!)).toBeTruthy() + expect(screen.getByText(legacyDetail.comments[0].body)).toBeTruthy() + expect(screen.queryByRole('button', { name: en.uploadAttachment })).toBeNull() + expect(screen.queryByText(en.noAttachments)).toBeNull() + + // A later backend response restores the capability without remounting. + detail = { ...legacyDetail, attachments: [] } + await act(() => client.invalidateQueries({ queryKey: taskKey('', legacyDetail.task.id) })) + expect(await screen.findByRole('button', { name: en.uploadAttachment })).toBeTruthy() + expect(screen.getByText(en.noAttachments)).toBeTruthy() + } + ) + + it('keeps upload and attachment rendering working for a supported empty list', async () => { + detail = { ...legacyDetail, attachments: [] } + const { container } = openDrawer() + const upload = await screen.findByRole('button', { name: en.uploadAttachment }) + expect(screen.getByText(en.noAttachments)).toBeTruthy() + + const input = container.querySelector('input[type="file"]')! + const click = vi.spyOn(input, 'click') + fireEvent.click(upload) + expect(click).toHaveBeenCalledOnce() + + const file = new File(['example'], 'example.txt', { type: 'text/plain' }) + const bytes = new ArrayBuffer(7) + // jsdom's File lacks arrayBuffer; the upload still uses the real REST adapter. + Object.defineProperty(file, 'arrayBuffer', { value: async () => bytes }) + fireEvent.change(input, { target: { files: [file] } }) + + await waitFor(() => + expect(rest).toHaveBeenCalledWith('/tasks/t_example/attachments', { + method: 'POST', + upload: { filename: file.name, contentType: file.type, bytes } + }) + ) + expect(await screen.findByText(file.name)).toBeTruthy() + expect(screen.queryByText(en.noAttachments)).toBeNull() + }) +}) diff --git a/apps/desktop/src/plugins/kanban/drawer.tsx b/apps/desktop/src/plugins/kanban/drawer.tsx index 95a01d2049..c5491bea3c 100644 --- a/apps/desktop/src/plugins/kanban/drawer.tsx +++ b/apps/desktop/src/plugins/kanban/drawer.tsx @@ -945,11 +945,13 @@ export function TaskDrawer({ )} - uploadMut.mutate(file)} - pending={uploadMut.isPending} - /> + {Array.isArray(detail.attachments) && ( + uploadMut.mutate(file)} + pending={uploadMut.isPending} + /> + )} )} diff --git a/apps/desktop/src/plugins/kanban/types.ts b/apps/desktop/src/plugins/kanban/types.ts index 1f6242d275..607e08d820 100644 --- a/apps/desktop/src/plugins/kanban/types.ts +++ b/apps/desktop/src/plugins/kanban/types.ts @@ -117,7 +117,8 @@ export interface KanbanTaskDetail { task: KanbanTaskFull comments: KanbanComment[] events: KanbanEvent[] - attachments: KanbanAttachment[] + /** Older backend plugins omit this collection and have no attachment endpoints. */ + attachments?: KanbanAttachment[] | null links: { parents: string[]; children: string[] } runs: KanbanRun[] }