orca/src/main/ipc/github.test.ts

238 lines
6.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handleMock,
getPRForBranchMock,
getIssueMock,
listIssuesMock,
listWorkItemsMock,
getAuthenticatedViewerMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
getPRForBranchMock: vi.fn(),
getIssueMock: vi.fn(),
listIssuesMock: vi.fn(),
listWorkItemsMock: vi.fn(),
getAuthenticatedViewerMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('../github/client', () => ({
getPRForBranch: getPRForBranchMock,
getIssue: getIssueMock,
listIssues: listIssuesMock,
listWorkItems: listWorkItemsMock,
getAuthenticatedViewer: getAuthenticatedViewerMock
}))
import { registerGitHubHandlers } from './github'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('registerGitHubHandlers', () => {
const handlers: HandlerMap = {}
type FixtureRepo = {
id: string
path: string
displayName: string
badgeColor: string
addedAt: number
connectionId?: string | null
issueSourcePreference?: 'origin' | 'upstream'
}
let repos: FixtureRepo[] = []
const store = {
getRepos: () => repos
}
const stats = {
hasCountedPR: () => false,
record: vi.fn()
}
beforeEach(() => {
handleMock.mockReset()
getPRForBranchMock.mockReset()
getIssueMock.mockReset()
listIssuesMock.mockReset()
listWorkItemsMock.mockReset()
getAuthenticatedViewerMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
// Reset fixture repos to the default single-repo fixture each test, so
// individual tests can mutate the list without leaking preferences across
// tests (e.g. a preference-threading test could otherwise shadow the
// default-undefined assertions in sibling tests).
repos = [
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
]
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
})
it('normalizes registered repo paths before invoking github clients', async () => {
getPRForBranchMock.mockResolvedValue({ number: 42 })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:prForBranch'](null, {
repoPath: '/workspace/repo/../repo',
branch: 'feature/test'
})
expect(getPRForBranchMock).toHaveBeenCalledWith(
'/workspace/repo',
'feature/test',
null,
null,
null
)
})
it('rejects unknown repository paths', async () => {
registerGitHubHandlers(store as never, stats as never)
expect(() =>
handlers['gh:issue'](null, {
repoPath: '/workspace/other',
number: 7
})
).toThrow('Access denied: unknown repository path')
expect(getIssueMock).not.toHaveBeenCalled()
})
it('forwards listIssues for registered repositories and unwraps items', async () => {
listIssuesMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
const result = await handlers['gh:listIssues'](null, {
repoPath: '/workspace/repo',
limit: 5
})
expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, undefined, null)
expect(result).toEqual([])
})
it('drops the error field from listIssues envelope at the IPC boundary', async () => {
// Why: src/main/ipc/github.ts intentionally unwraps the { items, error? }
// envelope to just `items` to preserve the pre-feature-1
// `Promise<IssueInfo[]>` contract for `gh:listIssues`. Feature 1's UI
// consumes the richer envelope through `gh:listWorkItems` instead. This
// test locks in that intentional drop so a future change that starts
// propagating the error through this channel (or that throws when an
// error is present) is caught.
listIssuesMock.mockResolvedValue({
items: [],
error: {
type: 'permission_denied',
message:
"You don't have permission to read issues for this repository. Check your GitHub token scopes."
}
})
registerGitHubHandlers(store as never, stats as never)
const result = await handlers['gh:listIssues'](null, {
repoPath: '/workspace/repo',
limit: 5
})
expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, undefined, null)
expect(result).toEqual([])
})
it('threads issueSourcePreference through gh:listIssues', async () => {
// Why: repo.issueSourcePreference must reach listIssues so the upstream
// repo is queried when configured. A regression that drops the arg would
// pass the default-fixture tests (which assert `undefined`) silently, so
// this test pins the non-undefined preference-threading contract.
repos[0].issueSourcePreference = 'upstream'
listIssuesMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:listIssues'](null, {
repoPath: '/workspace/repo',
limit: 5
})
expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, 'upstream', null)
})
it('threads issueSourcePreference through gh:listWorkItems', async () => {
// Why: gh:listWorkItems must also forward repo.issueSourcePreference
// (5th arg) so the work-items view honors the per-repo source selector.
repos[0].issueSourcePreference = 'origin'
listWorkItemsMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:listWorkItems'](null, {
repoPath: '/workspace/repo',
limit: 10,
query: 'is:open',
before: 'cursor-1'
})
expect(listWorkItemsMock).toHaveBeenCalledWith(
'/workspace/repo',
10,
'is:open',
'cursor-1',
'origin',
null
)
})
it('threads SSH connectionId through GitHub work-item handlers', async () => {
repos[0].connectionId = 'openclaw-2'
listWorkItemsMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:listWorkItems'](null, {
repoPath: '/workspace/repo',
limit: 10,
query: ''
})
expect(listWorkItemsMock).toHaveBeenCalledWith(
'/workspace/repo',
10,
'',
undefined,
undefined,
'openclaw-2'
)
})
it('forwards the authenticated viewer lookup', async () => {
getAuthenticatedViewerMock.mockResolvedValue({ login: 'octocat', email: 'octocat@example.com' })
registerGitHubHandlers(store as never, stats as never)
await expect(handlers['gh:viewer'](null, undefined)).resolves.toEqual({
login: 'octocat',
email: 'octocat@example.com'
})
expect(getAuthenticatedViewerMock).toHaveBeenCalled()
})
})