mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
- Lazy-load heavy PR comments and descriptions (Phase 2) only when the PR tab is active, using fast metadata (Phase 1) for the branch chip. - Unmount the PR body when inactive to avoid unnecessary comment tree re-renders and preserve WebView resources during commit text editing. - Implement soft-refresh on HEAD advancement to keep the ready UI visible while re-fetching checks post-commit. - Display the "Aborting..." label only when a merge or rebase abort is actively in flight. - Memoize the git history list and skip branch identity RPCs when gating the dock icon.
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type { GitHubWorkItemDetails, PRCheckDetail, PRInfo } from '../../../src/shared/types'
|
|
import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
|
|
import type { GitHubPrReadOutcome } from './github-pr-rpc'
|
|
import {
|
|
classifyPrSidebarFailure,
|
|
emptyPrSidebarDetails,
|
|
loadPrSidebarData,
|
|
loadPrSidebarDetails,
|
|
resolvePrSidebarDetailsAfterPhase2,
|
|
shouldApplyResult,
|
|
shouldSoftRefreshPrSidebarOnHeadChange,
|
|
type PrSidebarLoadDeps
|
|
} from './mobile-pr-sidebar-state'
|
|
import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller'
|
|
|
|
function ok<T>(result: T): GitHubPrReadOutcome<T> {
|
|
return { ok: true, result }
|
|
}
|
|
function fail<T>(error: string): GitHubPrReadOutcome<T> {
|
|
return { ok: false, error }
|
|
}
|
|
|
|
const PR: PRInfo = {
|
|
number: 7,
|
|
title: 'Feat',
|
|
state: 'open',
|
|
url: 'u',
|
|
checksStatus: 'success',
|
|
updatedAt: 'now',
|
|
mergeable: 'MERGEABLE',
|
|
reviewDecision: null,
|
|
headSha: 'sha-pr'
|
|
} as unknown as PRInfo
|
|
const DETAILS = { item: { number: 7 }, checks: [] } as unknown as GitHubWorkItemDetails
|
|
const CHECKS: PRCheckDetail[] = [
|
|
{ name: 'ci', status: 'completed', conclusion: 'success', url: null }
|
|
]
|
|
|
|
function ghInfo(over: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
|
|
return {
|
|
provider: 'github',
|
|
number: 7,
|
|
title: 'Feat',
|
|
state: 'open',
|
|
url: 'u',
|
|
status: 'success',
|
|
updatedAt: 'now',
|
|
mergeable: 'MERGEABLE',
|
|
...over
|
|
} as HostedReviewInfo
|
|
}
|
|
|
|
describe('classifyPrSidebarFailure', () => {
|
|
it('routes permission/auth messages to blocked', () => {
|
|
expect(classifyPrSidebarFailure('permission denied')).toBe('blocked')
|
|
expect(classifyPrSidebarFailure('GitHub account not connected')).toBe('blocked')
|
|
expect(classifyPrSidebarFailure('HTTP 403 Forbidden')).toBe('blocked')
|
|
expect(classifyPrSidebarFailure('401 Unauthorized')).toBe('blocked')
|
|
})
|
|
|
|
it('routes network/transient messages to error', () => {
|
|
expect(classifyPrSidebarFailure('network timeout')).toBe('error')
|
|
expect(classifyPrSidebarFailure('socket hang up')).toBe('error')
|
|
})
|
|
})
|
|
|
|
describe('loadPrSidebarData', () => {
|
|
function deps(over: Partial<PrSidebarLoadDeps> = {}): PrSidebarLoadDeps {
|
|
return {
|
|
fetchForBranch: vi.fn(async () => ok<HostedReviewInfo | null>(ghInfo())),
|
|
fetchWorktreeLinkedPR: vi.fn(async () => null),
|
|
fetchPRForBranch: vi.fn(async () => ok<PRInfo | null>(PR)),
|
|
fetchWorkItemDetails: vi.fn(async () => ok<GitHubWorkItemDetails | null>(DETAILS)),
|
|
fetchPRChecks: vi.fn(async () => ok<PRCheckDetail[]>(CHECKS)),
|
|
...over
|
|
}
|
|
}
|
|
|
|
it('phase 1 loads pr + checks into ready with details=null (comments deferred)', async () => {
|
|
const d = deps()
|
|
const out = await loadPrSidebarData(d, {
|
|
worktreeId: 'w',
|
|
branch: 'feat',
|
|
headSha: 'sha-status'
|
|
})
|
|
expect(out).toEqual({ kind: 'ready', data: { pr: PR, details: null, checks: CHECKS } })
|
|
// Details (heavy comments payload) are NOT fetched on the critical path.
|
|
expect(d.fetchWorkItemDetails).not.toHaveBeenCalled()
|
|
// forBranch's PR number is threaded into prForBranch as the linked hint.
|
|
expect(d.fetchPRForBranch).toHaveBeenCalledWith('w', { branch: 'feat', linkedPRNumber: 7 })
|
|
// The fetched PR head wins over the route's cached status SHA.
|
|
expect(d.fetchPRChecks).toHaveBeenCalledWith('w', {
|
|
prNumber: 7,
|
|
headSha: 'sha-pr',
|
|
prRepo: null
|
|
})
|
|
})
|
|
|
|
it('passes a null hint when forBranch and the worktree linkedPR are both empty', async () => {
|
|
const d = deps({ fetchForBranch: vi.fn(async () => ok<HostedReviewInfo | null>(null)) })
|
|
await loadPrSidebarData(d, { worktreeId: 'w', branch: 'feat' })
|
|
expect(d.fetchPRForBranch).toHaveBeenCalledWith('w', { branch: 'feat', linkedPRNumber: null })
|
|
})
|
|
|
|
it('falls back to the worktree linkedPR when forBranch has no open PR (closed/merged)', async () => {
|
|
const merged = { ...PR, number: 42, state: 'merged' } as unknown as PRInfo
|
|
const d = deps({
|
|
fetchForBranch: vi.fn(async () => ok<HostedReviewInfo | null>(null)),
|
|
fetchWorktreeLinkedPR: vi.fn(async () => 42),
|
|
fetchPRForBranch: vi.fn(async () => ok<PRInfo | null>(merged))
|
|
})
|
|
const out = await loadPrSidebarData(d, { worktreeId: 'w', branch: 'feat' })
|
|
expect(d.fetchPRForBranch).toHaveBeenCalledWith('w', { branch: 'feat', linkedPRNumber: 42 })
|
|
expect(out).toEqual({ kind: 'ready', data: { pr: merged, details: null, checks: CHECKS } })
|
|
})
|
|
|
|
it('prefers the forBranch open hint over the worktree linkedPR', async () => {
|
|
const d = deps({ fetchWorktreeLinkedPR: vi.fn(async () => 42) })
|
|
await loadPrSidebarData(d, { worktreeId: 'w', branch: 'feat' })
|
|
expect(d.fetchPRForBranch).toHaveBeenCalledWith('w', { branch: 'feat', linkedPRNumber: 7 })
|
|
})
|
|
|
|
it('does not pass non-GitHub hosted-review hints into the GitHub PR lookup', async () => {
|
|
const d = deps({
|
|
fetchForBranch: vi.fn(async () =>
|
|
ok<HostedReviewInfo | null>(ghInfo({ provider: 'gitlab', number: 99 }))
|
|
),
|
|
fetchWorktreeLinkedPR: vi.fn(async () => null)
|
|
})
|
|
await loadPrSidebarData(d, { worktreeId: 'w', branch: 'feat' })
|
|
expect(d.fetchPRForBranch).toHaveBeenCalledWith('w', { branch: 'feat', linkedPRNumber: null })
|
|
})
|
|
|
|
it('is non-fatal when forBranch errors — prForBranch still resolves', async () => {
|
|
const d = deps({ fetchForBranch: vi.fn(async () => fail<HostedReviewInfo | null>('timeout')) })
|
|
const out = await loadPrSidebarData(d, { worktreeId: 'w', branch: 'feat' })
|
|
expect(out.kind).toBe('ready')
|
|
})
|
|
|
|
it('returns the `none` empty state when the branch has no open/linked PR', async () => {
|
|
const out = await loadPrSidebarData(
|
|
deps({ fetchPRForBranch: vi.fn(async () => ok<PRInfo | null>(null)) }),
|
|
{ worktreeId: 'w', branch: 'feat' }
|
|
)
|
|
expect(out).toEqual({ kind: 'none' })
|
|
})
|
|
|
|
it('routes a checks failure through the classifier', async () => {
|
|
const out = await loadPrSidebarData(
|
|
deps({ fetchPRChecks: vi.fn(async () => fail<PRCheckDetail[]>('403 forbidden')) }),
|
|
{ worktreeId: 'w', branch: 'feat' }
|
|
)
|
|
expect(out.kind).toBe('blocked')
|
|
})
|
|
|
|
it('returns an error state when a dep rejects (no escaping rejection)', async () => {
|
|
const d = deps({
|
|
fetchPRForBranch: vi.fn(async () => {
|
|
throw new Error('transport closed')
|
|
})
|
|
})
|
|
const out = await loadPrSidebarData(d, { worktreeId: 'w', branch: 'feat' })
|
|
expect(out).toEqual({ kind: 'error', message: 'transport closed' })
|
|
})
|
|
})
|
|
|
|
describe('loadPrSidebarDetails (phase 2)', () => {
|
|
function deps(over: Partial<PrSidebarLoadDeps> = {}): PrSidebarLoadDeps {
|
|
return {
|
|
fetchForBranch: vi.fn(async () => ok<HostedReviewInfo | null>(ghInfo())),
|
|
fetchWorktreeLinkedPR: vi.fn(async () => null),
|
|
fetchPRForBranch: vi.fn(async () => ok<PRInfo | null>(PR)),
|
|
fetchWorkItemDetails: vi.fn(async () => ok<GitHubWorkItemDetails | null>(DETAILS)),
|
|
fetchPRChecks: vi.fn(async () => ok<PRCheckDetail[]>(CHECKS)),
|
|
...over
|
|
}
|
|
}
|
|
|
|
it('returns the fetched details', async () => {
|
|
expect(await loadPrSidebarDetails(deps(), 'w', 7)).toBe(DETAILS)
|
|
})
|
|
|
|
it('is non-fatal — a details failure yields null rather than erroring the sidebar', async () => {
|
|
const d = deps({
|
|
fetchWorkItemDetails: vi.fn(async () => fail<GitHubWorkItemDetails | null>('network down'))
|
|
})
|
|
expect(await loadPrSidebarDetails(d, 'w', 7)).toBeNull()
|
|
})
|
|
|
|
it('is non-fatal when fetchWorkItemDetails rejects (no escaping rejection)', async () => {
|
|
const d = deps({
|
|
fetchWorkItemDetails: vi.fn(async () => {
|
|
throw new Error('transport closed')
|
|
})
|
|
})
|
|
expect(await loadPrSidebarDetails(d, 'w', 7)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('shouldApplyResult', () => {
|
|
it('applies only the latest load sequence', () => {
|
|
expect(shouldApplyResult(3, 3)).toBe(true)
|
|
expect(shouldApplyResult(2, 3)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('shouldSoftRefreshPrSidebarOnHeadChange', () => {
|
|
it('restarts ready/none/loading so mid-flight head advances are not stuck', () => {
|
|
expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true)
|
|
expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true)
|
|
expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true)
|
|
})
|
|
|
|
it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => {
|
|
expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false)
|
|
expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false)
|
|
expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('resolvePrSidebarDetailsAfterPhase2', () => {
|
|
it('prefers a successful fetch over prior details', () => {
|
|
const prior = emptyPrSidebarDetails(PR)
|
|
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS)
|
|
})
|
|
|
|
it('keeps prior details when the fetch is non-fatal null', () => {
|
|
const prior = emptyPrSidebarDetails(PR)
|
|
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior)
|
|
})
|
|
|
|
it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => {
|
|
const empty = resolvePrSidebarDetailsAfterPhase2({
|
|
fetched: null,
|
|
prior: null,
|
|
pr: PR
|
|
})
|
|
expect(empty.body).toBe('')
|
|
expect(empty.comments).toEqual([])
|
|
expect(empty.item.number).toBe(7)
|
|
expect(empty.item.type).toBe('pr')
|
|
})
|
|
})
|
|
|
|
describe('buildMobilePrSidebarIdentity', () => {
|
|
it('keys identity on worktree + branch, not head SHA', () => {
|
|
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat')
|
|
// Head advances on commit must not form a new identity — soft refresh keeps ready UI.
|
|
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe(
|
|
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })
|
|
)
|
|
})
|
|
|
|
it('is null without a branch and changes when branch or worktree changes', () => {
|
|
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull()
|
|
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
|
|
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' })
|
|
)
|
|
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
|
|
buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' })
|
|
)
|
|
})
|
|
})
|