Files
orca/mobile/src/session/pr-actions-engine.test.ts
T

241 lines
7.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { PrActionsEngine, type PrActionMutations } from './pr-actions-engine'
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}
function makeEngine(mutations: Partial<PrActionMutations>) {
const ok = async (): Promise<GitHubPrMutationOutcome> => ({ ok: true })
return new PrActionsEngine({
mutations: {
mergePR: ok,
setPRAutoMerge: ok,
updatePRState: ok,
requestReviewers: ok,
removeReviewers: ok,
rerunChecks: ok,
...mutations
},
prNumber: 1,
refetch: () => {},
onChange: () => {}
})
}
describe('PrActionsEngine — scoped busy clear (overlapping actions)', () => {
it('a late-resolving action does not clear a newer action busy state', async () => {
const slow = deferred<GitHubPrMutationOutcome>()
const engine = makeEngine({
// merge resolves slowly; state resolves immediately.
mergePR: () => slow.promise,
updatePRState: async () => ({ ok: true })
})
// Start merge (sets busy = merge) but don't let it resolve yet.
const mergePromise = engine.merge()
expect(engine.isBusy({ kind: 'merge' })).toBe(true)
// While merge is in-flight, run state to completion (sets then clears busy=state).
await engine.updateState('closed')
// State began after merge, so it overwrote busy to 'state', then cleared its own.
expect(engine.isBusy({ kind: 'state' })).toBe(false)
// Now let the slow merge finish. Its finally must NOT clear a busy it no longer owns.
slow.resolve({ ok: true })
await mergePromise
// busy stays null (state already cleared); the key point is merge didn't clobber.
expect(engine.busy).toBeNull()
})
it('clears its own busy when it is still the owner', async () => {
const engine = makeEngine({})
await engine.merge()
expect(engine.busy).toBeNull()
})
})
describe('PrActionsEngine — transport-rejection-normalized outcomes settle cleanly', () => {
it('routes a { ok:false } outcome to error and clears busy', async () => {
const onChange = vi.fn()
const engine = new PrActionsEngine({
mutations: {
mergePR: async () => ({ ok: false, error: 'socket hung up' }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: () => {},
onChange
})
await engine.merge()
expect(engine.error).toBe('socket hung up')
expect(engine.busy).toBeNull()
})
it('surfaces a refetch throw as error without rejecting the action promise', async () => {
const engine = new PrActionsEngine({
mutations: {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: async () => {
throw new Error('refresh failed')
},
onChange: () => {}
})
await expect(engine.merge()).resolves.toBeUndefined()
expect(engine.error).toBe('refresh failed')
expect(engine.busy).toBeNull()
})
it('does not notify on no-op setError(null) at action start', async () => {
const onChange = vi.fn()
const engine = new PrActionsEngine({
mutations: {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: () => {},
onChange
})
// Idle: error is already null. Action start must only notify for busy, not a
// redundant clearError path.
onChange.mockClear()
const p = engine.merge()
// First notify is setBusy only (setError(null) no-ops).
expect(onChange).toHaveBeenCalledTimes(1)
await p
})
})
describe('PrActionsEngine — PR identity changes', () => {
it('clears optimistic state when the engine points at a different PR', async () => {
const slow = deferred<GitHubPrMutationOutcome>()
const mutations: PrActionMutations = {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => slow.promise,
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
}
const refetch = vi.fn()
const onChange = vi.fn()
const engine = new PrActionsEngine({
mutations,
prNumber: 1,
refetch,
onChange
})
const action = engine.setAutoMerge(true)
expect(engine.resolveAutoMerge(false)).toBe(true)
engine.updateConfig({
mutations,
prNumber: 2,
refetch,
onChange
})
expect(engine.resolveAutoMerge(false)).toBe(false)
expect(engine.busy).toBeNull()
slow.resolve({ ok: true })
await action
expect(refetch).not.toHaveBeenCalled()
})
it('clears reviewer optimism when switching PR identity', async () => {
const slow = deferred<GitHubPrMutationOutcome>()
const mutations: PrActionMutations = {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => slow.promise,
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
}
const refetch = vi.fn()
const onChange = vi.fn()
const engine = new PrActionsEngine({
mutations,
prNumber: 1,
refetch,
onChange
})
const action = engine.requestReviewer('alice')
expect(engine.resolveReviewerRequested('alice', false)).toBe(true)
engine.updateConfig({
mutations,
prNumber: 2,
refetch,
onChange
})
expect(engine.resolveReviewerRequested('alice', false)).toBe(false)
slow.resolve({ ok: true })
await action
expect(refetch).not.toHaveBeenCalled()
})
it('clears in-flight state when only the GitHub host changes', async () => {
const slow = deferred<GitHubPrMutationOutcome>()
const mutations: PrActionMutations = {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => slow.promise,
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
}
const refetch = vi.fn()
const onChange = vi.fn()
const baseConfig = {
mutations,
prNumber: 1,
refetch,
onChange
}
const engine = new PrActionsEngine({
...baseConfig,
prRepo: { owner: 'acme', repo: 'widgets' }
})
const action = engine.setAutoMerge(true)
expect(engine.resolveAutoMerge(false)).toBe(true)
expect(engine.isBusy({ kind: 'autoMerge' })).toBe(true)
engine.updateConfig({
...baseConfig,
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }
})
expect(engine.resolveAutoMerge(false)).toBe(false)
expect(engine.busy).toBeNull()
slow.resolve({ ok: true })
await action
expect(refetch).not.toHaveBeenCalled()
})
})