From b362e9932da8a3d35c74d1259d86c84296916870 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Sat, 20 Jun 2026 09:18:38 -0700 Subject: [PATCH 1/2] Handle expired Taskcluster tasks in the details panel Previously, selecting a job whose Taskcluster task had expired caused queue.task() to reject inside fetchTaskData, which rejected the whole Promise.all in useJobDetails and left selectedJobFull as null. Both SummaryPanel and TabsPanel gate their content on selectedJobFull, so the details panel rendered completely blank with no explanation. Catch the queue.task() failure, fall back to defaults so the rest of the panel renders, and surface an "Expired" badge next to the State line. Disable actions that need a live task definition (Retrigger, Cancel, Backfill, Create Interactive Task, Create Gecko Profile, Generate side-by-side, Confirm Test Failures, Custom Action) with tooltips explaining why. Log buttons render disabled, and an expired log returns a clear "no longer available" message in the log viewer. --- .../details/summary/ActionBar_test.jsx | 51 +++++++++++++ .../job-view/details/summary/LogItem.test.jsx | 29 +++++++ .../details/summary/StatusPanel_test.jsx | 27 +++++++ .../details/summary/SummaryPanel_test.jsx | 65 ++++++++++++++++ .../ui/job-view/details/useJobDetails.test.js | 54 +++++++++++++ tests/ui/logviewer/ClassicLogViewer_test.jsx | 43 +++++++++++ tests/ui/logviewer/useLogViewer_test.js | 12 +++ ui/job-view/details/DetailsPanel.jsx | 2 + ui/job-view/details/summary/ActionBar.jsx | 75 +++++++++++++++---- ui/job-view/details/summary/LogItem.jsx | 18 +++++ ui/job-view/details/summary/LogUrls.jsx | 11 ++- ui/job-view/details/summary/StatusPanel.jsx | 16 +++- ui/job-view/details/summary/SummaryPanel.jsx | 21 +++++- ui/job-view/details/useJobDetails.js | 22 +++++- ui/logviewer/ClassicLogViewer.jsx | 8 ++ ui/logviewer/useLogViewer.js | 10 ++- 16 files changed, 436 insertions(+), 28 deletions(-) create mode 100644 tests/ui/job-view/details/summary/ActionBar_test.jsx create mode 100644 tests/ui/job-view/details/summary/StatusPanel_test.jsx create mode 100644 tests/ui/job-view/details/summary/SummaryPanel_test.jsx create mode 100644 tests/ui/job-view/details/useJobDetails.test.js create mode 100644 tests/ui/logviewer/ClassicLogViewer_test.jsx diff --git a/tests/ui/job-view/details/summary/ActionBar_test.jsx b/tests/ui/job-view/details/summary/ActionBar_test.jsx new file mode 100644 index 00000000000..418a44c13cb --- /dev/null +++ b/tests/ui/job-view/details/summary/ActionBar_test.jsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; + +import ActionBar from '../../../../../ui/job-view/details/summary/ActionBar'; +import { + usePushesStore, + initialState as pushesInitialState, +} from '../../../../../ui/job-view/stores/pushesStore'; + +const baseProps = { + selectedJobFull: { + id: 1, + task_id: 'TASK_ID', + state: 'completed', + push_id: 1, + resultStatus: 'success', + job_group_name: 'Build', + job_type_name: 'build-linux', + job_type_symbol: 'B', + submit_timestamp: 0, + }, + user: { isLoggedIn: true, email: 'me@example.com' }, + logParseStatus: 'parsed', + currentRepo: { name: 'autoland', tc_root_url: 'https://tc.example' }, + jobLogUrls: [], + jobDetails: [], +}; + +describe('ActionBar expired-task disabling', () => { + beforeEach(() => { + usePushesStore.setState({ + ...pushesInitialState, + decisionTaskMap: { 1: { id: 'DEC_TASK' } }, + }); + }); + + it('does not disable Retrigger by default', () => { + render(); + + expect(screen.getByTitle(/^Retrigger job/)).not.toBeDisabled(); + }); + + it('disables Retrigger when taskExpired is true', () => { + render(); + + const retrigger = screen.getByTitle(/^Retrigger job/); + expect(retrigger).toBeDisabled(); + expect(retrigger.getAttribute('title')).toContain( + 'Taskcluster task expired', + ); + }); +}); diff --git a/tests/ui/job-view/details/summary/LogItem.test.jsx b/tests/ui/job-view/details/summary/LogItem.test.jsx index d0fc58b8779..c3aa1d76c95 100644 --- a/tests/ui/job-view/details/summary/LogItem.test.jsx +++ b/tests/ui/job-view/details/summary/LogItem.test.jsx @@ -216,6 +216,35 @@ describe('LogItem', () => { }); }); + describe('expired Taskcluster task', () => { + it('renders a disabled button with an expired tooltip even when the log was parsed', () => { + const logUrls = [createLogUrl({ parse_status: 'parsed' })]; + + render( + + View Log + , + ); + + // No active link should be rendered + expect(screen.queryByTestId('logviewer-btn')).not.toBeInTheDocument(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('disabled'); + expect(button).toHaveAttribute( + 'title', + 'Taskcluster task expired — log no longer available', + ); + }); + }); + describe('list item wrapper', () => { it('renders inside an li element', () => { const logUrls = [createLogUrl()]; diff --git a/tests/ui/job-view/details/summary/StatusPanel_test.jsx b/tests/ui/job-view/details/summary/StatusPanel_test.jsx new file mode 100644 index 00000000000..f11d69733aa --- /dev/null +++ b/tests/ui/job-view/details/summary/StatusPanel_test.jsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; + +import StatusPanel from '../../../../../ui/job-view/details/summary/StatusPanel'; + +const baseJob = { + resultStatus: 'success', + result: 'success', + state: 'completed', +}; + +describe('StatusPanel', () => { + it('does not show the Taskcluster expired badge by default', () => { + render(); + + expect( + screen.queryByTestId('taskcluster-expired-badge'), + ).not.toBeInTheDocument(); + }); + + it('shows the Taskcluster expired badge when taskExpired is true', () => { + render(); + + const badge = screen.getByTestId('taskcluster-expired-badge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('Expired'); + }); +}); diff --git a/tests/ui/job-view/details/summary/SummaryPanel_test.jsx b/tests/ui/job-view/details/summary/SummaryPanel_test.jsx new file mode 100644 index 00000000000..87822cdb551 --- /dev/null +++ b/tests/ui/job-view/details/summary/SummaryPanel_test.jsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; + +import SummaryPanel from '../../../../../ui/job-view/details/summary/SummaryPanel'; +import { + usePushesStore, + initialState as pushesInitialState, +} from '../../../../../ui/job-view/stores/pushesStore'; + +const selectedJobFull = { + id: 1, + task_id: 'TASK_ID', + state: 'completed', + result: 'success', + resultStatus: 'success', + push_id: 1, + searchStr: 'test job', + submit_timestamp: 0, + job_type_name: 'build-linux', + job_group_name: 'Build', + job_type_symbol: 'B', + build_platform: 'linux', +}; + +const renderPanel = (extraProps = {}) => + render( + + + , + ); + +describe('SummaryPanel log parsing status', () => { + beforeEach(() => { + usePushesStore.setState({ + ...pushesInitialState, + decisionTaskMap: { 1: { id: 'DEC_TASK' } }, + }); + }); + + it('shows the parse status when the task is not expired', () => { + renderPanel(); + + expect(screen.getByText('Log parsing status:')).toBeInTheDocument(); + expect(screen.getByText('parsed')).toBeInTheDocument(); + expect( + screen.queryByText('Expired, not available'), + ).not.toBeInTheDocument(); + }); + + it('shows an expired message for the log status when the task is expired', () => { + renderPanel({ taskExpired: true }); + + expect(screen.getByText('Expired, not available')).toBeInTheDocument(); + expect(screen.queryByText('parsed')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/ui/job-view/details/useJobDetails.test.js b/tests/ui/job-view/details/useJobDetails.test.js new file mode 100644 index 00000000000..262a16d1f13 --- /dev/null +++ b/tests/ui/job-view/details/useJobDetails.test.js @@ -0,0 +1,54 @@ +import { Queue } from 'taskcluster-client-web'; + +import { fetchTaskData } from '../../../../ui/job-view/details/useJobDetails'; + +describe('fetchTaskData', () => { + let consoleErrorSpy; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('returns defaults without args and does not mark expired', async () => { + expect(await fetchTaskData(null, null)).toEqual({ + testGroups: [], + taskQueueId: null, + taskExpired: false, + }); + }); + + it('marks taskExpired when the Taskcluster task lookup fails', async () => { + Queue.mockImplementationOnce(() => ({ + task: jest.fn().mockRejectedValue(new Error('404: task not found')), + })); + + const result = await fetchTaskData('EXPIRED_TASK_ID', 'https://tc.example'); + + expect(result).toEqual({ + testGroups: [], + taskQueueId: null, + taskExpired: true, + }); + }); + + it('returns task data with taskExpired false on success', async () => { + Queue.mockImplementationOnce(() => ({ + task: jest.fn().mockResolvedValue({ + taskQueueId: 'gecko-3/b-linux', + payload: { env: {} }, + }), + })); + + const result = await fetchTaskData('LIVE_TASK', 'https://tc.example'); + + expect(result).toEqual({ + testGroups: [], + taskQueueId: 'gecko-3/b-linux', + taskExpired: false, + }); + }); +}); diff --git a/tests/ui/logviewer/ClassicLogViewer_test.jsx b/tests/ui/logviewer/ClassicLogViewer_test.jsx new file mode 100644 index 00000000000..b4529df869b --- /dev/null +++ b/tests/ui/logviewer/ClassicLogViewer_test.jsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; + +import ClassicLogViewer from '../../../ui/logviewer/ClassicLogViewer'; + +describe('ClassicLogViewer error states', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + delete global.fetch; + }); + + it('shows an expired message when the log fetch returns 404', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + render(); + + expect( + await screen.findByText( + 'This log has expired and is no longer available.', + ), + ).toBeInTheDocument(); + }); + + it('shows the generic error message for non-404 failures', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Server Error', + }); + + render(); + + expect( + await screen.findByText(/Error loading log:/), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/logviewer/useLogViewer_test.js b/tests/ui/logviewer/useLogViewer_test.js index 48694304be4..60b6a9d335c 100644 --- a/tests/ui/logviewer/useLogViewer_test.js +++ b/tests/ui/logviewer/useLogViewer_test.js @@ -83,10 +83,22 @@ describe('useLogViewer', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.error).toBe('Failed to fetch log: 404 Not Found'); + expect(result.current.errorStatus).toBe(404); expect(result.current.lines).toEqual([]); expect(result.current.lineCount).toBe(0); }); + test('errorStatus is null for non-HTTP failures', async () => { + global.fetch.mockRejectedValue(new Error('Network down')); + + const { result } = renderHook(() => useLogViewer({ url: 'http://bad.txt' })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBe('Network down'); + expect(result.current.errorStatus).toBeNull(); + }); + test('returns empty state when no URL', () => { const { result } = renderHook(() => useLogViewer({})); diff --git a/ui/job-view/details/DetailsPanel.jsx b/ui/job-view/details/DetailsPanel.jsx index d569467421d..480a306e60f 100644 --- a/ui/job-view/details/DetailsPanel.jsx +++ b/ui/job-view/details/DetailsPanel.jsx @@ -44,6 +44,7 @@ function DetailsPanel({ classifications, testGroups, bugs, + taskExpired, } = useJobDetails(selectedJob, currentRepo, pushList, frameworks); const togglePinBoardVisibility = useCallback(() => { @@ -92,6 +93,7 @@ function DetailsPanel({ logViewerFullUrl={logViewerFullUrl} bugs={bugs} user={user} + taskExpired={taskExpired} /> { - const { isTryRepo } = this.props; + const { isTryRepo, taskExpired } = this.props; - return !isTryRepo; + return !isTryRepo && !taskExpired; }; backfillButtonTitle = () => { - const { isTryRepo } = this.props; + const { isTryRepo, taskExpired } = this.props; let title = ''; if (isTryRepo) { title = title.concat('backfill not available in this repository'); } + if (taskExpired) { + title = title.concat( + title ? ' / ' : '', + 'Taskcluster task expired — backfill unavailable', + ); + } + if (title === '') { title = 'Trigger jobs of this type on prior pushes ' + @@ -329,9 +337,13 @@ class ActionBar extends React.PureComponent { jobLogUrls = [], currentRepo, jobDetails, + taskExpired = false, } = this.props; const { customJobActionsShowing } = this.state; const resourceUsageProfile = this.getResourceUsageProfile(); + const expiredTitleSuffix = taskExpired + ? ' (unavailable — Taskcluster task expired)' + : ''; // For running tasks, add the live.log from artifacts for raw log only let rawLogUrls = jobLogUrls; @@ -357,6 +369,7 @@ class ActionBar extends React.PureComponent { rawLogUrls={rawLogUrls} logViewerUrl={logViewerUrl} logViewerFullUrl={logViewerFullUrl} + taskExpired={taskExpired} />
  • @@ -431,9 +445,10 @@ class ActionBar extends React.PureComponent { {this.canCancel() && (
  • @@ -508,16 +523,26 @@ class ActionBar extends React.PureComponent { !taskExpired && this.createInteractiveTask()} > Create Interactive Task {isPerfTest(selectedJobFull) && ( !taskExpired && this.createGeckoProfile()} > Create Gecko Profile @@ -526,8 +551,13 @@ class ActionBar extends React.PureComponent { !selectedJobFull.hasSideBySide && ( !taskExpired && this.createSideBySide()} > Generate side-by-side @@ -535,16 +565,28 @@ class ActionBar extends React.PureComponent { {canConfirmFailure(selectedJobFull) && ( !taskExpired && this.handleConfirmFailure()} > Confirm Test Failures )} !taskExpired && this.toggleCustomJobActions()} + className={`dropdown-item ${ + taskExpired ? 'disabled' : '' + }`} + title={ + taskExpired + ? 'Taskcluster task expired — action unavailable' + : undefined + } > Custom Action... @@ -579,6 +621,7 @@ ActionBar.propTypes = { isTryRepo: PropTypes.bool, logViewerUrl: PropTypes.string, logViewerFullUrl: PropTypes.string, + taskExpired: PropTypes.bool, }; // Wrapper to inject Zustand state into class component diff --git a/ui/job-view/details/summary/LogItem.jsx b/ui/job-view/details/summary/LogItem.jsx index f34b0a2fcbb..2b2c8769c77 100644 --- a/ui/job-view/details/summary/LogItem.jsx +++ b/ui/job-view/details/summary/LogItem.jsx @@ -47,8 +47,25 @@ export default function LogItem(props) { logViewerFullUrl = null, logKey, logDescription, + taskExpired = false, } = props; + // When the Taskcluster task has expired, its log artifacts are gone too, so + // render a disabled button explaining why rather than a dead link. + if (taskExpired) { + return ( +
  • + +
  • + ); + } + return (
  • {/* Case 1: Two or more logurls - Display a dropdown */} @@ -113,4 +130,5 @@ LogItem.propTypes = { logUrls: PropTypes.arrayOf(PropTypes.shape({})).isRequired, logViewerUrl: PropTypes.string, logViewerFullUrl: PropTypes.string, + taskExpired: PropTypes.bool, }; diff --git a/ui/job-view/details/summary/LogUrls.jsx b/ui/job-view/details/summary/LogUrls.jsx index f3862e746fa..cc3cde53303 100644 --- a/ui/job-view/details/summary/LogUrls.jsx +++ b/ui/job-view/details/summary/LogUrls.jsx @@ -8,7 +8,13 @@ import logviewerIcon from '../../../img/logviewerIcon.svg'; import LogItem from './LogItem'; export default function LogUrls(props) { - const { logUrls, rawLogUrls = [], logViewerUrl = null, logViewerFullUrl = null } = props; + const { + logUrls, + rawLogUrls = [], + logViewerUrl = null, + logViewerFullUrl = null, + taskExpired = false, + } = props; const logUrlsUseful = logUrls.filter( (logUrl) => !logUrl.name.includes('perfherder-data'), ); @@ -25,6 +31,7 @@ export default function LogUrls(props) { logViewerFullUrl={logViewerFullUrl} logKey="logviewer" logDescription="log viewer" + taskExpired={taskExpired} > Logviewer @@ -36,6 +43,7 @@ export default function LogUrls(props) { logViewerFullUrl={logViewerFullUrl} logKey="rawlog" logDescription="raw log" + taskExpired={taskExpired} > State: {selectedJobFull.state} + {taskExpired && ( +
    + + Expired + +
    + )}
  • ); } StatusPanel.propTypes = { selectedJobFull: PropTypes.shape({}).isRequired, + taskExpired: PropTypes.bool, }; export default StatusPanel; diff --git a/ui/job-view/details/summary/SummaryPanel.jsx b/ui/job-view/details/summary/SummaryPanel.jsx index 25ad5d6214b..86a4bcf331d 100644 --- a/ui/job-view/details/summary/SummaryPanel.jsx +++ b/ui/job-view/details/summary/SummaryPanel.jsx @@ -21,6 +21,7 @@ class SummaryPanel extends React.PureComponent { user, currentRepo, classificationMap, + taskExpired = false, } = this.props; const logs = jobLogUrls.filter( @@ -29,12 +30,19 @@ class SummaryPanel extends React.PureComponent { const artifacts = jobLogUrls.filter((artifact) => artifact.name.includes('perfherder-data'), ); + + let logParsingValue; + if (taskExpired) { + logParsingValue = 'Expired, not available'; + } else if (!logs.length) { + logParsingValue = 'No logs'; + } else { + logParsingValue = logs.map((log) => log.parse_status).join(', '); + } const logStatus = [ { title: 'Log parsing status', - value: !logs.length - ? 'No logs' - : logs.map((log) => log.parse_status).join(', '), + value: logParsingValue, }, ]; const artifactStatus = [ @@ -69,6 +77,7 @@ class SummaryPanel extends React.PureComponent { logViewerFullUrl={logViewerFullUrl} jobLogUrls={logs} user={user} + taskExpired={taskExpired} />
      @@ -81,7 +90,10 @@ class SummaryPanel extends React.PureComponent { currentRepo={currentRepo} /> )} - + { +export const fetchTaskData = async (taskId, rootUrl) => { let testGroups = []; let taskQueueId = null; if (!taskId || !rootUrl) { - return { testGroups, taskQueueId }; + return { testGroups, taskQueueId, taskExpired: false }; } const queue = new Queue({ rootUrl }); - const taskDefinition = await queue.task(taskId); + let taskDefinition; + try { + taskDefinition = await queue.task(taskId); + } catch (error) { + // Task definition may be unavailable (e.g. expired in Taskcluster). + // Fall back to defaults so the rest of the details panel can still render, + // and flag the task as expired so the UI can communicate the degraded state. + // eslint-disable-next-line no-console + console.error('Error fetching Taskcluster task definition:', error); + return { testGroups, taskQueueId, taskExpired: true }; + } if (taskDefinition) { taskQueueId = taskDefinition.taskQueueId; if (taskDefinition.payload.env?.MOZHARNESS_TEST_PATHS) { @@ -45,7 +55,7 @@ const fetchTaskData = async (taskId, rootUrl) => { } } - return { testGroups, taskQueueId }; + return { testGroups, taskQueueId, taskExpired: false }; }; const fetchClassifications = async (jobId, signal) => { @@ -132,6 +142,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { const [classifications, setClassifications] = useState([]); const [testGroups, setTestGroups] = useState([]); const [bugs, setBugs] = useState([]); + const [taskExpired, setTaskExpired] = useState(false); // Refs for cleanup const abortControllerRef = useRef(null); @@ -180,6 +191,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { // If no job is selected, clear the state if (!selectedJob) { setSelectedJobFull(null); + setTaskExpired(false); previousJobIdRef.current = null; isFirstLoadRef.current = true; return; @@ -304,6 +316,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { setLogViewerFullUrl(fullLogUrl); setJobRevision(push ? push.revision : null); setTestGroups(taskData.testGroups); + setTaskExpired(taskData.taskExpired); setClassifications(classificationsResult.classifications); setBugs(classificationsResult.bugs); @@ -440,6 +453,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { classifications, testGroups, bugs, + taskExpired, }; } diff --git a/ui/logviewer/ClassicLogViewer.jsx b/ui/logviewer/ClassicLogViewer.jsx index f3369ae11a7..753e85f316f 100644 --- a/ui/logviewer/ClassicLogViewer.jsx +++ b/ui/logviewer/ClassicLogViewer.jsx @@ -25,6 +25,7 @@ const ClassicLogViewer = ({ lineCount, isLoading, error, + errorStatus, searchTerm, setSearchTerm, matchLineNumbers, @@ -198,6 +199,13 @@ const ClassicLogViewer = ({ ); if (error) { + if (errorStatus === 404) { + return ( +
      + This log has expired and is no longer available. +
      + ); + } return
      Error loading log: {error}
      ; } diff --git a/ui/logviewer/useLogViewer.js b/ui/logviewer/useLogViewer.js index ae0d310d467..31b114a0ff2 100644 --- a/ui/logviewer/useLogViewer.js +++ b/ui/logviewer/useLogViewer.js @@ -20,6 +20,7 @@ export function useLogViewer({ const [lines, setLines] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [errorStatus, setErrorStatus] = useState(null); const [searchTerm, setSearchTermState] = useState(''); const [matchLineNumbers, setMatchLineNumbers] = useState([]); @@ -45,19 +46,23 @@ export function useLogViewer({ setLines([]); setIsLoading(false); setError(null); + setErrorStatus(null); return; } let cancelled = false; setIsLoading(true); setError(null); + setErrorStatus(null); fetch(url) .then((response) => { if (!response.ok) { - throw new Error( + const err = new Error( `Failed to fetch log: ${response.status} ${response.statusText}`, ); + err.status = response.status; + throw err; } return response.text(); }) @@ -70,6 +75,7 @@ export function useLogViewer({ .catch((err) => { if (cancelled) return; setError(err.message); + setErrorStatus(err.status ?? null); setLines([]); setIsLoading(false); }); @@ -271,6 +277,7 @@ export function useLogViewer({ lineCount, isLoading, error, + errorStatus, // Search searchTerm, setSearchTerm, @@ -301,6 +308,7 @@ export function useLogViewer({ lineCount, isLoading, error, + errorStatus, searchTerm, setSearchTerm, matchLineNumbers, From d4d7afc5a05ead7492c96f21aa0b5ccba5ad9dfc Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Mon, 22 Jun 2026 09:32:30 -0700 Subject: [PATCH 2/2] update ActionBar check for expiredTask into functions --- .../details/summary/ActionBar_test.jsx | 36 ++++++++++ ui/job-view/details/summary/ActionBar.jsx | 68 ++++++++++++++----- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/tests/ui/job-view/details/summary/ActionBar_test.jsx b/tests/ui/job-view/details/summary/ActionBar_test.jsx index 418a44c13cb..a895a7d1994 100644 --- a/tests/ui/job-view/details/summary/ActionBar_test.jsx +++ b/tests/ui/job-view/details/summary/ActionBar_test.jsx @@ -5,6 +5,15 @@ import { usePushesStore, initialState as pushesInitialState, } from '../../../../../ui/job-view/stores/pushesStore'; +import { thEvents } from '../../../../../ui/helpers/constants'; +import JobModel from '../../../../../ui/models/job'; + +jest.mock('../../../../../ui/models/job', () => ({ + __esModule: true, + default: { + retrigger: jest.fn(), + }, +})); const baseProps = { selectedJobFull: { @@ -27,6 +36,7 @@ const baseProps = { describe('ActionBar expired-task disabling', () => { beforeEach(() => { + JobModel.retrigger.mockClear(); usePushesStore.setState({ ...pushesInitialState, decisionTaskMap: { 1: { id: 'DEC_TASK' } }, @@ -48,4 +58,30 @@ describe('ActionBar expired-task disabling', () => { 'Taskcluster task expired', ); }); + + // The jobRetrigger event (fired by the "r" keyboard shortcut) bypasses the + // disabled button, so retriggerJob must guard against expired tasks itself. + it('retriggers via the jobRetrigger event when not expired', () => { + render(); + + window.dispatchEvent( + new CustomEvent(thEvents.jobRetrigger, { + detail: { job: baseProps.selectedJobFull }, + }), + ); + + expect(JobModel.retrigger).toHaveBeenCalledTimes(1); + }); + + it('ignores the jobRetrigger event when taskExpired is true', () => { + render(); + + window.dispatchEvent( + new CustomEvent(thEvents.jobRetrigger, { + detail: { job: baseProps.selectedJobFull }, + }), + ); + + expect(JobModel.retrigger).not.toHaveBeenCalled(); + }); }); diff --git a/ui/job-view/details/summary/ActionBar.jsx b/ui/job-view/details/summary/ActionBar.jsx index 437103bf60a..f3d8a300a29 100644 --- a/ui/job-view/details/summary/ActionBar.jsx +++ b/ui/job-view/details/summary/ActionBar.jsx @@ -136,7 +136,13 @@ class ActionBar extends React.PureComponent { }; createGeckoProfile = async () => { - const { selectedJobFull, decisionTaskMap, currentRepo } = this.props; + const { selectedJobFull, decisionTaskMap, currentRepo, taskExpired } = + this.props; + + if (taskExpired) { + return undefined; + } + return triggerTask( selectedJobFull, notify, @@ -147,7 +153,13 @@ class ActionBar extends React.PureComponent { }; createSideBySide = async () => { - const { selectedJobFull, decisionTaskMap, currentRepo } = this.props; + const { selectedJobFull, decisionTaskMap, currentRepo, taskExpired } = + this.props; + + if (taskExpired) { + return; + } + await triggerTask( selectedJobFull, notify, @@ -158,7 +170,13 @@ class ActionBar extends React.PureComponent { }; retriggerJob = async (jobs) => { - const { decisionTaskMap, currentRepo } = this.props; + const { decisionTaskMap, currentRepo, taskExpired } = this.props; + + // The retrigger keyboard shortcut and jobRetrigger event can reach this + // even though the button is disabled, so guard here too. + if (taskExpired) { + return; + } // Spin the retrigger button when retriggers happen document @@ -228,7 +246,13 @@ class ActionBar extends React.PureComponent { notify, decisionTaskMap, currentRepo, + taskExpired, } = this.props; + + if (taskExpired) { + return; + } + confirmFailure(selectedJobFull, notify, decisionTaskMap, currentRepo); }; @@ -268,7 +292,12 @@ class ActionBar extends React.PureComponent { }; createInteractiveTask = async () => { - const { user, selectedJobFull, decisionTaskMap, currentRepo } = this.props; + const { user, selectedJobFull, decisionTaskMap, currentRepo, taskExpired } = + this.props; + + if (taskExpired) { + return; + } const { id: decisionTaskId } = decisionTaskMap[selectedJobFull.push_id]; const results = await TaskclusterModel.load( @@ -324,6 +353,10 @@ class ActionBar extends React.PureComponent { }; toggleCustomJobActions = () => { + if (this.props.taskExpired) { + return; + } + const { customJobActionsShowing } = this.state; this.setState({ customJobActionsShowing: !customJobActionsShowing }); @@ -523,26 +556,28 @@ class ActionBar extends React.PureComponent { !taskExpired && this.createInteractiveTask()} + onClick={() => this.createInteractiveTask()} > Create Interactive Task {isPerfTest(selectedJobFull) && ( !taskExpired && this.createGeckoProfile()} + onClick={() => this.createGeckoProfile()} > Create Gecko Profile @@ -551,13 +586,14 @@ class ActionBar extends React.PureComponent { !selectedJobFull.hasSideBySide && ( !taskExpired && this.createSideBySide()} + onClick={() => this.createSideBySide()} > Generate side-by-side @@ -565,23 +601,23 @@ class ActionBar extends React.PureComponent { {canConfirmFailure(selectedJobFull) && ( !taskExpired && this.handleConfirmFailure()} + onClick={() => this.handleConfirmFailure()} > Confirm Test Failures )} !taskExpired && this.toggleCustomJobActions()} - className={`dropdown-item ${ - taskExpired ? 'disabled' : '' - }`} + onClick={() => this.toggleCustomJobActions()} + className="dropdown-item" + disabled={taskExpired} title={ taskExpired ? 'Taskcluster task expired — action unavailable'