diff --git a/tests/ui/hooks/useJobButtonRegistry.test.js b/tests/ui/hooks/useJobButtonRegistry.test.js index 5cd8afc80bf..253212d135d 100644 --- a/tests/ui/hooks/useJobButtonRegistry.test.js +++ b/tests/ui/hooks/useJobButtonRegistry.test.js @@ -5,6 +5,9 @@ import { unregisterJobButton, getJobButtonInstance, useJobButtonRegistry, + setPendingScrollTaskRun, + consumePendingScroll, + clearJobButtonRegistry, } from '../../../ui/hooks/useJobButtonRegistry'; import * as locationHelpers from '../../../ui/helpers/location'; @@ -387,4 +390,102 @@ describe('useJobButtonRegistry', () => { }); }); }); + + describe('pending scroll', () => { + const createMockJob = (overrides = {}) => ({ + id: 1, + task_run: 'task-run-123', + visible: true, + ...overrides, + }); + + const createMockFilterModel = () => ({ showJob: jest.fn(() => true) }); + + let rafSpy; + + beforeEach(() => { + clearJobButtonRegistry(); + rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb) => cb()); + }); + + afterEach(() => { + rafSpy.mockRestore(); + clearJobButtonRegistry(); + }); + + it('consumes a pending scroll only for the matching task run', () => { + setPendingScrollTaskRun('task-run-123'); + + expect(consumePendingScroll('other-task-run')).toBe(false); + expect(consumePendingScroll('task-run-123')).toBe(true); + // A pending scroll can only be consumed once. + expect(consumePendingScroll('task-run-123')).toBe(false); + }); + + it('is cleared by clearJobButtonRegistry', () => { + setPendingScrollTaskRun('task-run-123'); + clearJobButtonRegistry(); + + expect(consumePendingScroll('task-run-123')).toBe(false); + }); + + it('scrolls the selected button into view on mount when a scroll is pending', () => { + const job = createMockJob(); + const filterModel = createMockFilterModel(); + locationHelpers.getUrlParam.mockReturnValue('task-run-123'); + setPendingScrollTaskRun('task-run-123'); + + const { result } = renderHook(() => + useJobButtonRegistry(job, filterModel, jest.fn()), + ); + const element = { scrollIntoView: jest.fn() }; + act(() => { + result.current.buttonRef(element); + }); + + expect(element.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + // The pending scroll was consumed by the mount. + expect(consumePendingScroll('task-run-123')).toBe(false); + }); + + it('does not scroll a selected button on mount when no scroll is pending', () => { + const job = createMockJob(); + const filterModel = createMockFilterModel(); + locationHelpers.getUrlParam.mockReturnValue('task-run-123'); + + const { result } = renderHook(() => + useJobButtonRegistry(job, filterModel, jest.fn()), + ); + const element = { scrollIntoView: jest.fn() }; + act(() => { + result.current.buttonRef(element); + }); + + expect(element.scrollIntoView).not.toHaveBeenCalled(); + }); + + it('does not scroll an unselected button even when a scroll is pending', () => { + const job = createMockJob({ task_run: 'other-task-run' }); + const filterModel = createMockFilterModel(); + locationHelpers.getUrlParam.mockReturnValue('task-run-123'); + setPendingScrollTaskRun('task-run-123'); + + const { result } = renderHook(() => + useJobButtonRegistry(job, filterModel, jest.fn()), + ); + const element = { scrollIntoView: jest.fn() }; + act(() => { + result.current.buttonRef(element); + }); + + expect(element.scrollIntoView).not.toHaveBeenCalled(); + // Still pending for the actual selected button. + expect(consumePendingScroll('task-run-123')).toBe(true); + }); + }); }); diff --git a/tests/ui/job-view/Filtering_test.jsx b/tests/ui/job-view/Filtering_test.jsx index bd65eec92e7..3553f67cf77 100644 --- a/tests/ui/job-view/Filtering_test.jsx +++ b/tests/ui/job-view/Filtering_test.jsx @@ -86,6 +86,10 @@ describe('Filtering', () => { }); beforeEach(() => { locationTracker = null; + // Selection clicks in prior tests write params (e.g. selectedTaskRun) + // into the shared window.location; a fresh App mount would otherwise + // treat them as a deep link to a job. + window.history.replaceState(null, null, `/jobs?repo=${repoName}`); }); afterEach(async () => { diff --git a/tests/ui/job-view/SelectedJobFirstLoad_test.jsx b/tests/ui/job-view/SelectedJobFirstLoad_test.jsx new file mode 100644 index 00000000000..dbd0f3f766d --- /dev/null +++ b/tests/ui/job-view/SelectedJobFirstLoad_test.jsx @@ -0,0 +1,198 @@ +import fetchMock from 'fetch-mock'; +import { render, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router'; + +import { AppRoutes } from '../../../ui/App'; +import reposFixture from '../mock/repositories'; +import pushListFixture from '../mock/push_list'; +import jobListFixtureOne from '../mock/job_list/job_1.json'; +import fullJob from '../mock/full_job.json'; +import { getApiUrl } from '../../../ui/helpers/url'; +import { getProjectUrl } from '../../../ui/helpers/location'; +import { + usePushesStore, + initialState, +} from '../../../ui/shared/stores/pushesStore'; +import { useSelectedJobStore } from '../../../ui/shared/stores/selectedJobStore'; +import { clearJobButtonRegistry } from '../../../ui/hooks/useJobButtonRegistry'; + +const repoName = 'autoland'; +// The Gecko Decision Task from the job_1.json fixture, so that the job button +// renders once its push's jobs load. +const taskId = 'VaQoWKTbSdGSwBJn6UZV9g'; +const jobId = 259537193; +const pushId = pushListFixture.results[0].id; +const pushRevision = pushListFixture.results[0].revision; + +const resolvedJob = { + id: jobId, + task_id: taskId, + retry_id: 0, + push_id: pushId, + push_revision: pushRevision, + state: 'completed', + result: 'success', + failure_classification_id: 1, + job_type_name: 'Gecko Decision Task', + job_type_symbol: 'D', + job_group_name: 'unknown', + job_group_symbol: '?', + platform: 'gecko-decision', + platform_option: 'opt', + tier: 1, + duration: 5, + signature: '2aa083621bb989d6acf1151667288d5fe9616178', + last_modified: '2019-08-05T20:19:51.818175', +}; + +const testApp = () => ( + + + +); + +describe('Selected job first load', () => { + beforeAll(() => { + const link = document.createElement('link'); + link.setAttribute('rel', 'icon'); + link.setAttribute('href', 'data:image/png;base64,'); + document.querySelector('head').appendChild(link); + + fetchMock.get( + 'begin:https://treestatus.prod.lando.prod.cloudops.mozgcp.net/trees/', + { + result: { + message_of_the_day: '', + reason: '', + status: 'open', + tree: repoName, + }, + }, + ); + fetchMock.head( + 'begin:https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/', + 404, + ); + fetchMock.get( + `https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/${taskId}/runs/0/artifacts`, + [], + ); + fetchMock.get('begin:https://bugzilla.mozilla.org/rest/bug', { bugs: [] }); + + fetchMock.get(getApiUrl('/repository/'), reposFixture); + fetchMock.get(getApiUrl('/performance/framework/'), {}); + fetchMock.get(getApiUrl('/user/'), []); + fetchMock.get('/revision.txt', []); + fetchMock.get(getApiUrl('/failureclassification/'), []); + fetchMock.get(`begin:${getProjectUrl('/note/?job_id=', repoName)}`, []); + fetchMock.get( + `begin:${getProjectUrl('/bug-job-map/?job_id=', repoName)}`, + [], + ); + fetchMock.get( + `begin:${getProjectUrl('/job-log-url/?job_id=', repoName)}`, + [], + ); + fetchMock.get( + `begin:${getProjectUrl('/performance/job-data/?job_id=', repoName)}`, + [], + ); + + // The eager resolution of the deep-linked task. + fetchMock.get(`${getApiUrl('/jobs/')}?task_id=${taskId}&retry_id=0`, { + count: 1, + results: [resolvedJob], + }); + // The single push named by the resolved job's revision. + fetchMock.get( + `begin:${getProjectUrl('/push/?full=true&count=10&revision=', repoName)}`, + { results: [pushListFixture.results[0]] }, + ); + // That push's job list. + fetchMock.get(`begin:${getApiUrl('/jobs/?push_id=')}`, jobListFixtureOne); + + // Details panel fetches for the selected job. + fetchMock.get(getProjectUrl(`/jobs/${jobId}/`, repoName), { + ...fullJob, + id: jobId, + task_id: taskId, + push_id: pushId, + }); + fetchMock.get( + getProjectUrl(`/jobs/${jobId}/bug_suggestions/`, repoName), + [], + ); + }); + + afterAll(() => { + fetchMock.reset(); + }); + + beforeEach(() => { + window.history.replaceState( + null, + null, + `/jobs?repo=${repoName}&selectedTaskRun=${taskId}.0`, + ); + usePushesStore.setState({ ...initialState }); + useSelectedJobStore.setState({ selectedJob: null }); + clearJobButtonRegistry(); + Element.prototype.scrollIntoView = jest.fn(); + // jsdom reports all-zero rects, which makes isOnScreen() wrongly return + // true and skip scrolling; report an off-screen rect instead. + jest + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(() => ({ + top: 500, + bottom: 520, + left: 0, + right: 100, + width: 100, + height: 20, + x: 0, + y: 500, + })); + fetchMock.resetHistory(); + }); + + test('deep link resolves the job first and loads only its push', async () => { + const { findByTestId, findByText } = render(testApp()); + + // The details panel opens with the deep-linked job. + expect( + await findByTestId('summary-panel', {}, { timeout: 4000 }), + ).toBeInTheDocument(); + expect(await findByText(taskId)).toBeInTheDocument(); + + // Exactly one push fetch, limited to the job's revision. + await waitFor(() => { + const pushCalls = fetchMock + .calls() + .map((call) => call[0]) + .filter((url) => url.includes('/push/')); + expect(pushCalls).toHaveLength(1); + expect(pushCalls[0]).toContain(`revision=${pushRevision}`); + }); + + // The job was resolved before any push was fetched. + const urls = fetchMock.calls().map((call) => call[0]); + const resolveIndex = urls.findIndex((url) => url.includes('task_id=')); + const pushIndex = urls.findIndex((url) => url.includes('/push/')); + expect(resolveIndex).toBeGreaterThanOrEqual(0); + expect(resolveIndex).toBeLessThan(pushIndex); + + // The URL now names the push's revision, alongside the task run. + expect(window.location.search).toContain(`revision=${pushRevision}`); + expect(window.location.search).toContain(`selectedTaskRun=${taskId}.0`); + + // The job button renders selected and was scrolled into view. + await waitFor(() => { + const button = document.querySelector(`button[data-job-id='${jobId}']`); + expect(button).toHaveClass('selected-job'); + }); + // The scroll happens inside requestAnimationFrame once the button mounts. + await waitFor(() => + expect(Element.prototype.scrollIntoView).toHaveBeenCalled(), + ); + }); +}); diff --git a/tests/ui/job-view/stores/selectedJobEagerResolve_test.jsx b/tests/ui/job-view/stores/selectedJobEagerResolve_test.jsx new file mode 100644 index 00000000000..cdca44ff64e --- /dev/null +++ b/tests/ui/job-view/stores/selectedJobEagerResolve_test.jsx @@ -0,0 +1,226 @@ +import { + useSelectedJobStore, + resolveSelectedJobFromUrl, + syncSelectionFromUrl, +} from '../../../../ui/shared/stores/selectedJobStore'; +import { + setPendingScrollTaskRun, + consumePendingScroll, + clearJobButtonRegistry, +} from '../../../../ui/hooks/useJobButtonRegistry'; + +const mockGetList = jest.fn(); + +jest.mock('../../../../ui/models/job', () => ({ + __esModule: true, + default: { + getList: (...args) => mockGetList(...args), + }, +})); + +const taskId = 'OeYt2-iLQSaQb2ashZ_VIQ'; +const pushRevision = '1252c6014d122d48c6782310d5c3f4ae742751cb'; + +const apiJob = (overrides = {}) => ({ + id: 259537372, + task_id: taskId, + retry_id: 0, + push_id: 494796, + push_revision: pushRevision, + state: 'completed', + result: 'success', + job_type_name: 'source-test-mozlint-spell', + ...overrides, +}); + +const notify = jest.fn(); + +const setLocationSearch = (search) => { + window.history.replaceState(null, null, `/jobs${search}`); +}; + +beforeEach(() => { + notify.mockClear(); + mockGetList.mockReset(); + mockGetList.mockResolvedValue({ data: [], failureStatus: null }); + useSelectedJobStore.setState({ selectedJob: null }); + clearJobButtonRegistry(); + setLocationSearch('?repo=autoland'); +}); + +afterEach(() => { + setLocationSearch(''); + clearJobButtonRegistry(); + document.body.innerHTML = ''; +}); + +describe('resolveSelectedJobFromUrl', () => { + it('selects the job returned for a selectedTaskRun with a run id', async () => { + const job = apiJob(); + mockGetList.mockResolvedValue({ data: [job], failureStatus: null }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + const resolved = await resolveSelectedJobFromUrl(notify); + + expect(mockGetList).toHaveBeenCalledWith({ + task_id: taskId, + retry_id: 0, + }); + expect(resolved.id).toBe(job.id); + expect(useSelectedJobStore.getState().selectedJob.id).toBe(job.id); + expect(useSelectedJobStore.getState().selectedJob.task_run).toBe( + `${taskId}.0`, + ); + }); + + it('omits retry_id and picks the highest run when the URL has no run id', async () => { + const run0 = apiJob({ id: 1, retry_id: 0 }); + const run1 = apiJob({ id: 2, retry_id: 1 }); + mockGetList.mockResolvedValue({ data: [run0, run1], failureStatus: null }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}`); + + const resolved = await resolveSelectedJobFromUrl(notify); + + expect(mockGetList).toHaveBeenCalledWith({ task_id: taskId }); + expect(resolved.retry_id).toBe(1); + expect(useSelectedJobStore.getState().selectedJob.id).toBe(2); + }); + + it('resolves a selectedJob id param via the jobs endpoint', async () => { + const job = apiJob(); + mockGetList.mockResolvedValue({ data: [job], failureStatus: null }); + setLocationSearch(`?repo=autoland&selectedJob=${job.id}`); + + const resolved = await resolveSelectedJobFromUrl(notify); + + expect(mockGetList).toHaveBeenCalledWith({ id: job.id }); + expect(resolved.id).toBe(job.id); + expect(useSelectedJobStore.getState().selectedJob.id).toBe(job.id); + }); + + it('returns null without calling the API when no selection params exist', async () => { + setLocationSearch('?repo=autoland'); + + const resolved = await resolveSelectedJobFromUrl(notify); + + expect(resolved).toBeNull(); + expect(mockGetList).not.toHaveBeenCalled(); + }); + + it('notifies and strips the params when the task is not found', async () => { + mockGetList.mockResolvedValue({ data: [], failureStatus: null }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + const resolved = await resolveSelectedJobFromUrl(notify); + + expect(resolved).toBeNull(); + expect(useSelectedJobStore.getState().selectedJob).toBeNull(); + expect(notify).toHaveBeenCalledWith( + expect.stringContaining(taskId), + 'danger', + { sticky: true }, + ); + expect(window.location.search).not.toContain('selectedTaskRun'); + }); +}); + +describe('setSelectedJobFromQueryString with an eagerly resolved job', () => { + it('keeps the eager selection when the jobMap misses but the task matches', () => { + const eagerJob = apiJob({ task_run: `${taskId}.0` }); + useSelectedJobStore.setState({ selectedJob: eagerJob }); + setLocationSearch( + `?repo=autoland&revision=${pushRevision}&selectedTaskRun=${taskId}.0`, + ); + + syncSelectionFromUrl({}, notify); + + expect(useSelectedJobStore.getState().selectedJob).toBe(eagerJob); + }); + + it('keeps the eager selection without querying the db when no revision is set', () => { + const eagerJob = apiJob({ task_run: `${taskId}.0` }); + useSelectedJobStore.setState({ selectedJob: eagerJob }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + syncSelectionFromUrl({}, notify); + + expect(useSelectedJobStore.getState().selectedJob).toBe(eagerJob); + expect(mockGetList).not.toHaveBeenCalled(); + }); + + it('keeps the eager selection for a selectedJob id param', () => { + const eagerJob = apiJob({ task_run: `${taskId}.0` }); + useSelectedJobStore.setState({ selectedJob: eagerJob }); + setLocationSearch(`?repo=autoland&selectedJob=${eagerJob.id}`); + + syncSelectionFromUrl({}, notify); + + expect(useSelectedJobStore.getState().selectedJob).toBe(eagerJob); + expect(mockGetList).not.toHaveBeenCalled(); + }); + + it('still clears a selection for a different task when the jobMap misses', () => { + const otherJob = apiJob({ + task_id: 'Za9t2-iLQSaQb2ashZ_VIQ', + task_run: 'Za9t2-iLQSaQb2ashZ_VIQ.0', + }); + useSelectedJobStore.setState({ selectedJob: otherJob }); + setLocationSearch( + `?repo=autoland&revision=${pushRevision}&selectedTaskRun=${taskId}.0`, + ); + + syncSelectionFromUrl({}, notify); + + expect(useSelectedJobStore.getState().selectedJob).toBeNull(); + }); + + it('marks a pending scroll for the resolved job', async () => { + mockGetList.mockResolvedValue({ data: [apiJob()], failureStatus: null }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + await resolveSelectedJobFromUrl(notify); + + expect(consumePendingScroll(`${taskId}.0`)).toBe(true); + }); + + it('scrolls to the job button when the post-load sync finds the job', () => { + const jobMapJob = apiJob({ task_run: `${taskId}.0` }); + document.body.innerHTML = `
`; + const button = document.querySelector('button'); + button.scrollIntoView = jest.fn(); + // Make isOnScreen() false so scrollToElement actually scrolls. + button.getBoundingClientRect = () => ({ top: 500, bottom: 520 }); + setPendingScrollTaskRun(`${taskId}.0`); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + syncSelectionFromUrl({ [jobMapJob.id]: jobMapJob }, notify); + + expect(button.scrollIntoView).toHaveBeenCalled(); + // The pending scroll was consumed. + expect(consumePendingScroll(`${taskId}.0`)).toBe(false); + }); + + it('does not scroll on sync when no scroll is pending', () => { + const jobMapJob = apiJob({ task_run: `${taskId}.0` }); + document.body.innerHTML = `
`; + const button = document.querySelector('button'); + button.scrollIntoView = jest.fn(); + button.getBoundingClientRect = () => ({ top: 500, bottom: 520 }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + syncSelectionFromUrl({ [jobMapJob.id]: jobMapJob }, notify); + + expect(button.scrollIntoView).not.toHaveBeenCalled(); + }); + + it('swaps the eager selection for the jobMap instance on a hit', () => { + const eagerJob = apiJob({ task_run: `${taskId}.0` }); + const jobMapJob = apiJob({ task_run: `${taskId}.0` }); + useSelectedJobStore.setState({ selectedJob: eagerJob }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + syncSelectionFromUrl({ [jobMapJob.id]: jobMapJob }, notify); + + expect(useSelectedJobStore.getState().selectedJob).toBe(jobMapJob); + }); +}); diff --git a/tests/ui/job-view/stores/selectedJob_test.jsx b/tests/ui/job-view/stores/selectedJob_test.jsx index 6a53fa937c0..154f6b7c04b 100644 --- a/tests/ui/job-view/stores/selectedJob_test.jsx +++ b/tests/ui/job-view/stores/selectedJob_test.jsx @@ -46,9 +46,12 @@ describe('syncSelectionFromUrl', () => { }); it('clears the selected job when the task is not in the loaded jobs', () => { - // The URL still names a task, but the loaded pushes (e.g. after a repo - // switch) don't contain it. - useSelectedJobStore.setState({ selectedJob: testJob }); + // The URL names a task that isn't in the loaded pushes, and the current + // selection is a different job (a matching selection would be kept -- + // see selectedJobEagerResolve_test.jsx). + useSelectedJobStore.setState({ + selectedJob: { ...testJob, id: 1, task_id: 'Za9t2-iLQSaQb2ashZ_VIQ' }, + }); setLocationSearch( '?repo=mozilla-central&selectedTaskRun=OeYt2-iLQSaQb2ashZ_VIQ.0', ); diff --git a/tests/ui/shared/stores/fetchInitialPushes_test.jsx b/tests/ui/shared/stores/fetchInitialPushes_test.jsx new file mode 100644 index 00000000000..3dbce294fce --- /dev/null +++ b/tests/ui/shared/stores/fetchInitialPushes_test.jsx @@ -0,0 +1,151 @@ +import { + usePushesStore, + fetchInitialPushes, + initialState, +} from '../../../../ui/shared/stores/pushesStore'; +import { useSelectedJobStore } from '../../../../ui/shared/stores/selectedJobStore'; +import { clearJobButtonRegistry } from '../../../../ui/hooks/useJobButtonRegistry'; + +const mockJobGetList = jest.fn(); +const mockPushGetList = jest.fn(); + +jest.mock('../../../../ui/models/job', () => ({ + __esModule: true, + default: { + getList: (...args) => mockJobGetList(...args), + }, +})); + +jest.mock('../../../../ui/models/push', () => ({ + __esModule: true, + default: { + getList: (...args) => mockPushGetList(...args), + }, +})); + +const taskId = 'OeYt2-iLQSaQb2ashZ_VIQ'; +const pushRevision = '1252c6014d122d48c6782310d5c3f4ae742751cb'; + +const apiJob = { + id: 259537372, + task_id: taskId, + retry_id: 0, + push_id: 494796, + push_revision: pushRevision, + state: 'completed', + result: 'success', + job_type_name: 'source-test-mozlint-spell', +}; + +const notify = jest.fn(); + +const setLocationSearch = (search) => { + window.history.replaceState(null, null, `/jobs${search}`); +}; + +beforeEach(() => { + notify.mockClear(); + mockJobGetList.mockReset(); + mockPushGetList.mockReset(); + mockJobGetList.mockResolvedValue({ data: [apiJob], failureStatus: null }); + mockPushGetList.mockResolvedValue({ + data: { results: [] }, + failureStatus: null, + }); + usePushesStore.setState({ ...initialState }); + useSelectedJobStore.setState({ selectedJob: null }); + clearJobButtonRegistry(); + setLocationSearch('?repo=autoland'); +}); + +afterEach(() => { + setLocationSearch(''); + clearJobButtonRegistry(); +}); + +describe('fetchInitialPushes', () => { + it('fetches only the resolved job push for a deep link without range params', async () => { + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + const job = await fetchInitialPushes(notify); + + expect(job.id).toBe(apiJob.id); + // The job was resolved before any push fetch. + expect(mockJobGetList).toHaveBeenCalledWith({ + task_id: taskId, + retry_id: 0, + }); + // The push fetch was limited to the job's revision. + expect(mockPushGetList).toHaveBeenCalledTimes(1); + expect(mockPushGetList.mock.calls[0][0]).toMatchObject({ + revision: pushRevision, + }); + // The URL now names the revision, so get-next-N and reloads behave + // consistently with a normal single-revision view. + expect(window.location.search).toContain(`revision=${pushRevision}`); + expect(window.location.search).toContain(`selectedTaskRun=${taskId}.0`); + // The eager selection is set for the details panel. + expect(useSelectedJobStore.getState().selectedJob.id).toBe(apiJob.id); + }); + + it('normalizes a selectedJob id param to selectedTaskRun in the URL', async () => { + setLocationSearch(`?repo=autoland&selectedJob=${apiJob.id}`); + + await fetchInitialPushes(notify); + + expect(mockJobGetList).toHaveBeenCalledWith({ id: apiJob.id }); + expect(window.location.search).toContain(`selectedTaskRun=${taskId}.0`); + expect(window.location.search).not.toContain('selectedJob='); + expect(window.location.search).toContain(`revision=${pushRevision}`); + }); + + it('keeps an explicit range but still resolves the job eagerly', async () => { + setLocationSearch( + `?repo=autoland&fromchange=abcdef123456&selectedTaskRun=${taskId}.0`, + ); + + const job = await fetchInitialPushes(notify); + + expect(job).toBeNull(); + // The user's range params win; no revision rewrite happens. + expect(window.location.search).toContain('fromchange=abcdef123456'); + expect(window.location.search).not.toContain('revision='); + expect(mockPushGetList).toHaveBeenCalledTimes(1); + expect(mockPushGetList.mock.calls[0][0]).toMatchObject({ + fromchange: 'abcdef123456', + }); + // The eager resolution still happened, for details-first loading. + expect(mockJobGetList).toHaveBeenCalledWith({ + task_id: taskId, + retry_id: 0, + }); + }); + + it('fetches the default pushes when there is no selection param', async () => { + setLocationSearch('?repo=autoland'); + + const job = await fetchInitialPushes(notify); + + expect(job).toBeNull(); + expect(mockJobGetList).not.toHaveBeenCalled(); + expect(mockPushGetList).toHaveBeenCalledTimes(1); + expect(mockPushGetList.mock.calls[0][0]).toMatchObject({ count: 10 }); + }); + + it('falls back to the default pushes when the task cannot be resolved', async () => { + mockJobGetList.mockResolvedValue({ data: [], failureStatus: null }); + setLocationSearch(`?repo=autoland&selectedTaskRun=${taskId}.0`); + + const job = await fetchInitialPushes(notify); + + expect(job).toBeNull(); + expect(notify).toHaveBeenCalledWith( + expect.stringContaining(taskId), + 'danger', + { sticky: true }, + ); + expect(mockPushGetList).toHaveBeenCalledTimes(1); + expect(mockPushGetList.mock.calls[0][0]).toMatchObject({ count: 10 }); + expect(mockPushGetList.mock.calls[0][0].revision).toBeUndefined(); + }); +}); diff --git a/ui/hooks/useJobButtonRegistry.js b/ui/hooks/useJobButtonRegistry.js index 12181a75f14..9497f045435 100644 --- a/ui/hooks/useJobButtonRegistry.js +++ b/ui/hooks/useJobButtonRegistry.js @@ -46,11 +46,33 @@ export const clearCurrentlySelectedInstance = () => { currentlySelectedJobId = null; }; +// When a job is selected from the URL (a deep link), the button it belongs +// to may not have rendered yet -- its push's jobs load later. The selection +// code records the task run here, and whichever comes first consumes it: +// the button mounting (below) or the post-load selection sync +// (selectedJobStore), so the job is scrolled into view exactly once. +let pendingScrollTaskRun = null; + +export const setPendingScrollTaskRun = (taskRun) => { + pendingScrollTaskRun = taskRun; +}; + +// Returns true (and consumes the pending scroll) only when the given task +// run is the one waiting to be scrolled to. +export const consumePendingScroll = (taskRun) => { + if (pendingScrollTaskRun && pendingScrollTaskRun === taskRun) { + pendingScrollTaskRun = null; + return true; + } + return false; +}; + // Clear all registry state (useful for tests) export const clearJobButtonRegistry = () => { jobButtonRegistry.clear(); currentlySelectedInstance = null; currentlySelectedJobId = null; + pendingScrollTaskRun = null; }; /** @@ -74,7 +96,6 @@ export function useJobButtonRegistry(job, filterModel, filterPlatformCb) { }); const [isRunnableSelected, setIsRunnableSelected] = useState(false); const buttonRef = useRef(null); - const hasScrolledRef = useRef(false); // Listen for URL changes (popstate) to update selection state useEffect(() => { @@ -116,13 +137,14 @@ export function useJobButtonRegistry(job, filterModel, filterPlatformCb) { filterPlatformCb(getUrlParam('selectedTaskRun')); }, [filterPlatformCb]); - // Callback ref to attach to the button element - scrolls into view when selected + // Callback ref to attach to the button element - scrolls into view when + // the element mounts, is selected, and a pending scroll was requested for + // this job's task run (i.e. the job was selected via the URL before its + // button had rendered). const buttonRefCallback = useCallback( (element) => { buttonRef.current = element; - // Scroll into view when the element mounts and is selected (only on initial load) - if (element && isSelected && !hasScrolledRef.current) { - hasScrolledRef.current = true; + if (element && isSelected && consumePendingScroll(job.task_run)) { // Use requestAnimationFrame to ensure the DOM has fully rendered requestAnimationFrame(() => { if (element && typeof element.scrollIntoView === 'function') { @@ -131,7 +153,7 @@ export function useJobButtonRegistry(job, filterModel, filterPlatformCb) { }); } }, - [isSelected], + [isSelected, job.task_run], ); // Register with job button registry on mount, unregister on unmount diff --git a/ui/job-view/App.jsx b/ui/job-view/App.jsx index 5302801c60e..9a781808a5f 100644 --- a/ui/job-view/App.jsx +++ b/ui/job-view/App.jsx @@ -28,9 +28,12 @@ import UpdateAvailable from './headerbars/UpdateAvailable'; import DetailsPanel from './details/DetailsPanel'; import PushList from './pushes/PushList'; import KeyboardShortcuts from './KeyboardShortcuts'; -import { useNotificationStore } from '../shared/stores/notificationStore'; +import { notify, useNotificationStore } from '../shared/stores/notificationStore'; import { useSelectedJobStore } from '../shared/stores/selectedJobStore'; -import { usePushesStore, fetchPushes } from '../shared/stores/pushesStore'; +import { + usePushesStore, + fetchInitialPushes, +} from '../shared/stores/pushesStore'; import '../css/treeherder.css'; import '../css/treeherder-navbar-panels.css'; @@ -135,6 +138,12 @@ const App = () => { ); const [showShortCuts, setShowShortCuts] = useState(false); const [pushHealthVisibility, setPushHealthVisibility] = useState('try'); + // On a deep link to a job, the push list waits until the job (and the + // revision its push belongs to) has been resolved, so it fetches only + // that push. The details panel is not gated on this. + const [selectionResolved, setSelectionResolved] = useState( + () => !(urlParams.has('selectedTaskRun') || urlParams.has('selectedJob')), + ); const [frameworks, setFrameworks] = useState(null); const [latestSplitPct, setLatestSplitPct] = useState(undefined); @@ -289,8 +298,15 @@ const App = () => { setClassificationMap(ClassificationTypeModel.getMap(types)); }); - // Start (pre)fetching pushes immediately - fetchPushes(); + // Start (pre)fetching pushes immediately. On a deep link to a job this + // resolves the job first (details load right away) and then fetches only + // the push containing it. + fetchInitialPushes(notify).then((job) => { + if (job) { + setRevision(job.push_revision); + } + setSelectionResolved(true); + }); window.addEventListener('resize', updateDimensions, false); window.addEventListener(thEvents.filtersUpdated, handleFiltersUpdated); @@ -435,7 +451,7 @@ const App = () => { {serverChangedDelayed && ( )} - {currentRepo && ( + {currentRepo && selectionResolved && (
job.task_run === selectedTaskRun, + ); if ( - getUrlParam('selectedJob') || - getUrlParam('selectedTaskRun') + (getUrlParam('selectedJob') || selectedTaskRun) && + !selectionInPush ) { clearJobViaUrl(); } @@ -374,6 +391,49 @@ export const usePushesStore = create( // Standalone functions for use outside React components export const fetchPushes = (count, setFromchange) => usePushesStore.getState().fetchPushes(count, setFromchange); + +/** + * The initial push fetch for the jobs view. + * + * When the URL deep-links to a job (``selectedTaskRun`` or ``selectedJob``) + * without naming a push range, resolve the job first -- so the details panel + * loads immediately -- then fetch only the push that contains it, rewriting + * the URL to ``revision=`` so get-next-N and reloads behave + * like any other single-revision view. Explicit range params + * (revision/fromchange/etc.) always win; the job is still resolved eagerly + * so its details load before the pushes do. + * + * Returns the resolved job when the URL was rewritten to its revision, + * otherwise null. + */ +export const fetchInitialPushes = async (notifyFn = notify) => { + const params = getAllUrlParams(); + const hasSelection = + params.has('selectedTaskRun') || params.has('selectedJob'); + + if (hasSelection) { + const hasRange = PUSH_FETCH_KEYS.some((key) => params.has(key)); + + if (!hasRange) { + const job = await resolveSelectedJobFromUrl(notifyFn); + + if (job) { + const newParams = getAllUrlParams(); + newParams.set('revision', job.push_revision); + newParams.delete('selectedJob'); + newParams.set('selectedTaskRun', job.task_run); + replaceUrlSearch(newParams.toString()); + usePushesStore.getState().fetchPushes(); + return job; + } + } else { + // Fire-and-forget: the eager selection only feeds the details panel. + resolveSelectedJobFromUrl(notifyFn); + } + } + usePushesStore.getState().fetchPushes(); + return null; +}; export const pollPushes = () => usePushesStore.getState().pollPushes(); export const clearPushes = () => usePushesStore.getState().clearPushes(); export const setPushes = (pushList, jobMap) => diff --git a/ui/shared/stores/selectedJobStore.js b/ui/shared/stores/selectedJobStore.js index cbac07c5b04..81d4287be38 100644 --- a/ui/shared/stores/selectedJobStore.js +++ b/ui/shared/stores/selectedJobStore.js @@ -12,16 +12,20 @@ import { } from '../../helpers/job'; import { thJobNavSelectors } from '../../helpers/constants'; import { getUrlParam, setUrlParam, setUrlParams } from '../../helpers/location'; -import { updateUrlSearch } from '../../helpers/router'; +import { replaceUrlSearch, updateUrlSearch } from '../../helpers/router'; import JobModel from '../../models/job'; import { getJobsUrl } from '../../helpers/url'; +import { + consumePendingScroll, + setPendingScrollTaskRun, +} from '../../hooks/useJobButtonRegistry'; -const doSelectJob = (job) => { +const doSelectJob = (job, scrollTo = false) => { const selected = findSelectedInstance(); if (selected) selected.setSelected(false); - const newSelectedElement = findJobInstance(job.id); + const newSelectedElement = findJobInstance(job.id, scrollTo); if (newSelectedElement) { newSelectedElement.setSelected(true); @@ -42,6 +46,16 @@ const doSelectJob = (job) => { return { selectedJob: job }; }; +// Consume a pending deep-link scroll for this job only when its button is +// actually rendered; otherwise leave the pending scroll for the button's +// mount callback (useJobButtonRegistry) to consume once it appears. +const consumePendingScrollIfRendered = (job) => { + const jobEl = document.querySelector( + `#push-list button[data-job-id='${job.id}']`, + ); + return jobEl ? consumePendingScroll(getTaskRunStr(job)) : false; +}; + // ``countPinnedJobs`` may be a number of pinned jobs, or a pinned-jobs // object ({} when called from the URL-sync paths). Only skip clearing // when jobs are actually pinned. @@ -96,7 +110,7 @@ const searchDatabaseForTaskRun = async (jobParams, notify) => { export const useSelectedJobStore = create( devtools( - (set) => ({ + (set, get) => ({ selectedJob: null, setSelectedJob: (job, updateDetails = true) => { @@ -156,7 +170,20 @@ export const useSelectedJobStore = create( if (task) { setUrlParam('selectedJob'); setUrlParam('selectedTaskRun', getTaskRunStr(task)); - set(doSelectJob(task)); + set(doSelectJob(task, consumePendingScrollIfRendered(task))); + return; + } + + // The job may have been eagerly resolved from the URL before its + // push (and thus the jobMap) finished loading. Keep that + // selection rather than clearing it or re-querying the database. + const { selectedJob: currentSelection } = get(); + if ( + currentSelection && + currentSelection.task_id === taskId && + (runId === undefined || + currentSelection.retry_id === parseInt(runId, 10)) + ) { return; } @@ -187,7 +214,13 @@ export const useSelectedJobStore = create( if (task) { setUrlParam('selectedJob'); setUrlParam('selectedTaskRun', getTaskRunStr(task)); - set(doSelectJob(task)); + set(doSelectJob(task, consumePendingScrollIfRendered(task))); + return; + } + + // Keep an eagerly resolved selection for this same job id. + const { selectedJob: currentSelection } = get(); + if (currentSelection && currentSelection.id === selectedJobId) { return; } @@ -324,3 +357,59 @@ export const syncSelectionFromUrl = (jobMap, notify) => { store.setSelectedJobFromQueryString(notify, jobMap); }; +/** + * Eagerly resolve the job named by the ``selectedTaskRun`` or ``selectedJob`` + * URL param via the jobs API, before any pushes have loaded. This lets the + * details panel start fetching immediately on a deep link, and returns the + * job (including its ``push_revision``) so the caller can load just the push + * that contains it. + */ +export const resolveSelectedJobFromUrl = async (notify) => { + const selectedTaskRun = getUrlParam('selectedTaskRun'); + const selectedJobId = getUrlParam('selectedJob'); + let jobParams; + + if (selectedTaskRun) { + const { taskId, runId } = getTaskRun(selectedTaskRun); + if (taskId === undefined) { + return null; + } + jobParams = { task_id: taskId }; + if (runId !== undefined) { + jobParams.retry_id = parseInt(runId, 10); + } + } else if (selectedJobId) { + jobParams = { id: parseInt(selectedJobId, 10) }; + } else { + return null; + } + + const { data: taskList, failureStatus } = await JobModel.getList(jobParams); + + if (!failureStatus && taskList.length) { + // Without an explicit run id there may be several runs; select the latest. + const runs = [...taskList].sort((left, right) => left.retry_id - right.retry_id); + const job = runs[runs.length - 1]; + job.task_run = getTaskRunStr(job); + useSelectedJobStore.setState({ selectedJob: job }); + // Scroll to the job button once it renders (or when the post-load + // selection sync finds it, whichever happens first). + setPendingScrollTaskRun(job.task_run); + return job; + } + + // The task wasn't found in the db. Either never existed, or was expired + // and deleted. + const message = selectedTaskRun + ? `Task not found: ${selectedTaskRun}` + : `Job ID not found: ${selectedJobId}`; + notify(message, 'danger', { sticky: true }); + replaceUrlSearch( + setUrlParams([ + ['selectedTaskRun', null], + ['selectedJob', null], + ]), + ); + return null; +}; +