Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Test Run / Navigator polling updates for collection jobs #1125

Merged
merged 9 commits into from
Jun 24, 2024
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 }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this way of sharing the polling across multiple components!

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: {} });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
setProviderValue({ state: { collectionJob }, actions: {} });
setProviderValue({ state: { collectionJob }, actions: {} });
return () => {
stopPolling();
}

Could be good to return a cleanup function in the event of a component unmount

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this means exactly. Would this to be to enable the page to stop polling when the components on the page don't need it anymore? Seems like this case wouldn't be necessary, a minor save for what is unlikely to be a permanent situation (most up to date CollectionJob info for the current TestPlanRun is what this provider is providing) The useContext mechanism should automatically unmount/disconnect from the updates when it does.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a cleanup function to ensure that the polling is stopped even if the context is unmounted at an unexpected time. My understanding is that this is best practice for any useEffect with an async side effect.

}, [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;
stalgiag marked this conversation as resolved.
Show resolved Hide resolved
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
Loading