diff --git a/client/components/TestRun/CollectionJobContext.js b/client/components/TestRun/CollectionJobContext.js new file mode 100644 index 000000000..bf7677fd8 --- /dev/null +++ b/client/components/TestRun/CollectionJobContext.js @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React, { createContext, useState, useEffect } from 'react'; +import { useLazyQuery } from '@apollo/client'; +import { COLLECTION_JOB_UPDATES_QUERY } from './queries'; +import { isJobStatusFinal } from '../../utils/collectionJobStatus'; +const pollInterval = 5000; + +export const Context = createContext({ + state: { + collectionJob: null + }, + actions: {} +}); + +export const Provider = ({ children, testPlanRun }) => { + if (!testPlanRun) { + // Anonymous page / not working, just viewing the tests, no need for the + // provider to provide any data or updates, but to be consistent we will + // still wrap with a provider with static data + return ( + + {children} + + ); + } + const { id: testPlanRunId, collectionJob: initialCollectionJob } = + testPlanRun; + const [providerValue, setProviderValue] = useState({ + state: { collectionJob: initialCollectionJob }, + actions: {} + }); + + const [, { data: collectionJobUpdateData, startPolling, stopPolling }] = + testPlanRunId + ? useLazyQuery(COLLECTION_JOB_UPDATES_QUERY, { + fetchPolicy: 'cache-and-network', + variables: { collectionJobId: initialCollectionJob?.id }, + pollInterval + }) + : {}; + + // control the data flow, turn on polling if this is a collection job report + // that still has possible updates. + useEffect(() => { + // use the colllection job from the polling update first priority + // otherwise, default to the first data fetch from the API + const collectionJob = + collectionJobUpdateData?.collectionJob ?? initialCollectionJob; + const status = collectionJob?.status; + if (collectionJob && !isJobStatusFinal(status)) { + startPolling(pollInterval); + } else { + stopPolling(); + } + setProviderValue({ state: { collectionJob }, actions: {} }); + }, [collectionJobUpdateData]); + + return ( + {children} + ); +}; + +Provider.propTypes = { + children: PropTypes.node, + testPlanRun: PropTypes.shape({ + id: PropTypes.string, + collectionJob: PropTypes.shape({ + id: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + testStatus: PropTypes.arrayOf(PropTypes.object).isRequired + }) + }) +}; diff --git a/client/components/TestRun/Heading.js b/client/components/TestRun/Heading.js new file mode 100644 index 000000000..66146b437 --- /dev/null +++ b/client/components/TestRun/Heading.js @@ -0,0 +1,179 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faEdit, + faCheck, + faExclamationCircle, + faRobot +} from '@fortawesome/free-solid-svg-icons'; +import { Context } from './CollectionJobContext'; +import { + COLLECTION_JOB_STATUS, + isJobStatusFinal +} from '../../utils/collectionJobStatus'; + +const TestRunHeading = ({ + at, + browser, + editAtBrowserDetailsButtonRef, + handleEditAtBrowserDetailsClick, + isSignedIn, + openAsUser, + showEditAtBrowser, + testPlanTitle, + testResults, + testCount +}) => { + const { + state: { collectionJob } + } = useContext(Context); + + const renderTestsCompletedInfoBox = () => { + let isReviewingBot = Boolean(openAsUser?.isBot); + let content; + + if (isReviewingBot) { + const countTestResults = testResults.reduce( + (acc, { scenarioResults }) => + acc + + (scenarioResults && + scenarioResults.every(({ output }) => !!output) + ? 1 + : 0), + 0 + ); + const countCompleteCollection = collectionJob.testStatus.reduce( + (acc, { status }) => + acc + (status === COLLECTION_JOB_STATUS.COMPLETED ? 1 : 0), + 0 + ); + + content = ( + <> +

+ {`${Math.max( + countTestResults, + countCompleteCollection + )} of ${testCount}`}{' '} + responses collected. +

+

+ Collection Job Status: {collectionJob.status} +

+ + ); + } else if (!isSignedIn) { + content = {testCount} tests to view; + } else if (testCount) { + content = ( + <> + {' '} + {`${testResults.reduce( + (acc, { completedAt }) => acc + (completedAt ? 1 : 0), + 0 + )} of ${testCount}`}{' '} + tests completed + + ); + } else { + content =
No tests for this AT and Browser combination
; + } + + return ( +
+
+ + {content} +
+
+ ); + }; + + let openAsUserHeading = null; + + if (openAsUser?.isBot) { + openAsUserHeading = ( +
+ Reviewing tests of{' '} + {' '} + {`${openAsUser.username}`}. + {!isJobStatusFinal(collectionJob.status) && ( + <> +
+ The collection bot is still updating information on this + page. Changes may be lost when updates arrive. + + )} +
+ ); + } else if (openAsUser) { + openAsUserHeading = ( +
+ Reviewing tests of {`${openAsUser.username}`}. +

{`All changes will be saved as performed by ${openAsUser.username}.`}

+
+ ); + } + + return ( + <> +
+
+
+ Test Plan: {testPlanTitle} +
+
+
+
+
+ AT: {at} +
+
+ Browser: {browser} +
+
+ {showEditAtBrowser && ( + + )} +
+ {renderTestsCompletedInfoBox()} +
+ {openAsUserHeading} + + ); +}; + +TestRunHeading.propTypes = { + testPlanTitle: PropTypes.string.isRequired, + at: PropTypes.string.isRequired, + browser: PropTypes.string.isRequired, + showEditAtBrowser: PropTypes.bool.isRequired, + editAtBrowserDetailsButtonRef: PropTypes.object.isRequired, + isSignedIn: PropTypes.bool.isRequired, + openAsUser: PropTypes.shape({ + isBot: PropTypes.bool.isRequired, + username: PropTypes.string.isRequired + }), + testResults: PropTypes.arrayOf(PropTypes.shape({})), + testCount: PropTypes.number.isRequired, + handleEditAtBrowserDetailsClick: PropTypes.func.isRequired +}; + +export default TestRunHeading; diff --git a/client/components/TestRun/TestNavigator.jsx b/client/components/TestRun/TestNavigator.jsx index e1aba2b0c..0d24c1da5 100644 --- a/client/components/TestRun/TestNavigator.jsx +++ b/client/components/TestRun/TestNavigator.jsx @@ -6,10 +6,9 @@ import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; import { Col } from 'react-bootstrap'; -import React, { useMemo } from 'react'; +import React, { useContext, useMemo } from 'react'; +import { Context as CollectionJobContext } from './CollectionJobContext'; import '@fortawesome/fontawesome-svg-core/styles.css'; -import { COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY } from './queries'; -import { useQuery } from '@apollo/client'; const TestNavigator = ({ show = true, @@ -25,19 +24,14 @@ const TestNavigator = ({ }) => { const isBotCompletedTest = testPlanRun?.tester?.isBot; - const { data: collectionJobQuery } = useQuery( - COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY, - { - variables: { - testPlanRunId: testPlanRun?.id - } - } + const { + state: { collectionJob } + } = useContext(CollectionJobContext); + const testStatus = useMemo( + () => collectionJob?.testStatus ?? [], + [collectionJob] ); - const status = useMemo(() => { - return collectionJobQuery?.collectionJobByTestPlanRunId?.status; - }, [collectionJobQuery]); - return (
@@ -78,17 +72,23 @@ const TestNavigator = ({ if (test) { if (isBotCompletedTest) { - if ( - test.testResult?.scenarioResults.some( - s => s.output - ) - ) { + const { status } = + testStatus.find( + ts => ts.test.id === test.id + ) ?? {}; + if (status === 'COMPLETED') { resultClassName = 'bot-complete'; resultStatus = 'Completed by Bot'; - } else if (status !== 'CANCELLED') { + } else if (status === 'QUEUED') { resultClassName = 'bot-queued'; - resultStatus = 'In Progress by Bot'; - } else { + resultStatus = 'Queued by Bot'; + } else if (status === 'RUNNING') { + resultClassName = 'bot-running'; + resultStatus = 'Running with Bot'; + } else if (status === 'ERROR') { + resultClassName = 'bot-error'; + resultStatus = 'Error collecting with Bot'; + } else if (status === 'CANCELLED') { resultClassName = 'bot-cancelled'; resultStatus = 'Cancelled by Bot'; } diff --git a/client/components/TestRun/TestRun.css b/client/components/TestRun/TestRun.css index 25cc521fd..ef6c24db4 100644 --- a/client/components/TestRun/TestRun.css +++ b/client/components/TestRun/TestRun.css @@ -158,6 +158,27 @@ button.test-navigator-toggle:focus { left: 4px; } +.test-name-wrapper.bot-running .progress-indicator { + background: #d2d5d9; + border: 2px solid #1e8f37; +} + +.test-name-wrapper.bot-error .progress-indicator { + background: #e3261f; +} +.test-name-wrapper.bot-error .progress-indicator:before { + display: inline-block; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + font-family: 'Font Awesome 5 Free'; + font-weight: 900; + content: '\f071'; + color: white; + font-size: 10px; + position: relative; + top: -4px; + left: 3px; +} .test-name-wrapper.bot-queued .progress-indicator { background: #295fa6; } diff --git a/client/components/TestRun/index.jsx b/client/components/TestRun/index.jsx index db7320fbf..567603cc9 100644 --- a/client/components/TestRun/index.jsx +++ b/client/components/TestRun/index.jsx @@ -6,12 +6,10 @@ import { useMutation, useQuery } from '@apollo/client'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPen, - faEdit, faRedo, faCheck, faCheckCircle, - faExclamationCircle, - faRobot + faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; import nextId from 'react-id-generator'; import { Alert, Button, Col, Container, Row } from 'react-bootstrap'; @@ -20,6 +18,7 @@ import ReviewConflictsModal from './ReviewConflictsModal'; import StatusBar from './StatusBar'; import TestRenderer from '../TestRenderer'; import OptionButton from './OptionButton'; +import Heading from './Heading'; import PageStatus from '../common/PageStatus'; import BasicModal from '../common/BasicModal'; import BasicThemedModal from '../common/BasicThemedModal'; @@ -28,7 +27,6 @@ import { useDetectUa } from '../../hooks/useDetectUa'; import DisplayNone from '../../utils/DisplayNone'; import { navigateTests } from '../../utils/navigateTests'; import { - COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY, DELETE_TEST_RESULT_MUTATION, FIND_OR_CREATE_BROWSER_VERSION_MUTATION, FIND_OR_CREATE_TEST_RESULT_MUTATION, @@ -42,6 +40,7 @@ import './TestRun.css'; import ReviewConflicts from '../ReviewConflicts'; import createIssueLink from '../../utils/createIssueLink'; import { convertDateToString } from '../../utils/formatter'; +import { Provider as CollectionJobContextProvider } from './CollectionJobContext'; const TestRun = () => { const params = useParams(); @@ -72,17 +71,14 @@ const TestRun = () => { testPlanRunId ? TEST_RUN_PAGE_QUERY : TEST_RUN_PAGE_ANON_QUERY, { fetchPolicy: 'cache-and-network', - variables: { testPlanRunId, testPlanReportId } + variables: { testPlanRunId, testPlanReportId }, + pollInterval: 0 } ); - const { data: collectionJobQuery } = useQuery( - COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY, - { - variables: { testPlanRunId }, - fetchPolicy: 'cache-and-network' - } - ); + useEffect(() => { + if (data) setup(data); + }, [data]); const [createTestResult, { loading: createTestResultLoading }] = useMutation(FIND_OR_CREATE_TEST_RESULT_MUTATION); @@ -148,10 +144,6 @@ const TestRun = () => { const isAdminReviewer = !!(isAdmin && openAsUserId); const openAsUser = users?.find(user => user.id === openAsUserId); - useEffect(() => { - if (data) setup(data); - }, [data]); - useEffect(() => { reset(); @@ -338,7 +330,7 @@ const TestRun = () => { }; // Check to see if there are tests to run - const hasTestsToRun = tests.length; + const testCount = tests.length; // Check if this test is being run as an admin if ( @@ -892,57 +884,6 @@ const TestRun = () => { editAtBrowserDetailsButtonRef.current.focus(); }; - const renderTestsCompletedInfoBox = () => { - let isReviewingBot = false; - if (openAsUserId) { - isReviewingBot = openAsUser.isBot; - } - - let content; - - if (isReviewingBot) { - content = ( - <> - {`${testResults.reduce( - (acc, { scenarioResults }) => - acc + - (scenarioResults && - scenarioResults.every(({ output }) => !!output) - ? 1 - : 0), - 0 - )} of ${tests.length}`}{' '} - responses collected. - - ); - } else if (!isSignedIn) { - content = {tests.length} tests to view; - } else if (hasTestsToRun) { - content = ( - <> - {' '} - {`${testResults.reduce( - (acc, { completedAt }) => acc + (completedAt ? 1 : 0), - 0 - )} of ${tests.length}`}{' '} - tests completed - - ); - } else { - content =
No tests for this AT and Browser combination
; - } - return ( -
-
- - {content} -
-
- ); - }; - const renderTestContent = (testPlanReport, currentTest, heading) => { const { index } = currentTest; const isComplete = currentTest.testResult @@ -1014,8 +955,7 @@ const TestRun = () => { ]; } - const externalLogsUrl = - collectionJobQuery?.collectionJobByTestPlanRunId?.externalLogsUrl; + const externalLogsUrl = testPlanRun?.collectionJob?.externalLogsUrl; const menuRightOfContent = (
@@ -1076,6 +1016,11 @@ const TestRun = () => {
); + // we are ready enough to show the page and all the buttons when the above code is + // pageReady and we have an anon view, bot test run, or a test result to display + const completeRender = + pageReady && (!isSignedIn || currentTest.testResult); + return ( <>

@@ -1090,7 +1035,7 @@ const TestRun = () => { handleReviewConflictsButtonClick } /> - {pageReady && (isSignedIn ? currentTest.testResult : true) && ( + {completeRender && ( @@ -1219,82 +1164,32 @@ const TestRun = () => { let heading; let content; - let openAsUserHeading = null; - - if (openAsUserId) { - if (openAsUser.isBot) { - openAsUserHeading = ( -
- Reviewing tests of{' '} - {' '} - {`${openAsUser.username}`}. -
- ); - } else { - openAsUserHeading = ( -
- Reviewing tests of {`${openAsUser.username}`}. -

{`All changes will be saved as performed by ${openAsUser.username}.`}

-
- ); - } - } heading = pageReady && ( - <> -
-
-
- Test Plan:{' '} - {`${ - testPlanVersion.title || - testPlanVersion.testPlan?.directory || - '' - }`} -
-
-
-
-
- AT:{' '} - {`${testPlanReport.at?.name}${ - isSignedIn ? ` ${currentAtVersion?.name}` : '' - }`} -
-
- Browser:{' '} - {`${testPlanReport.browser?.name}${ - isSignedIn - ? ` ${currentBrowserVersion?.name || ''}` - : '' - }`} -
-
- {isSignedIn && ( - - )} -
- {renderTestsCompletedInfoBox()} -
- {openAsUserHeading} - + ); if (!isSignedIn || !testPlanRun?.isComplete) { - content = hasTestsToRun ? ( + content = testCount ? ( renderTestContent(testPlanReport, currentTest, heading) ) : ( // No tests loaded @@ -1320,110 +1215,114 @@ const TestRun = () => { return ( pageReady && ( - - - - {hasTestsToRun - ? `${currentTest.title} for ${testPlanReport.at?.name} ${currentAtVersion?.name} and ${testPlanReport.browser?.name} ${currentBrowserVersion?.name} ` + - `| ARIA-AT` - : 'No tests for this AT and Browser | ARIA-AT'} - - - {updateMessageComponent && ( - - {updateMessageComponent} - - )} - - - - - {content} - - - - {showThemedModal && ( - - )} - {isSignedIn && isShowingAtBrowserModal && ( - { - // Only provide at version options that released - // at the same time or later than the minimum - // AT version - let earliestReleasedAt = null; - if (testPlanReport.minimumAtVersion) { - earliestReleasedAt = new Date( - testPlanReport.minimumAtVersion.releasedAt - ); - return ( - new Date(item.releasedAt) >= - earliestReleasedAt - ); - } - return item; - }) - .map(item => item.name)} - browserName={testPlanReport.browser.name} - browserVersion={ - currentTest.testResult?.browserVersion?.name - } - browserVersions={testPlanReport.browser.browserVersions.map( - item => item.name - )} - patternName={testPlanVersion.title} - testerName={tester.username} - exactAtVersion={testPlanReport.exactAtVersion} - handleAction={handleAtAndBrowserDetailsModalAction} - handleClose={handleAtAndBrowserDetailsModalCloseAction} - /> - )} - + + + + + {testCount + ? `${currentTest.title} for ${testPlanReport.at?.name} ${currentAtVersion?.name} and ${testPlanReport.browser?.name} ${currentBrowserVersion?.name} ` + + `| ARIA-AT` + : 'No tests for this AT and Browser | ARIA-AT'} + + + {updateMessageComponent && ( + + {updateMessageComponent} + + )} + + + + + {content} + + + + {showThemedModal && ( + + )} + {isSignedIn && isShowingAtBrowserModal && ( + { + // Only provide at version options that released + // at the same time or later than the minimum + // AT version + let earliestReleasedAt = null; + if (testPlanReport.minimumAtVersion) { + earliestReleasedAt = new Date( + testPlanReport.minimumAtVersion.releasedAt + ); + return ( + new Date(item.releasedAt) >= + earliestReleasedAt + ); + } + return item; + }) + .map(item => item.name)} + exactAtVersion={testPlanReport.exactAtVersion} + browserName={testPlanReport.browser.name} + browserVersion={ + currentTest.testResult?.browserVersion?.name + } + browserVersions={testPlanReport.browser.browserVersions.map( + item => item.name + )} + patternName={testPlanVersion.title} + testerName={tester.username} + handleAction={handleAtAndBrowserDetailsModalAction} + handleClose={ + handleAtAndBrowserDetailsModalCloseAction + } + /> + )} + + ) ); }; diff --git a/client/components/TestRun/queries.js b/client/components/TestRun/queries.js index aed6ca3e1..e0e7ebd1a 100644 --- a/client/components/TestRun/queries.js +++ b/client/components/TestRun/queries.js @@ -5,6 +5,17 @@ export const TEST_RUN_PAGE_QUERY = gql` testPlanRun(id: $testPlanRunId) { id initiatedByAutomation + collectionJob { + id + status + externalLogsUrl + testStatus { + test { + id + } + status + } + } tester { id username @@ -195,6 +206,22 @@ export const TEST_RUN_PAGE_QUERY = gql` } `; +export const COLLECTION_JOB_UPDATES_QUERY = gql` + query CollectionJob($collectionJobId: ID!) { + collectionJob(id: $collectionJobId) { + id + status + externalLogsUrl + testStatus { + test { + id + } + status + } + } + } +`; + export const TEST_RUN_PAGE_ANON_QUERY = gql` query TestPlanRunAnonPage($testPlanReportId: ID!) { testPlanReport(id: $testPlanReportId) { @@ -1257,13 +1284,3 @@ export const FIND_OR_CREATE_BROWSER_VERSION_MUTATION = gql` } } `; - -export const COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY = gql` - query CollectionJobIdByTestPlanRunId($testPlanRunId: ID!) { - collectionJobByTestPlanRunId(testPlanRunId: $testPlanRunId) { - id - status - externalLogsUrl - } - } -`; diff --git a/client/tests/smokeTest.test.js b/client/tests/smokeTest.test.js index 3818b1d3b..fb1a92f22 100644 --- a/client/tests/smokeTest.test.js +++ b/client/tests/smokeTest.test.js @@ -111,7 +111,33 @@ describe('smoke test', () => { const h1Handle = await page.waitForSelector('h1'); const h1Text = await h1Handle.evaluate(h1 => h1.innerText); expect(h1Text).toBe('Data Management'); - }) + }), + getPage( + { role: false, url: '/test-plan-report/15' }, + async page => { + // Wait for an h2 because an h1 will show while the page is + // still loading + await page.waitForSelector('h2'); + const h1Handle = await page.waitForSelector('h1'); + const h1Text = await h1Handle.evaluate(h1 => h1.innerText); + expect(h1Text).toBe( + 'Test 1:\nNavigate forwards to a not pressed toggle button' + ); + } + ), + getPage( + { role: 'admin', url: '/test-plan-report/15' }, + async page => { + // Wait for an h2 because an h1 will show while the page is + // still loading + await page.waitForSelector('h2'); + const h1Handle = await page.waitForSelector('h1'); + const h1Text = await h1Handle.evaluate(h1 => h1.innerText); + expect(h1Text).toBe( + 'Test 1:\nNavigate forwards to a not pressed toggle button' + ); + } + ) ]); }); }); diff --git a/client/utils/collectionJobStatus.js b/client/utils/collectionJobStatus.js new file mode 100644 index 000000000..1bba17474 --- /dev/null +++ b/client/utils/collectionJobStatus.js @@ -0,0 +1,16 @@ +// server side version of this enum. +// don't forget to also update the client side version in +// server/util/enums.js + +export const COLLECTION_JOB_STATUS = { + QUEUED: 'QUEUED', + RUNNING: 'RUNNING', + COMPLETED: 'COMPLETED', + ERROR: 'ERROR', + CANCELLED: 'CANCELLED' +}; + +export const isJobStatusFinal = status => + status === COLLECTION_JOB_STATUS.COMPLETED || + status === COLLECTION_JOB_STATUS.CANCELLED || + status === COLLECTION_JOB_STATUS.ERROR;