import { beforeEach, describe, expect, it, vi } from 'vitest' import { workspaceFromLinearData } from '../../src/main/linear/linear-workspace-record' import { credentialDecryptionMessage } from '../../src/shared/integration-credential-errors' import type { LinearIssue } from '../../src/shared/linear/issue-types' import { getTaskSourceCacheScope, type TaskSourceContext } from '../../src/shared/task-source-context' import type { LinearCollectionResult, LinearConnectionStatus, LinearViewer } from '../../src/shared/linear/workspace-types' import { clearLinearRequestMaps, getInflightStatusRequest } from '../../src/renderer/src/store/slices/linear/linear-slice-request-state' import { createTestStore, deferred, issue } from '../../src/renderer/src/store/slices/linear/linear-slice-test-harness' const linearStatus = vi.fn() const linearConnect = vi.fn() const linearDisconnect = vi.fn() const linearListIssues = vi.fn() const linearSearchIssues = vi.fn() const linearListTeams = vi.fn() const linearGetIssue = vi.fn() const linearListProjects = vi.fn() const linearGetCustomView = vi.fn() const linearGetProject = vi.fn() const linearListProjectIssues = vi.fn() const linearListCustomViews = vi.fn() const linearListCustomViewIssues = vi.fn() const linearListCustomViewProjects = vi.fn() const linearTestConnection = vi.fn() const linearSelectWorkspace = vi.fn() const linearDisconnectWorkspace = vi.fn() vi.mock('@/runtime/linear/runtime-linear-client', async (importOriginal) => { const actual = await importOriginal>() return { ...actual, linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: (...args: unknown[]) => linearDisconnectWorkspace(...args), linearListIssues: (...args: unknown[]) => linearListIssues(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: (...args: unknown[]) => linearSelectWorkspace(...args), linearStatus: (...args: unknown[]) => linearStatus(...args), linearTestConnection: (...args: unknown[]) => linearTestConnection(...args) } }) vi.mock('@/runtime/linear/runtime-linear-issue-client', async (importOriginal) => { const actual = await importOriginal>() return { ...actual, linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) } }) vi.mock('@/runtime/linear/runtime-linear-project-client', async (importOriginal) => { const actual = await importOriginal>() return { ...actual, linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), linearGetProject: (...args: unknown[]) => linearGetProject(...args), linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), linearListProjects: (...args: unknown[]) => linearListProjects(...args), linearListTeams: (...args: unknown[]) => linearListTeams(...args) } }) vi.mock('../../src/renderer/src/hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) describe('createLinearSlice', () => { beforeEach(() => { linearStatus.mockReset() linearConnect.mockReset() linearDisconnect.mockReset() linearListIssues.mockReset() linearSearchIssues.mockReset() linearListTeams.mockReset() linearGetIssue.mockReset() linearTestConnection.mockReset() }) it('dedupes concurrent connection checks', async () => { const pending = deferred() linearStatus.mockReturnValueOnce(pending.promise) const store = createTestStore() const first = store.getState().checkLinearConnection() const second = store.getState().checkLinearConnection() expect(linearStatus).toHaveBeenCalledTimes(1) pending.resolve({ connected: true, viewer: { displayName: 'Test User', email: 'test@example.com', organizationName: 'Test Org' } }) await Promise.all([first, second]) expect(store.getState().linearStatus.connected).toBe(true) expect(store.getState().linearStatusChecked).toBe(true) }) it('ignores stale forced connection checks when a newer forced check finishes first', async () => { const staleCheck = deferred() const freshCheck = deferred() const viewer = { displayName: 'Test User', email: 'test@example.com', organizationName: 'Test Org' } linearStatus.mockReturnValueOnce(staleCheck.promise).mockReturnValueOnce(freshCheck.promise) const store = createTestStore() const stalePromise = store.getState().checkLinearConnection(true) const freshPromise = store.getState().checkLinearConnection(true) freshCheck.resolve({ connected: true, viewer }) await freshPromise staleCheck.resolve({ connected: false, viewer: null }) await stalePromise expect(store.getState().linearStatus.connected).toBe(true) expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com') }) it('ignores stale status responses after the active runtime changes', async () => { const localStatus = deferred() const remoteStatus = deferred() const localViewer = { displayName: 'Local User', email: 'local@example.com', organizationName: 'Local Org' } const remoteViewer = { displayName: 'Remote User', email: 'remote@example.com', organizationName: 'Remote Org' } linearStatus.mockReturnValueOnce(localStatus.promise).mockReturnValueOnce(remoteStatus.promise) const store = createTestStore() const localRequest = store.getState().checkLinearConnection() store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) const remoteRequest = store.getState().checkLinearConnection() remoteStatus.resolve({ connected: true, viewer: remoteViewer }) await remoteRequest expect(store.getState().linearStatus.viewer?.email).toBe('remote@example.com') expect(store.getState().linearStatusContextKey).toBe('runtime:runtime-1#0') localStatus.resolve({ connected: true, viewer: localViewer }) await localRequest expect(store.getState().linearStatus.viewer?.email).toBe('remote@example.com') expect(store.getState().linearStatusContextKey).toBe('runtime:runtime-1#0') }) it('ignores stale list cache writes after the active runtime changes', async () => { const localList = deferred>() const remoteList = deferred>() linearListIssues.mockReturnValueOnce(localList.promise).mockReturnValueOnce(remoteList.promise) const store = createTestStore() store.setState({ linearStatus: { connected: true, viewer: null } }) const localRequest = store .getState() .listLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) const remoteRequest = store .getState() .listLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) remoteList.resolve({ items: [issue('LIN-REMOTE')] }) await remoteRequest expect( store.getState().getCachedLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) ).toMatchObject({ items: [{ id: 'LIN-REMOTE' }] }) localList.resolve({ items: [issue('LIN-LOCAL')] }) await expect(localRequest).rejects.toThrow('Linear read abandoned') expect( store.getState().getCachedLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) ).toMatchObject({ items: [{ id: 'LIN-REMOTE' }] }) }) it('ignores stale status checks after a successful connect', async () => { const staleMountCheck = deferred() const viewer = { displayName: 'Test User', email: 'test@example.com', organizationName: 'Test Org' } linearStatus.mockReturnValueOnce(staleMountCheck.promise) linearConnect.mockResolvedValueOnce({ ok: true, viewer }) const store = createTestStore() const mountCheck = store.getState().checkLinearConnection() const connectPromise = store.getState().connectLinear('linear-key') await Promise.resolve() expect(linearStatus).toHaveBeenCalledTimes(1) await connectPromise staleMountCheck.resolve({ connected: false, viewer: null }) await mountCheck expect(store.getState().linearStatus.connected).toBe(true) expect(store.getState().linearStatus.viewer?.email).toBe('test@example.com') }) it('ignores stale connect results after the active runtime changes', async () => { const connectResult = deferred<{ ok: true; viewer: LinearViewer }>() const viewer = { displayName: 'Local User', email: 'local@example.com', organizationName: 'Local Org' } linearConnect.mockReturnValueOnce(connectResult.promise) const store = createTestStore() const connectPromise = store.getState().connectLinear('linear-key') store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) connectResult.resolve({ ok: true, viewer }) await expect(connectPromise).resolves.toEqual({ ok: true, viewer }) expect(store.getState().linearStatus.connected).toBe(false) expect(store.getState().linearStatusContextKey).toBeNull() store.setState({ linearSearchCache: { 'other::search::warm::12': { data: [issue('old-runtime')], fetchedAt: 1 } } }) const workspace = workspaceFromLinearData(viewer, { id: 'workspace-1', name: 'Remote Org' }) linearConnect.mockResolvedValueOnce({ ok: true, viewer: workspace, workspace }) await store.getState().connectLinear('new-runtime-key') expect(store.getState().linearSearchCache).toEqual({}) }) it('preserves an actual Connect result while a later account snapshot owns availability', async () => { const connectResult = deferred<{ ok: true; viewer: LinearViewer }>() const viewer = { displayName: 'User', email: null, organizationName: 'Org' } linearConnect.mockReturnValueOnce(connectResult.promise) linearStatus.mockResolvedValue({ connected: false, viewer: null }) const store = createTestStore() const connect = store.getState().connectLinear('linear-key') await store.getState().checkLinearConnection(true) connectResult.resolve({ ok: true, viewer }) await expect(connect).resolves.toEqual({ ok: true, viewer }) expect(store.getState().linearStatus.connected).toBe(false) expect(linearStatus).toHaveBeenCalledTimes(1) }) it('ignores stale direct status writes after a newer mutation', async () => { const testResult = deferred<{ ok: true; viewer: LinearViewer }>() const staleStatus = deferred() const viewer = { displayName: 'Test User', email: 'test@example.com', organizationName: 'Test Org' } linearTestConnection.mockReturnValueOnce(testResult.promise) linearStatus.mockReturnValueOnce(staleStatus.promise) linearDisconnect.mockResolvedValueOnce(undefined) const store = createTestStore() const testPromise = store.getState().testLinearConnection() testResult.resolve({ ok: true, viewer }) await Promise.resolve() await store.getState().disconnectLinear() staleStatus.resolve({ connected: true, viewer }) await testPromise expect(store.getState().linearStatus.connected).toBe(false) expect(store.getState().linearStatus.viewer).toBeNull() }) }) describe('account observations stay separate from query transport', () => { beforeEach(() => { vi.resetAllMocks() clearLinearRequestMaps() }) const confirmed: LinearConnectionStatus = { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1', workspaces: [ { id: 'workspace-1', organizationId: 'org', organizationName: 'Acme', displayName: 'User', email: null }, { id: 'workspace-2', organizationId: 'org2', organizationName: 'Other', displayName: 'User', email: null } ] } function connectedStore() { const store = createTestStore() store.setState({ linearStatus: confirmed, linearStatusObserved: true, linearStatusChecked: true, linearStatusContextKey: 'local#0' }) return store } it('consumes exact Connect/test/disconnect acknowledgements even when status delivery fails', async () => { const store = connectedStore() const workspace = { ...confirmed.workspaces![0], displayName: 'User', email: null } const acknowledgement = { ok: true, viewer: workspace, workspace } linearStatus.mockRejectedValue(new Error('unreachable')) linearDisconnectWorkspace.mockResolvedValue(undefined) await store.getState().disconnectLinearWorkspace(workspace.id) expect(store.getState().linearStatus.workspaces?.map((item) => item.id)).toEqual([ 'workspace-2' ]) linearConnect.mockResolvedValue(acknowledgement) await expect(store.getState().connectLinear('fixture-token')).resolves.toEqual(acknowledgement) expect(store.getState().linearStatus.workspaces?.map((item) => item.id)).toEqual([ 'workspace-2', 'workspace-1' ]) linearTestConnection.mockResolvedValue(acknowledgement) await expect(store.getState().testLinearConnection(workspace.id)).resolves.toEqual( acknowledgement ) expect(store.getState().linearStatus.connected).toBe(true) expect(linearStatus).not.toHaveBeenCalled() }) it('does not lose an actual Connect acknowledgement to a concurrent failed account delivery', async () => { const store = connectedStore() const ack = Promise.withResolvers<{ ok: true viewer: LinearViewer workspace: NonNullable[number] }>() linearConnect.mockReturnValue(ack.promise) const action = store.getState().connectLinear('fixture-token') linearStatus.mockRejectedValue(new Error('unreachable')) await store.getState().checkLinearConnection(true) const workspace = workspaceFromLinearData( { displayName: 'User', email: null }, { id: 'workspace-1', name: 'Acme' } ) ack.resolve({ ok: true, viewer: workspace, workspace }) await expect(action).resolves.toMatchObject({ ok: true }) expect( store.getState().linearStatus.workspaces?.find((item) => item.id === workspace.id) ).toEqual(workspace) }) it('revalidates repeated revisionless acknowledgements only in their scope and preserves another scope credential error', async () => { const store = connectedStore() const workspace = workspaceFromLinearData( { displayName: 'User', email: null }, { id: 'workspace-1', name: 'Acme' } ) const error = credentialDecryptionMessage('Linear') store.setState({ linearStatus: { ...confirmed, credentialError: error } }) const acknowledgement = { ok: true, viewer: workspace, workspace } linearConnect.mockResolvedValue(acknowledgement) linearTestConnection.mockResolvedValue(acknowledgement) linearStatus.mockRejectedValue(new Error('unreachable')) linearSearchIssues.mockResolvedValue([issue('healthy')]) const read = (workspaceId: string | null) => store.getState().searchLinearIssues('warm', 12, { workspaceId }) await read('workspace-2') await getInflightStatusRequest()?.promise const healthy = store.getState().linearSearchCache['workspace-2::search::warm::12'] for (const action of ['connect', 'connect', 'test'] as const) { await read('workspace-1') await read(null) await getInflightStatusRequest()?.promise const calls = linearSearchIssues.mock.calls.length await (action === 'connect' ? store.getState().connectLinear('fixture-token') : store.getState().testLinearConnection(workspace.id)) expect(store.getState().linearStatus.credentialError).toBe(error) expect(store.getState().linearSearchCache['workspace-1::search::warm::12']).toBeUndefined() expect(store.getState().linearSearchCache['default::search::warm::12']).toBeUndefined() expect(store.getState().linearSearchCache['workspace-2::search::warm::12']).toBe(healthy) await read('workspace-1') await getInflightStatusRequest()?.promise await read(null) await read('workspace-2') await getInflightStatusRequest()?.promise expect(linearSearchIssues).toHaveBeenCalledTimes(calls + 2) expect(store.getState().linearStatus.workspaces?.map((item) => item.id)).toEqual([ 'workspace-2', 'workspace-1' ]) } expect(workspace).not.toHaveProperty('credentialRevision') }) it('does not turn SSH authentication or a renderer-synthesized auth error into account rejection', async () => { const store = connectedStore() store.setState({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only active runtime is read from these fixture settings. settings: { activeRuntimeEnvironmentId: 'remote' } as never, linearStatusContextKey: 'runtime:remote#0' }) linearStatus.mockRejectedValue(new Error('unreachable')) linearListProjectIssues.mockRejectedValue( new Error('Timed out while waiting for SSH authentication') ) const result = await store.getState().listLinearProjectIssues('project', 'workspace-1') expect(result?.errors).toMatchObject([{ workspaceId: 'workspace-1', type: 'unknown' }]) await getInflightStatusRequest()?.promise expect(store.getState().linearStatus).toBe(confirmed) expect(getInflightStatusRequest()).toBeNull() }) it('does not let a held select snapshot re-add a scope rejected by the handler when status fails', async () => { const store = connectedStore() const held = deferred() linearSelectWorkspace.mockReturnValue(held.promise) const select = store.getState().selectLinearWorkspace('workspace-1') linearStatus.mockRejectedValue(new Error('unreachable')) linearListIssues.mockResolvedValue({ items: [{ ...issue('healthy'), workspaceId: 'workspace-2' }], errors: [ { workspaceId: 'workspace-1', type: 'auth', message: 'Linear authentication expired for this workspace.' } ] }) await store.getState().listLinearIssues({ kind: 'list' }, { workspaceId: 'all' }) await getInflightStatusRequest()?.promise held.resolve(confirmed) await select expect(store.getState().linearStatus.workspaces?.map((item) => item.id)).toEqual([ 'workspace-2' ]) }) it('retains healthy concrete and aggregate cached observations during affected-scope revalidation', async () => { const store = connectedStore() const healthy = { ...issue('healthy'), workspaceId: 'workspace-2' } linearSearchIssues.mockResolvedValue([healthy]) await store.getState().searchLinearIssues('warm', 12, { workspaceId: 'workspace-2' }) await store.getState().searchLinearIssues('warm', 12, { workspaceId: 'all' }) const sources: TaskSourceContext[] = [ { kind: 'task-source', provider: 'linear', projectId: 'project', hostId: 'local' }, { kind: 'task-source', provider: 'linear', projectId: 'project', hostId: 'local', accountLabel: 'Account' }, { kind: 'task-source', provider: 'linear', projectId: 'project', hostId: 'runtime:foreign' } ] for (const sourceContext of sources) { for (const workspaceId of ['workspace-1', 'workspace-2', 'all']) { await store.getState().searchLinearIssues('warm', 12, { workspaceId, sourceContext }) } } linearStatus.mockResolvedValue({ ...confirmed, workspaces: [confirmed.workspaces![1]], selectedWorkspaceId: 'workspace-2' }) await store.getState().checkLinearConnection(true) expect(store.getState().linearSearchCache['workspace-2::search::warm::12']?.data).toEqual([ healthy ]) expect(store.getState().linearSearchCache['all::search::warm::12']?.data).toEqual([healthy]) for (const source of sources) { const prefix = getTaskSourceCacheScope(source) expect( store.getState().linearSearchCache[`${prefix}::workspace-2::search::warm::12`]?.data ).toEqual([healthy]) expect(store.getState().linearSearchCache[`${prefix}::all::search::warm::12`]?.data).toEqual([ healthy ]) expect( Boolean(store.getState().linearSearchCache[`${prefix}::workspace-1::search::warm::12`]) ).toBe(source.hostId !== 'local') } }) it('keeps matching confirmed status/cache on failure, terminates cold unknown, and accepts actual false/disconnect', async () => { const store = connectedStore() store.setState({ linearIssueCache: { warm: { data: issue('warm'), fetchedAt: 1 } } }) linearStatus.mockRejectedValue(new Error('unreachable')) await store.getState().checkLinearConnection(true) expect(store.getState().linearStatus).toBe(confirmed) expect(store.getState().linearIssueCache.warm.data?.id).toBe('warm') expect(getInflightStatusRequest()).toBeNull() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exercises only the runtime setting; unrelated GlobalSettings are not read. store.setState({ settings: { activeRuntimeEnvironmentId: 'other' } as never }) await store.getState().checkLinearConnection(true) expect(store.getState()).toMatchObject({ linearStatus: { connected: false }, linearStatusObserved: false, linearStatusChecked: true, linearStatusContextKey: 'runtime:other#0' }) store.setState({ settings: null }) linearStatus.mockResolvedValue({ connected: false, viewer: null }) await store.getState().checkLinearConnection(true) expect(store.getState()).toMatchObject({ linearStatusObserved: true, linearStatus: { connected: false } }) store.setState({ linearStatus: confirmed }) linearDisconnect.mockResolvedValue(undefined) await store.getState().disconnectLinear() expect(store.getState().linearStatus.connected).toBe(false) }) it.each([false, true])( 'successful all-workspace list with partial=%s survives failed account delivery', async (partial) => { const store = connectedStore() const result: LinearCollectionResult = { items: [{ ...issue('healthy'), workspaceId: 'workspace-2' }], ...(partial ? { errors: [ { workspaceId: 'workspace-1', type: 'auth' as const, message: 'Linear authentication expired for this workspace.' } ] } : {}) } linearListIssues.mockResolvedValue(result) linearStatus.mockRejectedValue(new Error('unreachable')) await expect( store.getState().listLinearIssues({ kind: 'list' }, { workspaceId: 'all' }) ).resolves.toEqual(result) await getInflightStatusRequest()?.promise expect(store.getState().linearStatus.workspaces?.map((workspace) => workspace.id)).toEqual( partial ? ['workspace-2'] : ['workspace-1', 'workspace-2'] ) expect( store.getState().getCachedLinearIssues({ kind: 'list' }, { workspaceId: 'all' }) ).toEqual(result) expect(getInflightStatusRequest()).toBeNull() } ) it('old-host ambiguous empty Retry keeps main query behavior without a status veto', async () => { const store = connectedStore() linearSearchIssues .mockRejectedValueOnce(new Error('401 raw old-host diagnostic')) .mockResolvedValue([]) linearStatus.mockRejectedValue(new Error('unreachable')) await expect(store.getState().searchLinearIssues('legacy', 12)).rejects.toThrow('401') await getInflightStatusRequest()?.promise await expect( store.getState().searchLinearIssues('legacy', 12, { force: true }) ).resolves.toEqual([]) expect(store.getState().linearStatus.connected).toBe(true) linearStatus.mockResolvedValue(confirmed) await expect( store.getState().searchLinearIssues('legacy', 12, { force: true }) ).resolves.toEqual([]) expect(getInflightStatusRequest()).toBeNull() }) it.each([20, 200])( 'real %i-read cohorts bound query and account operations independently', async (N) => { const store = connectedStore() store.setState({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only active runtime is read from these fixture settings. settings: { activeRuntimeEnvironmentId: 'cohort' } as never, linearStatusContextKey: 'runtime:cohort#0' }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) linearStatus.mockResolvedValue(confirmed) linearSearchIssues.mockRejectedValue(new Error('ordinary transport outage')) await Promise.allSettled( Array.from({ length: N }, (_, i) => store.getState().searchLinearIssues(`generic-${i}`, 12)) ) expect(linearSearchIssues).toHaveBeenCalledTimes(N) expect(linearStatus).toHaveBeenCalledTimes(0) linearSearchIssues.mockRejectedValue( new Error('Linear authentication expired for this workspace.') ) await Promise.allSettled( Array.from({ length: N }, (_, i) => store.getState().searchLinearIssues(`auth-${i}`, 12)) ) await getInflightStatusRequest()?.promise expect(linearSearchIssues).toHaveBeenCalledTimes(2 * N) expect(linearStatus).toHaveBeenCalledTimes(1) // Later incompatible cohorts must dispatch again; there is no arbitrary-Q bound of one. await Promise.allSettled([store.getState().searchLinearIssues('later-auth', 12)]) await getInflightStatusRequest()?.promise expect(linearStatus).toHaveBeenCalledTimes(2) expect(getInflightStatusRequest()).toBeNull() warn.mockRestore() } ) it.each(['force', 'runtime', 'mutation'] as const)( 'queued automatic probe cannot dispatch after %s supersedes it', async (successor) => { const store = connectedStore() linearStatus.mockResolvedValue(confirmed) linearDisconnect.mockResolvedValue(undefined) linearSearchIssues.mockRejectedValue(new Error('401 auth failure')) const lease = store.getState().searchLinearIssues('queued', 12).acquire() let next: Promise | undefined lease.subscribe(() => { if (lease.currentView().kind === 'error') { queueMicrotask(() => { if (successor === 'force') { next = store.getState().checkLinearConnection(true) } else if (successor === 'mutation') { next = store.getState().disconnectLinear() } else { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exercises only the runtime setting; unrelated GlobalSettings are not read. store.setState({ settings: { activeRuntimeEnvironmentId: 'new' } as never }) } }) } }) await lease.waitForCurrentTerminal() lease.release() await Promise.resolve() await next await Promise.resolve() expect(linearStatus).toHaveBeenCalledTimes(successor === 'force' ? 1 : 0) expect(getInflightStatusRequest()).toBeNull() } ) })