Files
orca/tests/e2e/github-pr-hosted-receipt-loop.unit.test.ts
T

407 lines
15 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 {
commands,
fixture,
found,
mocks,
rpcRead
} from '../../src/main/runtime/integrations/reviews/runtime-hosted-pr-snapshot-test-harness'
import type { PRRefreshOutcome } from '../../src/shared/github/pull-requests/refresh/pull-request-refresh-types'
describe('hosted PR receipt delivery', () => {
it('carries a real deferred reply through the visible target, store and scheduler to its completion', async () => {
const {
createTestStore,
makePRRefreshWorktree,
resetRemoteRuntimeMocks,
runtimeEnvironmentCall
} = await import('../../src/renderer/src/store/github/github-slice-test-harness')
const { getVisibleHostedReviewRefreshTargets } =
await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets')
const { createVisibleHostedReviewRefreshScheduler } =
await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler')
const { PrForBranch } =
await import('../../src/shared/rpc-contract/github/github-pull-request-params')
const readReceipt = (reason: 'active' | 'swr') =>
commands.getRepoPRForBranch(
'repo',
'receipt',
12,
null,
false,
'head-a',
reason,
undefined,
undefined,
undefined,
true
)
resetRemoteRuntimeMocks()
const store = createTestStore()
const seed = await readReceipt('active')
if (!('kind' in seed) || seed.kind !== 'found') {
throw new Error('expected the initial PR')
}
store.setState({
repos: [
{
id: 'repo',
path: '/repo',
kind: 'git',
executionHostId: 'runtime:env-1',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 0
}
],
worktreesByRepo: {
repo: [
makePRRefreshWorktree({
id: 'receipt-worktree',
repoId: 'repo',
branch: 'receipt',
head: 'head-a',
linkedPR: 12,
hostId: 'runtime:env-1',
runtimeOwnerEnvironmentId: 'env-1'
})
]
},
detectedWorktreesByRepo: {},
activeWorktreeId: 'receipt-worktree',
activeWorkspaceExecutionHostId: 'runtime:env-1',
visibleReviewWorktreeIds: ['receipt-worktree'],
prCache: { 'runtime:env-1::repo::receipt': { data: seed.pr, fetchedAt: 1_000 } }
})
const reasons: string[] = []
const replies: unknown[] = []
runtimeEnvironmentCall.mockImplementation(async (args) => {
if (args.method !== 'github.prForBranch') {
throw new Error('unexpected fixture RPC')
}
const params = PrForBranch.parse(args.params)
expect(params).toMatchObject({
repo: 'repo',
branch: 'receipt',
linkedPRNumber: 12,
currentHeadOid: 'head-a',
reason: 'swr',
acceptRevalidationOnly: true,
selected: true
})
reasons.push(params.reason!)
const result = await rpcRead(args.params)
replies.push(result)
return { id: 'rpc', ok: true, result }
})
const timerProvider = Promise.withResolvers<PRRefreshOutcome>()
const freshProvider = Promise.withResolvers<PRRefreshOutcome>()
mocks.resolve
.mockReturnValueOnce(timerProvider.promise)
.mockReturnValueOnce(freshProvider.promise)
const scheduler = createVisibleHostedReviewRefreshScheduler()
const update = () =>
scheduler.update(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState))
const unsubscribe = store.subscribe(update)
try {
expect(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState)).toMatchObject([
{ selected: true, intervalMs: 60_000, fetchedAt: 1_000 }
])
update()
scheduler.setVisible(true)
await vi.advanceTimersByTimeAsync(60_000)
// Fake timers defer the host's nested zero-delay drain to the next clock tick.
await vi.advanceTimersByTimeAsync(1)
expect(reasons).toEqual(['swr'])
expect(replies).toEqual([])
expect(mocks.resolve).toHaveBeenCalledTimes(2)
const otherClient = readReceipt('active')
await vi.advanceTimersByTimeAsync(0)
fixture.title = 'superseded provider result'
timerProvider.resolve(found())
await vi.advanceTimersByTimeAsync(0)
expect(replies).toMatchObject([{ fetchedAt: 1_000, revalidation: { retryAfterMs: 10_000 } }])
expect(store.getState().prCache['runtime:env-1::repo::receipt']?.data?.title).toBe('before')
expect(store.getState().prCache['runtime:env-1::repo::receipt']?.fetchedAt).toBe(1_000)
fixture.title = 'completed by another client'
freshProvider.resolve(found())
await otherClient
await vi.advanceTimersByTimeAsync(9_999)
expect(reasons).toEqual(['swr'])
await vi.advanceTimersByTimeAsync(1)
expect(reasons).toEqual(['swr', 'swr'])
expect(store.getState().prCache['runtime:env-1::repo::receipt']?.data?.title).toBe(
'completed by another client'
)
expect(mocks.resolve).toHaveBeenCalledTimes(3)
} finally {
unsubscribe()
scheduler.dispose()
}
})
})
it('applies data-bearing retry replies to both clients and sends selection after an unselected wait', async () => {
const seed = await commands.getRepoPRForBranch(
'repo',
'shared',
12,
null,
false,
'head-a',
'active'
)
if (!('kind' in seed) || seed.kind !== 'found') {
throw new Error('expected initial PR')
}
const clients: {
select: () => void
title: () => string | undefined
fetchedAt: () => number | undefined
dispose: () => void
}[] = []
const selection: boolean[][] = [[], []]
const receipts: unknown[][] = [[], []]
const reasons: string[] = []
for (let client = 0; client < 2; client += 1) {
// Separate module graphs retain each renderer's own request coalescer.
vi.resetModules()
const {
createTestStore,
makePRRefreshWorktree,
resetRemoteRuntimeMocks,
runtimeEnvironmentCall
} = await import('../../src/renderer/src/store/github/github-slice-test-harness')
const { getVisibleHostedReviewRefreshTargets } =
await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets')
const { createVisibleHostedReviewRefreshScheduler } =
await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler')
resetRemoteRuntimeMocks()
runtimeEnvironmentCall.mockImplementation(async (args) => {
if (args.method !== 'github.prForBranch') {
throw new Error('unexpected fixture RPC')
}
const { PrForBranch } =
await import('../../src/shared/rpc-contract/github/github-pull-request-params')
const params = PrForBranch.parse(args.params)
const requestClient = 'selector' in args && args.selector === 'env-client-0' ? 0 : 1
reasons.push(params.reason ?? 'missing')
selection[requestClient].push(params.selected === true)
const result = await rpcRead(args.params)
receipts[requestClient].push(result)
return { id: 'rpc', ok: true, result }
})
const store = createTestStore()
store.setState({
repos: [
{
id: 'repo',
path: '/repo',
kind: 'git',
executionHostId: `runtime:env-client-${client}`,
displayName: 'repo',
badgeColor: 'blue',
addedAt: 0
}
],
worktreesByRepo: {
repo: [
makePRRefreshWorktree({
id: 'shared-worktree',
repoId: 'repo',
branch: 'shared',
head: 'head-a',
linkedPR: 12,
hostId: `runtime:env-client-${client}`,
runtimeOwnerEnvironmentId: `env-client-${client}`
})
]
},
detectedWorktreesByRepo: {},
activeWorktreeId: null,
activeWorkspaceExecutionHostId: `runtime:env-client-${client}`,
visibleReviewWorktreeIds: ['shared-worktree'],
prCache: {
[`runtime:env-client-${client}::repo::shared`]: { data: seed.pr, fetchedAt: seed.fetchedAt }
}
})
const scheduler = createVisibleHostedReviewRefreshScheduler()
const update = () =>
scheduler.update(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState))
const unsubscribe = store.subscribe(update)
update()
scheduler.setVisible(true)
clients.push({
select: () => store.setState({ activeWorktreeId: 'shared-worktree' }),
title: () =>
store.getState().prCache[`runtime:env-client-${client}::repo::shared`]?.data?.title,
fetchedAt: () =>
store.getState().prCache[`runtime:env-client-${client}::repo::shared`]?.fetchedAt,
dispose: () => {
unsubscribe()
scheduler.dispose()
}
})
}
try {
// A second client's fresh result can settle in the same millisecond as the first.
fixture.title = 'changed on another client'
await commands.getRepoPRForBranch('repo', 'shared', 12, null, false, 'head-a', 'active')
mocks.gate.mockResolvedValue(181_000)
await vi.advanceTimersByTimeAsync(120_001)
// The first job learns the quota pause after admission, so its wait ends at the response deadline.
await vi.advanceTimersByTimeAsync(25_000)
expect(selection).toEqual([[false], [false]])
for (let client = 0; client < 2; client += 1) {
expect(receipts[client]).toMatchObject([
{
kind: 'found',
pr: { title: 'changed on another client' },
revalidation: { retryAfterMs: expect.any(Number) }
}
])
expect(clients[client].title()).toBe('changed on another client')
expect(clients[client].fetchedAt()).toBe(1_000)
clients[client].select()
}
mocks.gate.mockResolvedValue(null)
await vi.advanceTimersByTimeAsync(70_000)
expect(selection).toEqual([
[false, true],
[false, true]
])
expect(reasons).toEqual(['swr', 'swr', 'swr', 'swr'])
for (const client of clients) {
expect(client.title()).toBe('changed on another client')
expect(client.fetchedAt()).toBe(181_000)
}
expect(mocks.resolve).toHaveBeenCalledTimes(3)
} finally {
for (const client of clients) {
client.dispose()
}
}
})
it('holds a return under Minimal until the host’s real Retry-After on the request, then reads once', async () => {
const {
createTestStore,
makePRRefreshWorktree,
resetRemoteRuntimeMocks,
runtimeEnvironmentCall
} = await import('../../src/renderer/src/store/github/github-slice-test-harness')
const { getVisibleHostedReviewRefreshTargets } =
await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets')
const { createVisibleHostedReviewRefreshScheduler } =
await import('../../src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler')
const { PrForBranch } =
await import('../../src/shared/rpc-contract/github/github-pull-request-params')
const policy = await import('../../src/shared/review-refresh-policy')
const coordinator =
await import('../../src/main/github/pull-requests/refresh/pr-refresh-coordinator')
const seed = await commands.getRepoPRForBranch(
'repo',
'held',
12,
null,
false,
'head-a',
'active'
)
if (!('kind' in seed) || seed.kind !== 'found') {
throw new Error('expected the initial PR')
}
resetRemoteRuntimeMocks()
const store = createTestStore()
store.setState({
repos: [
{
id: 'repo',
path: '/repo',
kind: 'git',
executionHostId: 'runtime:env-1',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 0
}
],
worktreesByRepo: {
repo: [
makePRRefreshWorktree({
id: 'held-worktree',
repoId: 'repo',
branch: 'held',
head: 'head-a',
linkedPR: 12,
hostId: 'runtime:env-1',
runtimeOwnerEnvironmentId: 'env-1'
})
]
},
detectedWorktreesByRepo: {},
activeWorktreeId: 'held-worktree',
activeWorkspaceExecutionHostId: 'runtime:env-1',
visibleReviewWorktreeIds: ['held-worktree'],
prCache: { 'runtime:env-1::repo::held': { data: seed.pr, fetchedAt: 1_000 } }
})
// The host runs Minimal: its timer reads every 600 s, Default would every 60 s.
policy.setPRAutoRefreshCadenceForHost('env-1', 'minimal')
const reasons: string[] = []
runtimeEnvironmentCall.mockImplementation(async (args) => {
if (args.method !== 'github.prForBranch') {
throw new Error('unexpected fixture RPC')
}
reasons.push(PrForBranch.parse(args.params).reason ?? 'missing')
return { id: 'rpc', ok: true, result: await rpcRead(args.params) }
})
const observed: string[] = []
const removeObserver = coordinator.addPRRefreshOutcomeObserver((_candidate, outcome) =>
observed.push(outcome.kind)
)
const scheduler = createVisibleHostedReviewRefreshScheduler()
const update = () =>
scheduler.update(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState))
const unsubscribe = store.subscribe(update)
try {
// Shown at 61 s with data a Default interval old, it is read at once; GitHub answers with a
// secondary rate limit and a 300 s Retry-After, while the primary quota stays healthy.
await vi.advanceTimersByTimeAsync(60_000)
expect(getVisibleHostedReviewRefreshTargets(store.getState(), store.getState)).toMatchObject([
{ selected: true, cadence: 'minimal', intervalMs: 600_000, foregroundIntervalMs: 60_000 }
])
mocks.resolve.mockRejectedValueOnce(
Object.assign(new Error('gh: HTTP 429'), {
stderr: 'HTTP 429: You have exceeded a secondary rate limit.\nRetry-After: 300'
})
)
update()
scheduler.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(reasons).toEqual(['active'])
expect(mocks.resolve).toHaveBeenCalledTimes(2)
const observedAfterFailure = observed.length
// Hidden, then back at 122 s: Default would retry the failure from 121 s, but the host holds
// this request until 361 s, so neither the return nor anything else reads it sooner.
scheduler.setVisible(false)
await vi.advanceTimersByTimeAsync(61_000)
scheduler.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(reasons).toEqual(['active'])
await vi.advanceTimersByTimeAsync(238_999)
expect(reasons).toEqual(['active'])
expect(mocks.resolve).toHaveBeenCalledTimes(2)
expect(observed).toHaveLength(observedAfterFailure)
// The owed return reads once, as soon as the hold ends.
await vi.advanceTimersByTimeAsync(1)
expect(reasons).toEqual(['active', 'active'])
expect(mocks.resolve).toHaveBeenCalledTimes(3)
} finally {
removeObserver()
unsubscribe()
scheduler.dispose()
policy._resetPRAutoRefreshCadencesForTests()
}
})