From 7178c7ed474755d31e0c48ab2b124c02ccbdedfb Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 21:37:03 -0700 Subject: [PATCH] fix(web): Jira reaches the server (#27136) The web client had no Jira namespace, so every Jira call fell through to the silent fallback and returned undefined. Forward each Jira method to the server's jira.* RPCs, stream issue details, and warn once per namespace when a call lands on the fallback. Builds on #16803 by @JasonJarvan. Co-authored-by: m4air --- .../web/preload-api/web-fallback-api.test.ts | 31 +++++ .../src/web/preload-api/web-fallback-api.ts | 16 +++ .../src/web/preload-api/web-jira-api.ts | 66 ++++++++++ .../web/web-preload-api-composition.test.ts | 1 + .../src/web/web-preload-api-jira.test.ts | 123 ++++++++++++++++++ src/renderer/src/web/web-preload-api.ts | 2 + 6 files changed, 239 insertions(+) create mode 100644 src/renderer/src/web/preload-api/web-fallback-api.test.ts create mode 100644 src/renderer/src/web/preload-api/web-jira-api.ts create mode 100644 src/renderer/src/web/web-preload-api-jira.test.ts diff --git a/src/renderer/src/web/preload-api/web-fallback-api.test.ts b/src/renderer/src/web/preload-api/web-fallback-api.test.ts new file mode 100644 index 00000000000..c6ed0a3c8df --- /dev/null +++ b/src/renderer/src/web/preload-api/web-fallback-api.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createFallbackProxy } from './web-fallback-api' + +describe('web fallback proxy', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('warns once per unimplemented namespace and still returns the default result', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + type Api = { first: () => Promise; second: () => Promise } + const missing: Api = createFallbackProxy(['missingNamespace']) + const other: Api = createFallbackProxy(['otherMissingNamespace']) + + await expect(missing.first()).resolves.toBeUndefined() + await missing.second() + await other.first() + + expect(warn).toHaveBeenCalledTimes(2) + expect(warn.mock.calls[0]?.[0]).toContain('window.api.missingNamespace.first') + expect(warn.mock.calls[1]?.[0]).toContain('window.api.otherMissingNamespace.first') + }) + + it('does not warn when a namespace routes calls through an override', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const routed: { call: () => unknown } = createFallbackProxy(['routedNamespace'], () => 'ok') + + expect(routed.call()).toBe('ok') + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/web/preload-api/web-fallback-api.ts b/src/renderer/src/web/preload-api/web-fallback-api.ts index 2f59cd72615..f9e57f7d02c 100644 --- a/src/renderer/src/web/preload-api/web-fallback-api.ts +++ b/src/renderer/src/web/preload-api/web-fallback-api.ts @@ -21,6 +21,7 @@ export function createFallbackProxy( applyOverride?: (path: string[], args: unknown[]) => unknown ): never { const fn = () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the proxy answers every property and call, so it stands in for any API member. return new Proxy(fn, { get(_target, property) { if (property === 'then') { @@ -32,11 +33,26 @@ export function createFallbackProxy( if (applyOverride) { return applyOverride(path, args) } + warnFallbackNamespaceOnce(path) return getFallbackResult(path, args) } }) as never } +const warnedFallbackNamespaces = new Set() + +// Why: a missing web namespace otherwise fails silently with a default result (see #16802). +function warnFallbackNamespaceOnce(path: string[]): void { + const namespace = path[0] ?? '' + if (warnedFallbackNamespaces.has(namespace)) { + return + } + warnedFallbackNamespaces.add(namespace) + console.warn( + `[web] window.api.${path.join('.')} has no web implementation; returning a default result.` + ) +} + export function getFallbackResult(path: string[], args: unknown[]): unknown { const name = path.at(-1) ?? '' if (name.startsWith('on')) { diff --git a/src/renderer/src/web/preload-api/web-jira-api.ts b/src/renderer/src/web/preload-api/web-jira-api.ts new file mode 100644 index 00000000000..3f235e85430 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-jira-api.ts @@ -0,0 +1,66 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { readRuntimeJiraPayload } from '../../runtime/runtime-jira-payload-stream' +import { RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironment } from './web-runtime-session' + +const JIRA_NETWORK_TIMEOUT_MS = 30_000 +const JIRA_SETTINGS_TIMEOUT_MS = 15_000 + +function jiraCall(method: string, params?: unknown, timeoutMs = JIRA_NETWORK_TIMEOUT_MS) { + return callRuntimeResult(`jira.${method}`, params, timeoutMs) +} + +// Why: issue bodies with inline images exceed the 1 MiB socket frame, so stream them in chunks. +async function readJiraPayload( + streamMethod: string, + fallbackMethod: string, + args: unknown +): Promise { + const target = { kind: 'environment', environmentId: requireActiveEnvironment().id } as const + try { + return await readRuntimeJiraPayload(target, `jira.${streamMethod}`, args) + } catch (error) { + if (!(error instanceof RuntimeRpcCallError) || error.code !== 'method_not_found') { + throw error + } + // Older servers lack payload streaming but return text-only details one-shot. + return jiraCall(fallbackMethod, args) + } +} + +export function createWebJiraApi(): PreloadApi['jira'] { + return { + connect: (args) => jiraCall('connect', args), + disconnect: async (args) => { + await jiraCall('disconnect', args, JIRA_SETTINGS_TIMEOUT_MS) + }, + selectSite: (args) => jiraCall('selectSite', args, JIRA_SETTINGS_TIMEOUT_MS), + status: () => jiraCall('status', undefined, JIRA_SETTINGS_TIMEOUT_MS), + readStatus: () => jiraCall('readStatus', undefined, JIRA_SETTINGS_TIMEOUT_MS), + testConnection: (args) => jiraCall('testConnection', args), + // Why: requestId keys desktop-only IPC cancellation; the server has no cancel method. + searchIssues: ({ requestId: _requestId, ...args }) => jiraCall('searchIssues', args), + cancelSearchIssues: () => Promise.resolve(), + listIssues: (args) => jiraCall('listIssues', args), + getIssue: (args) => readJiraPayload('getIssueStream', 'getIssue', args), + lookupIssueSummary: ({ requestId: _requestId, ...args }) => + jiraCall('lookupIssueSummary', args), + cancelIssueSummary: () => Promise.resolve(), + createIssue: (args) => jiraCall('createIssue', args), + updateIssue: (args) => jiraCall('updateIssue', args), + addIssueComment: (args) => jiraCall('addIssueComment', args), + issueComments: (args) => readJiraPayload('issueCommentsStream', 'issueComments', args), + listProjects: (args) => jiraCall('listProjects', args), + listIssueTypes: (args) => jiraCall('listIssueTypes', args), + listCreateFields: (args) => jiraCall('listCreateFields', args), + listPriorities: (args) => jiraCall('listPriorities', args), + listAssignableUsers: (args) => jiraCall('listAssignableUsers', args), + // Why: servers expose no project-scoped user search; match the remote-environment fallback. + listAssignableUsersForProject: ({ query, siteId }) => + jiraCall('searchUsers', { query, siteId }), + searchUsers: (args) => jiraCall('searchUsers', args), + listTransitions: (args) => jiraCall('listTransitions', args), + getProjectStatusOrder: (args) => jiraCall('getProjectStatusOrder', args) + } +} diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index c09c1336dc7..cedd68e21bb 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -46,6 +46,7 @@ describe('web preload API composition', () => { 'gl', 'hostedReview', 'linear', + 'jira', 'hooks', 'stats', 'memory', diff --git a/src/renderer/src/web/web-preload-api-jira.test.ts b/src/renderer/src/web/web-preload-api-jira.test.ts new file mode 100644 index 00000000000..23cac4fac43 --- /dev/null +++ b/src/renderer/src/web/web-preload-api-jira.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +type RuntimeCall = { method: string; params: unknown } + +function mockRuntimeClient(resultFor: (method: string) => unknown): RuntimeCall[] { + const runtimeCalls: RuntimeCall[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: resultFor(method), + _meta: { runtimeId: 'runtime-1' } + }) + } + + subscribe( + method: string, + params: unknown, + callbacks: { onResponse: (response: RuntimeRpcResponse) => void } + ): Promise<{ unsubscribe: () => void }> { + runtimeCalls.push({ method, params }) + const payload = JSON.stringify(resultFor(method)) + for (const result of [{ type: 'chunk', content: payload }, { type: 'end' }]) { + callbacks.onResponse({ id: 'stream-1', ok: true, result, _meta: { runtimeId: 'r-1' } }) + } + return Promise.resolve({ unsubscribe: () => {} }) + } + + close(): void {} + } + })) + return runtimeCalls +} + +async function installApi() { + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + // Why: return the globals, not the api proxy, which reads as a thenable. + return globals +} + +describe('web Jira preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('routes Jira connector calls through the paired runtime', async () => { + const runtimeCalls = mockRuntimeClient((method) => + method === 'jira.connect' + ? { ok: true, viewer: { accountId: 'viewer-1', displayName: 'Test User' } } + : { connected: false, viewer: null } + ) + const { + window: { api } + } = await installApi() + const credentials = { + siteUrl: 'https://jira.example.com', + email: 'user@example.com', + apiToken: 'token' + } + + await expect(api.jira.connect(credentials)).resolves.toMatchObject({ ok: true }) + await expect(api.jira.status()).resolves.toEqual({ connected: false, viewer: null }) + + expect(runtimeCalls).toEqual([ + { method: 'jira.connect', params: credentials }, + { method: 'jira.status', params: undefined } + ]) + }) + + it('maps desktop-only Jira methods onto the server methods that exist', async () => { + const runtimeCalls = mockRuntimeClient(() => []) + const { + window: { api } + } = await installApi() + + await api.jira.searchIssues({ jql: 'project = ORCA', siteId: 'site-1', requestId: 'r1' }) + await api.jira.lookupIssueSummary({ key: 'ORCA-1', siteId: 'site-1', requestId: 'r2' }) + await api.jira.listAssignableUsersForProject({ + projectIdOrKey: 'ORCA', + query: 'ann', + siteId: 'site-1' + }) + await expect(api.jira.cancelSearchIssues({ requestId: 'r1' })).resolves.toBeUndefined() + await expect(api.jira.cancelIssueSummary({ requestId: 'r2' })).resolves.toBeUndefined() + + expect(runtimeCalls).toEqual([ + { method: 'jira.searchIssues', params: { jql: 'project = ORCA', siteId: 'site-1' } }, + { method: 'jira.lookupIssueSummary', params: { key: 'ORCA-1', siteId: 'site-1' } }, + { method: 'jira.searchUsers', params: { query: 'ann', siteId: 'site-1' } } + ]) + }) + + it('streams issue details so inline images are not cut off by the socket frame cap', async () => { + const issue = { key: 'ORCA-1', title: 'Large issue' } + const runtimeCalls = mockRuntimeClient((method) => + method === 'jira.getIssueStream' ? issue : null + ) + const { + window: { api } + } = await installApi() + + await expect(api.jira.getIssue({ key: 'ORCA-1', siteId: 'site-1' })).resolves.toEqual(issue) + expect(runtimeCalls).toEqual([ + { method: 'jira.getIssueStream', params: { key: 'ORCA-1', siteId: 'site-1' } } + ]) + }) +}) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 635e6dd924e..aca82b57501 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -40,6 +40,7 @@ import { createWebPlatformApi } from './preload-api/web-platform-api' import { createRateLimitsApi } from './preload-api/web-rate-limits-api' import { createReposApi } from './preload-api/web-repositories-api' import { createHooksApi, createRuntimeNamespaceApi } from './preload-api/web-review-api' +import { createWebJiraApi } from './preload-api/web-jira-api' import { callRuntimeResult } from './preload-api/web-runtime-calls' import { createWebRuntimeApi } from './preload-api/web-runtime-api' import { createRuntimeEnvironmentsApi } from './preload-api/web-runtime-environments-api' @@ -91,6 +92,7 @@ function createWebPreloadApi(): Partial { gl: createGitLabApi(), hostedReview: createRuntimeNamespaceApi('hostedReview'), linear: createRuntimeNamespaceApi('linear'), + jira: createWebJiraApi(), hooks: createHooksApi(), stats: { getSummary: async () =>