Skip to content

Commit

Permalink
feat: Test Run / Navigator polling updates for collection jobs (#1125)
Browse files Browse the repository at this point in the history
* Test Run / Navigator polling updates for collection jobs

* fix test, add updates incoming warning

* fix status messages

* minute adjustment for error

* isJobStatusFinal

* Shared update context for CollectionJob, let smaller portions of page rerender

* fix small render error for test run

* Working for not logged in users again

* Add smoke tests for /test-plan-report/15 as anon and admin
  • Loading branch information
gnarf authored Jun 24, 2024
1 parent a3e2b23 commit 29456fc
Show file tree
Hide file tree
Showing 8 changed files with 510 additions and 279 deletions.
73 changes: 73 additions & 0 deletions client/components/TestRun/CollectionJobContext.js
Original file line number Diff line number Diff line change
@@ -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 (
<Context.Provider value={{ state: {}, actions: {} }}>
{children}
</Context.Provider>
);
}
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 (
<Context.Provider value={providerValue}>{children}</Context.Provider>
);
};

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
})
})
};
179 changes: 179 additions & 0 deletions client/components/TestRun/Heading.js
Original file line number Diff line number Diff line change
@@ -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 = (
<>
<p>
<b>{`${Math.max(
countTestResults,
countCompleteCollection
)} of ${testCount}`}</b>{' '}
responses collected.
</p>
<p>
Collection Job Status: <b>{collectionJob.status}</b>
</p>
</>
);
} else if (!isSignedIn) {
content = <b>{testCount} tests to view</b>;
} else if (testCount) {
content = (
<>
{' '}
<b>{`${testResults.reduce(
(acc, { completedAt }) => acc + (completedAt ? 1 : 0),
0
)} of ${testCount}`}</b>{' '}
tests completed
</>
);
} else {
content = <div>No tests for this AT and Browser combination</div>;
}

return (
<div className="test-info-entity tests-completed">
<div className="info-label">
<FontAwesomeIcon
icon={testCount ? faCheck : faExclamationCircle}
/>
{content}
</div>
</div>
);
};

let openAsUserHeading = null;

if (openAsUser?.isBot) {
openAsUserHeading = (
<div className="test-info-entity reviewing-as bot">
Reviewing tests of{' '}
<FontAwesomeIcon icon={faRobot} className="m-0" />{' '}
<b>{`${openAsUser.username}`}.</b>
{!isJobStatusFinal(collectionJob.status) && (
<>
<br />
The collection bot is still updating information on this
page. Changes may be lost when updates arrive.
</>
)}
</div>
);
} else if (openAsUser) {
openAsUserHeading = (
<div className="test-info-entity reviewing-as">
Reviewing tests of <b>{`${openAsUser.username}`}.</b>
<p>{`All changes will be saved as performed by ${openAsUser.username}.`}</p>
</div>
);
}

return (
<>
<div className="test-info-wrapper">
<div
className="test-info-entity apg-example-name"
data-test="apg-example-name"
>
<div className="info-label">
<b>Test Plan:</b> {testPlanTitle}
</div>
</div>
<div
className="test-info-entity at-browser"
data-test="at-browser"
>
<div className="at-browser-row">
<div className="info-label">
<b>AT:</b> {at}
</div>
<div className="info-label">
<b>Browser:</b> {browser}
</div>
</div>
{showEditAtBrowser && (
<Button
ref={editAtBrowserDetailsButtonRef}
id="edit-fa-button"
aria-label="Edit version details for AT and Browser"
onClick={handleEditAtBrowserDetailsClick}
>
<FontAwesomeIcon icon={faEdit} />
</Button>
)}
</div>
{renderTestsCompletedInfoBox()}
</div>
{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;
44 changes: 22 additions & 22 deletions client/components/TestRun/TestNavigator.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<Col className="test-navigator" md={show ? 3 : 12}>
<div className="test-navigator-toggle-container">
Expand Down Expand Up @@ -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';
}
Expand Down
21 changes: 21 additions & 0 deletions client/components/TestRun/TestRun.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading

0 comments on commit 29456fc

Please sign in to comment.