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;
+};
+