Files
orca/mobile/src/session/pr-actions-engine.test.ts
T
Kelvin Amoaba 89e272abc5 fix(mobile): use desktop's merge gating and PR link rules in the PR panel (#33982)
* fix(mobile): gate PR merge and linking on desktop's shared rules

Merge state moves to src/shared so the phone stops offering Merge on drafts,
conflicts and blocked PRs, and stops letting branch PRs override explicit links.

* fix(mobile): keep stack merges and stale-link recovery in the PR panel

Mobile now applies desktop's stack override and drops a linked PR the branch has moved
past, and only offers Ready for review when a relaying desktop also forwards it.

* fix(mobile): persist stale PR link clears and add a merge recheck

The phone now writes desktop's link clear instead of rereading the stale link, and a
blocked Merge can be rechecked since nothing on mobile polls PR state.

* fix(mobile): guard stale PR link clears like desktop

Re-reads the worktree before clearing so a link chosen after the lookup survives, and
localizes the merge-state strings the lift exposed to the coverage gate.

* test(mobile): drop identity test for worktree link params

* refactor(mobile): share one stack-merge decision and trim PR panel wiring

* fix(mobile): default worktree bare/archived flags for the stale-link guard

* fix(mobile): label the PR merge button from its default merge method
2026-10-27 02:12:00 +00:00

246 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
markReadyForReview: 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 newer = deferred<GitHubPrMutationOutcome>()
const engine = makeEngine({
mergePR: () => slow.promise,
updatePRState: () => newer.promise
})
const mergePromise = engine.merge()
expect(engine.isBusy({ kind: 'merge' })).toBe(true)
const statePromise = engine.updateState('closed')
expect(engine.isBusy({ kind: 'state' })).toBe(true)
// Late merge completion must not clear a newer action’s busy state.
slow.resolve({ ok: true })
await mergePromise
expect(engine.isBusy({ kind: 'state' })).toBe(true)
newer.resolve({ ok: true })
await statePromise
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 }),
markReadyForReview: 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 }),
markReadyForReview: 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 }),
markReadyForReview: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: () => {},
onChange
})
// Already-clear error state must not add a redundant action-start notification.
onChange.mockClear()
const p = engine.merge()
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 }),
markReadyForReview: 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 }),
markReadyForReview: 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 }),
markReadyForReview: 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()
})
})