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