// @vitest-environment happy-dom import { createElement } from 'react' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { normalizeItem, type RawItem } from '../../src/main/github/project-view/project-view-item-normalization' import '../../src/main/github/project-view/project-view-item-page' import { fetchAllItems } from '../../src/main/github/project-view/project-view-items' import { _resetProjectViewCachesForTests, type OptionalProjectItemField } from '../../src/main/github/project-view/project-view-cache' import { ghExecFileAsync } from '../../src/main/git/command-runner/hosted-cli/gh-exec-file' import type { GitHubProjectRow } from '../../src/shared/github/projects/project-types' import { normalizeLinkedPullRequests } from '../../src/main/github/project-view/project-view-field-normalization' import { projectRelationsTableFixture } from '../../src/shared/github/projects/project-relations.test-fixture' import { sortRows } from '../../src/shared/github/projects/project-group-sort' import ProjectCell from '../../src/renderer/src/components/github-project/ProjectCell' vi.mock('../../src/main/github/execution/github-operation-slots', () => ({ acquire: vi.fn(), release: vi.fn() })) vi.mock('../../src/main/git/command-runner/hosted-cli/gh-exec-file', () => ({ ghExecFileAsync: vi.fn() })) vi.mock('../../src/main/github/project-view/project-host-execution', () => ({ projectHostAuthenticationError: vi.fn().mockResolvedValue(null), projectGhExecOptions: (host?: string) => ({ host }) })) vi.mock('../../src/main/github/api/rate-limit', () => ({ repositoryRateLimitGuard: () => ({ blocked: false }), noteRepositoryRateLimitSpend: vi.fn() })) vi.mock('../../src/main/github/project-view/project-view-config', () => ({ ownerQueryRoot: () => 'organization' })) const parent = { number: 10, title: 'Parent', url: 'https://github.com/o/r/issues/10' } const item: RawItem = { id: 'item-1', type: 'ISSUE', content: { __typename: 'Issue', number: 42, title: 'Issue', parent, subIssuesSummary: { completed: 1, total: 2, percentCompleted: 50 } }, fieldValues: { nodes: [ { __typename: 'ProjectV2ItemFieldPullRequestValue', field: { id: 'field-pr', name: 'Linked pull requests', dataType: 'LINKED_PULL_REQUESTS' }, pullRequests: { totalCount: 12, nodes: Array.from({ length: 10 }, (_, index) => ({ number: index + 1, title: `Change ${index + 1}`, url: `https://github.com/o/r/pull/${index + 1}` })) } } ] } } const args = { owner: 'o', ownerType: 'organization', projectNumber: 1, query: '', first: 100, after: null, unsupportedFields: new Set(), host: 'acme.ghe.com' } as const const prText = '#1, #2, #3, #4, #5, #6, #7, #8, #9, #10 +2 more' // The linked PR overflow renders in its own span, so match the element holding the whole cell text. const cellText = (text: string) => (_: string, element: Element | null) => element?.textContent === text && !Array.from(element.children).some((child) => child.textContent === text) const missingSummary = { extensions: { code: 'undefinedField', typeName: 'Issue', fieldName: 'subIssuesSummary' }, message: "Field 'subIssuesSummary' doesn't exist on type 'Issue'" } const missingPrs = { extensions: { code: 'undefinedType', typeName: 'ProjectV2ItemFieldPullRequestValue' }, message: "No such type ProjectV2ItemFieldPullRequestValue, so it can't be a fragment condition" } const missingParent = { type: 'FIELD_NOT_FOUND', path: ['parent'] } function response( nodes: RawItem[], totalCount = nodes.length, pageInfo: { hasNextPage: boolean; endCursor: string | null } = { hasNextPage: false, endCursor: null } ) { return { stdout: JSON.stringify({ data: { organization: { projectV2: { items: { totalCount, pageInfo, nodes } } } } }), stderr: '' } } function selectedResponse(arguments_: string[]) { const query = arguments_.join(' ') const content = { ...item.content } if (!query.includes('parent {')) { delete content.parent } if (!query.includes('subIssuesSummary {')) { delete content.subIssuesSummary } return response([ { ...item, content, fieldValues: query.includes('... on ProjectV2ItemFieldPullRequestValue') ? item.fieldValues : { nodes: [] } } ]) } beforeEach(() => { vi.clearAllMocks() _resetProjectViewCachesForTests() vi.mocked(ghExecFileAsync) .mockReset() .mockImplementation(async (arguments_) => selectedResponse(arguments_)) }) afterEach(() => { cleanup() vi.restoreAllMocks() }) function normalized(raw: RawItem): GitHubProjectRow { const result = normalizeItem(raw, 0) if (!result.ok) { throw new Error('Expected a project row') } return result.row } function renderColumns(row: GitHubProjectRow): void { for (const [id, dataType] of [ ['field-pr', 'LINKED_PULL_REQUESTS'], ['field-progress', 'SUB_ISSUES_PROGRESS'] ] as const) { render( createElement(ProjectCell, { row, field: { kind: 'field', id, name: dataType, dataType }, editable: false, sourceTarget: { kind: 'local' } }) ) } } describe('Project columns from the items response', () => { it.each([ { errors: [missingSummary], parentDropped: false, summary: false, prs: true }, { errors: [missingPrs], parentDropped: false, summary: true, prs: false }, { errors: [missingParent], parentDropped: true, summary: true, prs: true }, { errors: [missingSummary, missingPrs], parentDropped: false, summary: false, prs: false }, { errors: [{ message: "Cannot query field 'subIssuesSummary' on type 'Issue'." }], parentDropped: false, summary: false, prs: true } ])('omits only unsupported selections: %j', async ({ errors, parentDropped, summary, prs }) => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.mocked(ghExecFileAsync).mockResolvedValueOnce({ stdout: JSON.stringify({ errors }), stderr: '' }) const result = await fetchAllItems(args) if (!result.ok) { throw new Error('Expected compatibility fallback to succeed') } expect(result.parentFieldDropped).toBe(parentDropped) expect(result.rows[0].content.parentIssue).toEqual(parentDropped ? null : parent) expect(warning).toHaveBeenCalledTimes(parentDropped ? 1 : 0) renderColumns(result.rows[0]) expect(Boolean(screen.queryByText('1/2 (50%)'))).toBe(summary) expect(Boolean(screen.queryByText(cellText(prText)))).toBe(prs) expect(ghExecFileAsync).toHaveBeenCalledTimes(2) const query = vi.mocked(ghExecFileAsync).mock.calls[1][0].join(' ') expect(query.includes('parent {')).toBe(!parentDropped) expect(query.includes('subIssuesSummary {')).toBe(summary) expect(query.includes('... on ProjectV2ItemFieldPullRequestValue')).toBe(prs) await fetchAllItems(args) expect(ghExecFileAsync).toHaveBeenCalledTimes(3) await fetchAllItems({ ...args, owner: 'another-owner' }) expect(vi.mocked(ghExecFileAsync).mock.calls[3][0].join(' ')).toContain('subIssuesSummary {') }) it('bounds sequential missing-field retries by the three optional selections', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) for (const error of [missingSummary, missingPrs, missingParent]) { vi.mocked(ghExecFileAsync).mockResolvedValueOnce({ stdout: JSON.stringify({ errors: [error] }), stderr: '' }) } expect(await fetchAllItems(args)).toMatchObject({ ok: true, parentFieldDropped: true }) expect(ghExecFileAsync).toHaveBeenCalledTimes(4) }) it.each(['FORBIDDEN', 'UNAUTHORIZED', 'RATE_LIMITED', 'NETWORK_ERROR'])( 'never caches unrelated %s failures', async (type) => { vi.mocked(ghExecFileAsync).mockResolvedValueOnce({ stdout: JSON.stringify({ errors: [{ type, message: 'Cannot access subIssuesSummary' }] }), stderr: '' }) expect(await fetchAllItems(args)).toMatchObject({ ok: false }) expect(ghExecFileAsync).toHaveBeenCalledTimes(1) expect(await fetchAllItems(args)).toMatchObject({ ok: true, parentFieldDropped: false }) expect(vi.mocked(ghExecFileAsync).mock.calls[1][0].join(' ')).toContain('subIssuesSummary {') } ) it('keeps supported fields and the cursor on pages after a capability retry', async () => { vi.mocked(ghExecFileAsync).mockResolvedValueOnce({ stdout: JSON.stringify({ errors: [missingSummary] }), stderr: '' }) const firstPage = JSON.parse( selectedResponse(['parent {', '... on ProjectV2ItemFieldPullRequestValue']).stdout ) firstPage.data.organization.projectV2.items.pageInfo = { hasNextPage: true, endCursor: 'next' } firstPage.data.organization.projectV2.items.totalCount = 2 vi.mocked(ghExecFileAsync).mockResolvedValueOnce({ stdout: JSON.stringify(firstPage), stderr: '' }) expect(await fetchAllItems(args)).toMatchObject({ ok: true, rows: [expect.objectContaining({ position: 0 }), expect.objectContaining({ position: 1 })] }) const last = vi.mocked(ghExecFileAsync).mock.calls[2][0] expect(last).toContain('after=next') expect(last.join(' ')).not.toContain('subIssuesSummary {') expect(last.join(' ')).toContain('parent {') expect(last.join(' ')).toContain('... on ProjectV2ItemFieldPullRequestValue') }) }) it('sorts linked PRs by their first number and treats count-only metadata as an unknown sort key', () => { const field = { kind: 'field', id: 'prs', name: 'Linked pull requests', dataType: 'LINKED_PULL_REQUESTS' } as const const rows = ['ten', 'two-high', 'count-only', 'two-low', 'absent'].map((id, position) => ({ ...normalized({ id, type: 'ISSUE', content: { title: id } }), position })) const table = projectRelationsTableFixture() table.rows = rows for (const [index, numbers] of [ [0, [10]], [1, [2, 100]], [3, [2, 9]] ] as const) { rows[index].content.linkedPullRequests = { pullRequests: numbers.map((number) => ({ number, title: 'PR', url: `https://github.com/acme/repo/pull/${number}` })), totalCount: numbers.length } } rows[2].content.linkedPullRequests = normalizeLinkedPullRequests({ __typename: 'ProjectV2ItemFieldPullRequestValue', pullRequests: { nodes: [null, {}], totalCount: 3 } }) for (const [direction, expected] of [ ['ASC', ['two-high', 'two-low', 'ten', 'count-only', 'absent']], ['DESC', ['ten', 'two-high', 'two-low', 'count-only', 'absent']] ] as const) { expect( sortRows( { ...table, selectedView: { ...table.selectedView, sortByFields: [{ field, direction }] } }, rows ).map((row) => row.id) ).toEqual(expected) } })