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..a895a7d1994 --- /dev/null +++ b/tests/ui/job-view/details/summary/ActionBar_test.jsx @@ -0,0 +1,87 @@ +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'; +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: { + 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(() => { + JobModel.retrigger.mockClear(); + 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', + ); + }); + + // 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/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 { 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,25 +246,39 @@ class ActionBar extends React.PureComponent { notify, decisionTaskMap, currentRepo, + taskExpired, } = this.props; + + if (taskExpired) { + return; + } + confirmFailure(selectedJobFull, notify, decisionTaskMap, currentRepo); }; - // Can we backfill? At the moment, this only ensures we're not in a 'try' repo. + // Can we backfill? Excludes 'try' repos and tasks whose Taskcluster + // definition has expired (backfill needs a live task definition). canBackfill = () => { - 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 ' + @@ -260,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( @@ -316,6 +353,10 @@ class ActionBar extends React.PureComponent { }; toggleCustomJobActions = () => { + if (this.props.taskExpired) { + return; + } + const { customJobActionsShowing } = this.state; this.setState({ customJobActionsShowing: !customJobActionsShowing }); @@ -329,9 +370,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 +402,7 @@ class ActionBar extends React.PureComponent { rawLogUrls={rawLogUrls} logViewerUrl={logViewerUrl} logViewerFullUrl={logViewerFullUrl} + taskExpired={taskExpired} />
  • @@ -431,9 +478,10 @@ class ActionBar extends React.PureComponent { {this.canCancel() && (
  • @@ -509,7 +557,13 @@ class ActionBar extends React.PureComponent { this.createInteractiveTask()} > Create Interactive Task @@ -517,7 +571,13 @@ class ActionBar extends React.PureComponent { this.createGeckoProfile()} > Create Gecko Profile @@ -527,7 +587,13 @@ class ActionBar extends React.PureComponent { this.createSideBySide()} > Generate side-by-side @@ -536,15 +602,27 @@ class ActionBar extends React.PureComponent { this.handleConfirmFailure()} > Confirm Test Failures )} this.toggleCustomJobActions()} className="dropdown-item" + disabled={taskExpired} + title={ + taskExpired + ? 'Taskcluster task expired — action unavailable' + : undefined + } > Custom Action... @@ -579,6 +657,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,