test(tasks): cover the PR-error envelope end-to-end; neutral no-more-results toast

Round-7 review: the two literal gh-utils mocks lacked classifyListPrsError
(a PR-side rejection in those suites would TypeError instead of assert),
and the producer half of the errors.prs contract had no main-side test —
added both, plus a classifier contract test pinning the search-window
phrase the renderer keys on. The refused-clamp toast now reads the
committed count via a synchronous ref mirror instead of the click-time
closure, and says 'No more results' — nothing failed on that branch.
Both toast keys plus the new one are translated in es/ja/ko/zh.
This commit is contained in:
Brennan Benson
2026-07-30 09:04:45 -07:00
parent fe8bfcc2d7
commit d8550400e8
9 changed files with 68 additions and 20 deletions
@@ -63,7 +63,8 @@ vi.mock('./gh-utils', () => ({
release: releaseMock,
_resetOwnerRepoCache: vi.fn(),
classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }),
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr })
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }),
classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr })
}))
vi.mock('../git/runner', () => ({
@@ -266,6 +267,22 @@ describe('listWorkItems query paging', () => {
expect(items.map((item) => item.number)).toEqual([4, 3])
})
it('lifts a swallowed PR-side failure onto errors.prs instead of reading as end-of-data', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
// Non-availability failure (plain 403): swallowed into [], must still surface.
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403: Forbidden'))
const envelope = await listWorkItems('/repo-root', 10, 'is:pr is:open', 2)
expect(envelope.items).toEqual([])
expect(envelope.errors?.issues).toBeUndefined()
expect(envelope.errors?.prs).toEqual({
type: 'unknown',
message: expect.stringContaining('HTTP 403')
})
})
it('filters pull request rows out of issue Search API results', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
+2 -1
View File
@@ -55,7 +55,8 @@ vi.mock('./gh-utils', () => ({
release: releaseMock,
_resetOwnerRepoCache: vi.fn(),
classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }),
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr })
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }),
classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr })
}))
vi.mock('../git/runner', () => ({
+12
View File
@@ -795,4 +795,16 @@ describe('gh error classification', () => {
message: 'Issues are disabled on this repository.'
})
})
// Why: the renderer detects the Search API 1000-result window by matching
// /first 1000 search results/i against this message (#11485) — trimming the
// raw stderr out of the validation_error copy would silently downgrade every
// window 422 to a generic failure. This pins the contract.
it('keeps the search-window phrase in validation_error list messages', () => {
const stderr =
'Command failed: gh api --hostname github.com search/issues\nValidation Failed: Only the first 1000 search results are available (HTTP 422)'
const classified = classifyListIssuesError(stderr)
expect(classified.type).toBe('validation_error')
expect(classified.message).toMatch(/first 1000 search results/i)
})
})
+18 -13
View File
@@ -3823,6 +3823,9 @@ export default function TaskPage(): React.JSX.Element {
// can't resurrect proven-unreachable pages, nor be pinned by a speculative
// withdrawal (see deriveAdvertisedTotalPages).
const [provenPageLimit, setProvenPageLimit] = useState<number | null>(null)
// Why: synchronous mirror of countedTotalPages — the empty-page branch needs
// the committed value, not a click-time closure, and refs update immediately.
const countedTotalPagesRef = useRef<number | null>(null)
const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage)
const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos)
@@ -6228,23 +6231,24 @@ export default function TaskPage(): React.JSX.Element {
{ id: 'work-items-page-load-failed' }
)
} else {
// Why: with a real count the clamp is refused, so without a toast
// the click would look dead — the count mis-advertised the page.
// countedTotalPages is a click-time closure read; worst case the
// toast decision is off in a race, which is benign.
if (countedTotalPages !== null && countedTotalPages > 0) {
toast.error(
// Why: with a real count the clamp is refused, so without feedback
// the click would look dead — the count over-advertised; nothing
// failed, so the copy stays neutral. The ref carries the committed
// count, immune to the click-time closure race.
const committedCount = countedTotalPagesRef.current
if (committedCount !== null && committedCount > 0) {
toast(
translate(
'auto.components.TaskPage.loadPageFailed',
'Page {{value0}} could not be loaded from GitHub.',
'auto.components.TaskPage.loadPageNoMoreResults',
'No more results on page {{value0}}.',
{ value0: String(target + 1) }
),
{ id: 'work-items-page-load-failed' }
{ id: 'work-items-page-no-more-results' }
)
}
setCountedTotalPages((previous) =>
applyEmptyPageClamp(previous, { target, failedCount, errorTypes })
)
const next = applyEmptyPageClamp(committedCount, { target, failedCount, errorTypes })
countedTotalPagesRef.current = next
setCountedTotalPages(next)
}
return
}
@@ -6270,7 +6274,6 @@ export default function TaskPage(): React.JSX.Element {
paginationLoading,
selectedRepos,
currentPage,
countedTotalPages,
appliedTaskSearch,
fetchWorkItemsNextPage,
githubPageSize,
@@ -6354,6 +6357,7 @@ export default function TaskPage(): React.JSX.Element {
setPages([page0])
setCurrentPage(0)
setCountedTotalPages(null)
countedTotalPagesRef.current = null
setProvenPageLimit(null)
setTasksError(null)
setFailedCount(0) // reset so a prior failure banner doesn't linger
@@ -6462,6 +6466,7 @@ export default function TaskPage(): React.JSX.Element {
// Why: the count overwrites unconditionally — proven window limits live
// in provenPageLimit, so a late count can't be pinned by a speculative
// end-of-data withdrawal, and can't resurrect proven-dead pages either.
countedTotalPagesRef.current = countedPages
setCountedTotalPages(countedPages)
}
})
+2 -1
View File
@@ -1891,7 +1891,8 @@
"noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved.",
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.",
"loadPageUnreachable": "Page {{value0}} is beyond what GitHub search can return.",
"loadPageFailed": "Page {{value0}} could not be loaded from GitHub."
"loadPageFailed": "Page {{value0}} could not be loaded from GitHub.",
"loadPageNoMoreResults": "No more results on page {{value0}}."
},
"Terminal": {
"73768427cf": "Close",
+4 -1
View File
@@ -1865,7 +1865,10 @@
"75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.",
"noGithubSourceDetected": "No GitHub source detected for",
"noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved.",
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again."
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.",
"loadPageUnreachable": "La página {{value0}} está más allá de lo que la búsqueda de GitHub puede devolver.",
"loadPageFailed": "No se pudo cargar la página {{value0}} desde GitHub.",
"loadPageNoMoreResults": "No hay más resultados en la página {{value0}}."
},
"Terminal": {
"73768427cf": "Cerrar",
+4 -1
View File
@@ -1865,7 +1865,10 @@
"75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.",
"noGithubSourceDetected": "No GitHub source detected for",
"noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved.",
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again."
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.",
"loadPageUnreachable": "ページ {{value0}} は GitHub 検索が返せる範囲を超えています。",
"loadPageFailed": "GitHub からページ {{value0}} を読み込めませんでした。",
"loadPageNoMoreResults": "ページ {{value0}} にはこれ以上結果がありません。"
},
"Terminal": {
"73768427cf": "閉じる",
+4 -1
View File
@@ -1865,7 +1865,10 @@
"75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.",
"noGithubSourceDetected": "No GitHub source detected for",
"noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved.",
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again."
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.",
"loadPageUnreachable": "{{value0}} 페이지는 GitHub 검색이 반환할 수 있는 범위를 벗어났습니다.",
"loadPageFailed": "GitHub에서 {{value0}} 페이지를 불러오지 못했습니다.",
"loadPageNoMoreResults": "{{value0}} 페이지에 더 이상 결과가 없습니다."
},
"Terminal": {
"73768427cf": "닫기",
+4 -1
View File
@@ -1865,7 +1865,10 @@
"75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.",
"noGithubSourceDetected": "No GitHub source detected for",
"noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved.",
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again."
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.",
"loadPageUnreachable": "第 {{value0}} 页超出了 GitHub 搜索可返回的范围。",
"loadPageFailed": "无法从 GitHub 加载第 {{value0}} 页。",
"loadPageNoMoreResults": "第 {{value0}} 页没有更多结果。"
},
"Terminal": {
"73768427cf": "关闭",