diff --git a/src/renderer/src/web/preload-api/web-jira-filter-state-api.test.ts b/src/renderer/src/web/preload-api/web-jira-filter-state-api.test.ts new file mode 100644 index 00000000000..161c7c77870 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-jira-filter-state-api.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, + JIRA_SAVED_FILTER_UPDATE_REQUIRED +} from '../../../../shared/jira-saved-filter-capability' +import { + JIRA_STATUS_FILTER_RUNTIME_CAPABILITY, + JIRA_STATUS_FILTER_UPDATE_REQUIRED +} from '../../../../shared/jira-quick-filters' +import { + mutateJiraSavedFilters, + readJiraSavedFilterState +} from '../../runtime/runtime-jira-saved-filter-state' +import { + readJiraQuickFilterState, + writeJiraQuickFilterState +} from '../../runtime/runtime-jira-quick-filter-state' +import { createWebUiApi } from './web-ui-api' + +const runtime = vi.hoisted(() => { + const state: { activeId: string; idAfterProbe: string | null } = { + activeId: 'host-a', + idAfterProbe: null + } + return Object.assign(state, { call: vi.fn(), status: vi.fn() }) +}) +vi.mock('./web-runtime-calls', () => ({ + callRuntimeResult: runtime.call, + getRemoteRuntimeStatus: async () => { + const status = await runtime.status() + if (runtime.idAfterProbe) { + runtime.activeId = runtime.idAfterProbe + } + return status + } +})) +vi.mock('./web-runtime-session', () => ({ + webRuntimeState: { activeEnvironment: null }, + requireActiveEnvironmentOrNull: () => ({ id: runtime.activeId }), + requireActiveEnvironment: () => ({ id: runtime.activeId }), + assertActiveEnvironment: (id: string) => { + if (id !== runtime.activeId) { + throw new Error('Pairing changed') + } + } +})) +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('../../runtime/runtime-rpc-client', () => ({ + assertRuntimeEnvironmentCapability: vi.fn(), + callRuntimeRpc: rpc +})) + +const savedState = { + filters: [{ id: 'mine', name: 'My work', jql: 'project = APP' }], + activeFilterId: 'mine' +} +const quickState = { statuses: ['In Progress'] } +const BOTH = [JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, JIRA_STATUS_FILTER_RUNTIME_CAPABILITY] + +beforeEach(() => { + localStorage.clear() + runtime.call.mockReset() + runtime.status.mockReset() + runtime.activeId = 'host-a' + runtime.idAfterProbe = null + rpc.mockReset() + vi.stubGlobal('window', { localStorage, api: { ui: createWebUiApi() } }) +}) +afterEach(() => vi.unstubAllGlobals()) + +it('reads and writes Jira filter state on the paired host from the local target', async () => { + runtime.status.mockResolvedValue({ capabilities: BOTH }) + runtime.call.mockImplementation(async (method: string) => + method.includes('Saved') ? savedState : quickState + ) + + expect(await readJiraSavedFilterState({ kind: 'local' })).toEqual(savedState) + await mutateJiraSavedFilters({ kind: 'local' }, { kind: 'select', id: 'mine' }) + await readJiraQuickFilterState({ kind: 'local' }) + await writeJiraQuickFilterState({ kind: 'local' }, quickState) + + expect(runtime.call.mock.calls).toEqual([ + ['ui.getJiraSavedFilters', undefined, 15_000], + ['ui.mutateJiraSavedFilters', { kind: 'select', id: 'mine' }, 15_000], + ['ui.getJiraQuickFilters', undefined, 15_000], + ['ui.setJiraQuickFilters', quickState, 15_000] + ]) + expect(rpc).not.toHaveBeenCalled() +}) + +it('reports update-required for an older host and never sends the new methods', async () => { + runtime.status.mockResolvedValue({ capabilities: [] }) + + await expect(readJiraSavedFilterState({ kind: 'local' })).rejects.toThrow( + JIRA_SAVED_FILTER_UPDATE_REQUIRED + ) + await expect( + mutateJiraSavedFilters({ kind: 'local' }, { kind: 'select', id: null }) + ).rejects.toThrow(JIRA_SAVED_FILTER_UPDATE_REQUIRED) + await expect(readJiraQuickFilterState({ kind: 'local' })).rejects.toThrow( + JIRA_STATUS_FILTER_UPDATE_REQUIRED + ) + await expect(writeJiraQuickFilterState({ kind: 'local' }, quickState)).rejects.toThrow( + JIRA_STATUS_FILTER_UPDATE_REQUIRED + ) + expect(runtime.call).not.toHaveBeenCalled() +}) + +it('rejects host failures instead of returning empty or unsaved state', async () => { + runtime.status.mockRejectedValueOnce(new Error('Connection lost')) + await expect(readJiraSavedFilterState({ kind: 'local' })).rejects.toThrow('Connection lost') + + runtime.status.mockResolvedValue({ capabilities: BOTH }) + runtime.call.mockRejectedValue(new Error('Host write failed')) + await expect(writeJiraQuickFilterState({ kind: 'local' }, quickState)).rejects.toThrow( + 'Host write failed' + ) +}) + +it('does not send to a host paired in while the capability probe was in flight', async () => { + runtime.status.mockResolvedValue({ capabilities: BOTH }) + runtime.idAfterProbe = 'host-b' + + await expect( + mutateJiraSavedFilters({ kind: 'local' }, { kind: 'select', id: 'mine' }) + ).rejects.toThrow('Pairing changed') + expect(runtime.call).not.toHaveBeenCalled() +}) + +it('keeps explicit environment targets on the environment RPC path', async () => { + rpc.mockResolvedValue(savedState) + + await readJiraSavedFilterState({ kind: 'environment', environmentId: 'host-c' }) + + expect(rpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'host-c' }, + 'ui.getJiraSavedFilters', + undefined, + { timeoutMs: 15_000 } + ) + expect(runtime.call).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/web/preload-api/web-jira-filter-state-api.ts b/src/renderer/src/web/preload-api/web-jira-filter-state-api.ts new file mode 100644 index 00000000000..a4d1f49b67c --- /dev/null +++ b/src/renderer/src/web/preload-api/web-jira-filter-state-api.ts @@ -0,0 +1,70 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, + JIRA_SAVED_FILTER_UPDATE_REQUIRED +} from '../../../../shared/jira-saved-filter-capability' +import { + JIRA_STATUS_FILTER_RUNTIME_CAPABILITY, + JIRA_STATUS_FILTER_UPDATE_REQUIRED, + type JiraQuickFilterState +} from '../../../../shared/jira-quick-filters' +import type { RuntimeCapability } from '../../../../shared/protocol-version' +import type { JiraSavedFilterState } from '../../../../shared/jira-saved-filters' +import { callRuntimeResult, getRemoteRuntimeStatus } from './web-runtime-calls' +import { assertActiveEnvironment, requireActiveEnvironment } from './web-runtime-session' + +type WebJiraFilterStateApi = Pick< + NonNullable['ui']>, + 'getJiraQuickFilters' | 'setJiraQuickFilters' | 'getJiraSavedFilters' | 'mutateJiraSavedFilters' +> + +const JIRA_FILTER_TIMEOUT_MS = 15_000 + +/** Calls the active host only when it advertises the method, so an older host reports "update required" instead of failing generically. */ +async function callJiraFilterMethod( + capability: RuntimeCapability, + updateRequired: string, + method: string, + params?: unknown +): Promise { + const environmentId = requireActiveEnvironment().id + const status = await getRemoteRuntimeStatus() + // Why: the capability verdict belongs to the probed host; never send to a host switched in mid-probe. + assertActiveEnvironment(environmentId) + if (!status.capabilities?.includes(capability)) { + throw new Error(updateRequired) + } + return callRuntimeResult(method, params, JIRA_FILTER_TIMEOUT_MS) +} + +/** Jira filter state lives on the paired host, which is the web client's local execution target. */ +export function createWebJiraFilterStateApi(): WebJiraFilterStateApi { + return { + getJiraQuickFilters: () => + callJiraFilterMethod( + JIRA_STATUS_FILTER_RUNTIME_CAPABILITY, + JIRA_STATUS_FILTER_UPDATE_REQUIRED, + 'ui.getJiraQuickFilters' + ), + setJiraQuickFilters: (state) => + callJiraFilterMethod( + JIRA_STATUS_FILTER_RUNTIME_CAPABILITY, + JIRA_STATUS_FILTER_UPDATE_REQUIRED, + 'ui.setJiraQuickFilters', + state + ), + getJiraSavedFilters: () => + callJiraFilterMethod( + JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, + JIRA_SAVED_FILTER_UPDATE_REQUIRED, + 'ui.getJiraSavedFilters' + ), + mutateJiraSavedFilters: (mutation) => + callJiraFilterMethod( + JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, + JIRA_SAVED_FILTER_UPDATE_REQUIRED, + 'ui.mutateJiraSavedFilters', + mutation + ) + } +} diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 993e15d5a24..0d926726009 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -1,6 +1,7 @@ import type { AgentSessionAttachmentClipboardTarget } from '../../../../shared/agent-session-attachments' import { saveClipboardImageAsWebAgentSessionAttachment } from './web-agent-session-attachment-upload' import { createWebExplorerRootSync } from './web-explorer-root-sync' +import { createWebJiraFilterStateApi } from './web-jira-filter-state-api' import type { PreloadApi } from '../../../../preload/api-types' import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text' import type { ReadClipboardTextOptions } from '../../../../shared/clipboard-text' @@ -39,6 +40,7 @@ export function createWebUiApi(): NonNullable['ui']> { } let zoomLevel = readLocalWebUIState().uiZoomLevel return { + ...createWebJiraFilterStateApi(), /** Hydrates from the active host after replaying pending roots, falling back to local state on failure or host changes. */ get: async () => { try {