diff --git a/client/components/BotRunTestStatusList/index.js b/client/components/BotRunTestStatusList/index.js index 4fb00a729..6d1873c50 100644 --- a/client/components/BotRunTestStatusList/index.js +++ b/client/components/BotRunTestStatusList/index.js @@ -1,13 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { - COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY, - TEST_PLAN_RUNS_TEST_RESULTS_QUERY -} from './queries'; -import { useLazyQuery, useQuery } from '@apollo/client'; +import { TEST_PLAN_RUNS_TEST_RESULTS_QUERY } from './queries'; +import { useQuery } from '@apollo/client'; import styled from '@emotion/styled'; import ReportStatusDot from '../common/ReportStatusDot'; -import { isBot } from '../../utils/automation'; const BotRunTestStatusUnorderedList = styled.ul` list-style-type: none; @@ -35,7 +31,9 @@ const BotRunTestStatusUnorderedList = styled.ul` const testCountString = (count, status) => `${count} Test${count === 1 ? '' : 's'} ${status}`; -const BotRunTestStatusList = ({ testPlanReportId, runnableTestsLength }) => { +const pollInterval = 2000; + +const BotRunTestStatusList = ({ testPlanReportId }) => { const { data: testPlanRunsQueryResult, startPolling, @@ -43,113 +41,83 @@ const BotRunTestStatusList = ({ testPlanReportId, runnableTestsLength }) => { } = useQuery(TEST_PLAN_RUNS_TEST_RESULTS_QUERY, { variables: { testPlanReportId }, fetchPolicy: 'cache-and-network', - pollInterval: 2000 + pollInterval }); - const [getCollectionJobStatus, { data: collectionJobStatusQueryResult }] = - useLazyQuery(COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY, { - fetchPolicy: 'cache-and-network' - }); - - const [collectedData, setCollectedData] = useState([]); - const requestedTestRunIds = useRef(new Set()); - - const botTestPlanRuns = useMemo(() => { - if (!testPlanRunsQueryResult?.testPlanRuns) { - return []; - } - return testPlanRunsQueryResult.testPlanRuns.filter(testPlanRun => - isBot(testPlanRun.tester) - ); - }, [testPlanRunsQueryResult?.testPlanRuns]); - - useEffect(() => { - const ids = botTestPlanRuns.map(run => run.id); - for (const id of ids) { - if (!requestedTestRunIds.current.has(id)) { - requestedTestRunIds.current.add(id); - getCollectionJobStatus({ - variables: { testPlanRunId: id } - }); - } - } - }, [botTestPlanRuns]); - - useEffect(() => { - if (collectionJobStatusQueryResult?.collectionJobByTestPlanRunId) { - const { status } = - collectionJobStatusQueryResult.collectionJobByTestPlanRunId; - setCollectedData(prev => [...prev, status]); - } - }, [collectionJobStatusQueryResult?.collectionJobByTestPlanRunId]); - - const [numTestsCompleted, numTestsQueued, numTestsCancelled] = - useMemo(() => { - const res = [0, 0, 0]; - if ( - botTestPlanRuns && - botTestPlanRuns.length && - collectedData.length === botTestPlanRuns.length - ) { - for (let i = 0; i < botTestPlanRuns.length; i++) { - const status = collectedData[i]; - res[0] += botTestPlanRuns[i].testResults.length; - switch (status) { - case 'COMPLETED': - case 'RUNNING': - case 'QUEUED': - res[1] += - runnableTestsLength - - botTestPlanRuns[i].testResults.length; - break; - case 'CANCELLED': - res[2] += - runnableTestsLength - - botTestPlanRuns[i].testResults.length; - break; - default: - break; + const { COMPLETED, ERROR, RUNNING, CANCELLED, QUEUED } = useMemo(() => { + const counter = { + COMPLETED: 0, + ERROR: 0, + RUNNING: 0, + CANCELLED: 0, + QUEUED: 0 + }; + let anyPossibleUpdates = false; + if (testPlanRunsQueryResult?.testPlanRuns) { + for (const { + collectionJob + } of testPlanRunsQueryResult.testPlanRuns) { + if (collectionJob?.testStatus) { + for (const { status } of collectionJob.testStatus) { + counter[status]++; + if (status === 'QUEUED' || status === 'RUNNING') { + anyPossibleUpdates = true; + } } } - if ( - res[0] + res[2] === - runnableTestsLength * botTestPlanRuns.length - ) { - stopPolling(); - } } - return res; - }, [testPlanRunsQueryResult, collectedData, stopPolling, startPolling]); + // it's possible that we got incomplete data on first fetch and + // stopped the polling, so restart the polling if we detect any + // possible future updates, otherwise stop. + if (anyPossibleUpdates) { + startPolling(pollInterval); + } else { + stopPolling(); + } + } + return counter; + }, [testPlanRunsQueryResult, stopPolling, startPolling]); if ( - !botTestPlanRuns || - botTestPlanRuns.length === 0 || - !collectedData || - !(collectedData.length === botTestPlanRuns.length) + !testPlanRunsQueryResult || + testPlanRunsQueryResult.testPlanRuns.length === 0 ) { return null; } return ( + {RUNNING > 0 && ( +
  • + + {testCountString(RUNNING, 'Running')} +
  • + )} + {ERROR > 0 && ( +
  • + + {testCountString(ERROR, 'Error')} +
  • + )}
  • - {testCountString(numTestsCompleted, 'Completed')} + {testCountString(COMPLETED, 'Completed')}
  • - {testCountString(numTestsQueued, 'Queued')} -
  • -
  • - - {testCountString(numTestsCancelled, 'Cancelled')} + {testCountString(QUEUED, 'Queued')}
  • + {CANCELLED > 0 && ( +
  • + + {testCountString(CANCELLED, 'Cancelled')} +
  • + )}
    ); }; BotRunTestStatusList.propTypes = { - testPlanReportId: PropTypes.string.isRequired, - runnableTestsLength: PropTypes.number.isRequired + testPlanReportId: PropTypes.string.isRequired }; export default BotRunTestStatusList; diff --git a/client/components/BotRunTestStatusList/queries.js b/client/components/BotRunTestStatusList/queries.js index 048acc1aa..cd99fb2bc 100644 --- a/client/components/BotRunTestStatusList/queries.js +++ b/client/components/BotRunTestStatusList/queries.js @@ -16,15 +16,12 @@ export const TEST_PLAN_RUNS_TEST_RESULTS_QUERY = gql` } } } - } - } -`; - -export const COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY = gql` - query CollectionJobStatusByTestPlanRunId($testPlanRunId: ID!) { - collectionJobByTestPlanRunId(testPlanRunId: $testPlanRunId) { - id - status + collectionJob { + status + testStatus { + status + } + } } } `; diff --git a/client/components/common/ReportStatusDot/index.jsx b/client/components/common/ReportStatusDot/index.jsx index d369e91ad..0c44c6521 100644 --- a/client/components/common/ReportStatusDot/index.jsx +++ b/client/components/common/ReportStatusDot/index.jsx @@ -17,6 +17,15 @@ const ReportStatusDot = styled.span` background: #7c7c7c; } + &.tests-running { + border: 2px solid #1e8f37; + background: #d2d5d9; + } + + &.tests-error { + background: #e3261f; + } + &.tests-queued, &.reports-in-progress { background: #3876e8; @@ -27,7 +36,9 @@ const ReportStatusDot = styled.span` background: #2ba51c; } - &.tests-cancelled, + &.tests-cancelled { + background: #a231ff; + } &.reports-missing { background: #ce1b4c; } diff --git a/client/tests/BotRunTestStatusList.test.jsx b/client/tests/BotRunTestStatusList.test.jsx index 66b93e03d..2aec3b4c8 100644 --- a/client/tests/BotRunTestStatusList.test.jsx +++ b/client/tests/BotRunTestStatusList.test.jsx @@ -5,13 +5,11 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { MockedProvider } from '@apollo/client/testing'; import BotRunTestStatusList from '../components/BotRunTestStatusList'; -import { - TEST_PLAN_RUNS_TEST_RESULTS_QUERY, - COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY -} from '../components/BotRunTestStatusList/queries'; +import { TEST_PLAN_RUNS_TEST_RESULTS_QUERY } from '../components/BotRunTestStatusList/queries'; import '@testing-library/jest-dom/extend-expect'; +import { COLLECTION_JOB_STATUS } from '../../server/util/enums'; -const getMocks = (testPlanRuns, collectionJobStatuses) => { +const getMocks = testPlanRuns => { const testPlanRunMock = { request: { query: TEST_PLAN_RUNS_TEST_RESULTS_QUERY, @@ -20,22 +18,7 @@ const getMocks = (testPlanRuns, collectionJobStatuses) => { result: { data: { testPlanRuns } } }; - const collectionJobStatusMocks = testPlanRuns.map((testRun, index) => ({ - request: { - query: COLLECTION_JOB_STATUS_BY_TEST_PLAN_RUN_ID_QUERY, - variables: { testPlanRunId: testRun.id } - }, - result: { - data: { - collectionJobByTestPlanRunId: { - status: collectionJobStatuses[index], - id: testRun.id - } - } - } - })); - - return [testPlanRunMock, ...collectionJobStatusMocks]; + return [testPlanRunMock]; }; test('correctly displays statuses for single COMPLETED test run', async () => { @@ -43,27 +26,31 @@ test('correctly displays statuses for single COMPLETED test run', async () => { { id: '0', testResults: new Array(3).fill(null), - tester: { username: 'bot' } + tester: { username: 'bot' }, + collectionJob: { + status: COLLECTION_JOB_STATUS.COMPLETED, + testStatus: [ + { status: COLLECTION_JOB_STATUS.COMPLETED }, + { status: COLLECTION_JOB_STATUS.COMPLETED }, + { status: COLLECTION_JOB_STATUS.COMPLETED } + ] + } } ]; - const collectionJobStatuses = ['COMPLETED']; + const mocks = getMocks(testPlanRuns); - const mocks = getMocks(testPlanRuns, collectionJobStatuses); - - const { getByText } = render( + const screen = render( - + ); + const { getByText } = screen; + await waitFor(() => { expect(getByText('3 Tests Completed')).toBeInTheDocument(); expect(getByText('0 Tests Queued')).toBeInTheDocument(); - expect(getByText('0 Tests Cancelled')).toBeInTheDocument(); }); }); @@ -72,32 +59,34 @@ test('correctly ignores test results from a human-submitted test plan run', asyn { id: '0', testResults: new Array(2).fill(null), - tester: { username: 'bot' } + tester: { username: 'bot' }, + collectionJob: { + status: COLLECTION_JOB_STATUS.COMPLETED, + testStatus: [ + { status: COLLECTION_JOB_STATUS.COMPLETED }, + { status: COLLECTION_JOB_STATUS.COMPLETED } + ] + } }, { id: '1', testResults: new Array(2).fill(null), - tester: { username: 'human' } + tester: { username: 'human' }, + collectionJob: null } ]; - const collectionJobStatuses = ['COMPLETED', 'COMPLETED']; - - const mocks = getMocks(testPlanRuns, collectionJobStatuses); + const mocks = getMocks(testPlanRuns); const { getByText } = render( - + ); await waitFor(async () => { expect(getByText('2 Tests Completed')).toBeInTheDocument(); expect(getByText('0 Tests Queued')).toBeInTheDocument(); - expect(getByText('0 Tests Cancelled')).toBeInTheDocument(); }); }); @@ -106,20 +95,23 @@ test('correctly displays statuses for CANCELLED test run', async () => { { id: '0', testResults: new Array(2).fill(null), - tester: { username: 'bot' } + tester: { username: 'bot' }, + collectionJob: { + status: COLLECTION_JOB_STATUS.CANCELLED, + testStatus: [ + { status: COLLECTION_JOB_STATUS.COMPLETED }, + { status: COLLECTION_JOB_STATUS.COMPLETED }, + { status: COLLECTION_JOB_STATUS.CANCELLED } + ] + } } ]; - const collectionJobStatuses = ['CANCELLED']; - - const mocks = getMocks(testPlanRuns, collectionJobStatuses); + const mocks = getMocks(testPlanRuns); const { getByText } = render( - + ); @@ -135,35 +127,36 @@ test('correctly displays statuses for multiple RUNNING and QUEUED test runs', as { id: '0', testResults: new Array(2).fill(null), - tester: { username: 'bot' } + tester: { username: 'bot' }, + collectionJob: { + status: COLLECTION_JOB_STATUS.RUNNING, + testStatus: [ + { status: COLLECTION_JOB_STATUS.RUNNING }, + { status: COLLECTION_JOB_STATUS.COMPLETED }, + { status: COLLECTION_JOB_STATUS.QUEUED } + ] + } }, { id: '1', testResults: new Array(2).fill(null), - tester: { username: 'bot' } - }, - { - id: '2', - testResults: [null], - tester: { username: 'bot' } - }, - { - id: '3', - testResults: new Array(2).fill(null), - tester: { username: 'human' } + tester: { username: 'bot' }, + collectionJob: { + status: COLLECTION_JOB_STATUS.CANCELLED, + testStatus: [ + { status: COLLECTION_JOB_STATUS.CANCELLED }, + { status: COLLECTION_JOB_STATUS.COMPLETED }, + { status: COLLECTION_JOB_STATUS.CANCELLED } + ] + } } ]; - const collectionJobStatuses = ['RUNNING', 'RUNNING', 'CANCELLED']; - - const mocks = getMocks(testPlanRuns, collectionJobStatuses); + const mocks = getMocks(testPlanRuns); const { getByText } = render( - + ); @@ -171,9 +164,10 @@ test('correctly displays statuses for multiple RUNNING and QUEUED test runs', as // Wait for the component to update // Imperfect but prevents needing to detect loading removal await setTimeout(() => { + expect(getByText('1 Test Running')).toBeInTheDocument(); expect(getByText('2 Tests Completed')).toBeInTheDocument(); expect(getByText('1 Test Queued')).toBeInTheDocument(); - expect(getByText('1 Test Cancelled')).toBeInTheDocument(); + expect(getByText('2 Tests Cancelled')).toBeInTheDocument(); }, 500); }); }); diff --git a/server/controllers/AutomationController.js b/server/controllers/AutomationController.js index 110351019..39f2ba930 100644 --- a/server/controllers/AutomationController.js +++ b/server/controllers/AutomationController.js @@ -1,7 +1,8 @@ const axios = require('axios'); const { getCollectionJobById, - updateCollectionJobById + updateCollectionJobById, + updateCollectionJobTestStatusByQuery } = require('../models/services/CollectionJobService'); const { findOrCreateTestResult @@ -17,14 +18,14 @@ const { findOrCreateBrowserVersion } = require('../models/services/BrowserService'); const { HttpQueryError } = require('apollo-server-core'); -const { COLLECTION_JOB_STATUS } = require('../util/enums'); +const { COLLECTION_JOB_STATUS, isJobStatusFinal } = require('../util/enums'); const populateData = require('../services/PopulatedData/populateData'); const { getFinalizedTestResults } = require('../models/services/TestResultReadService'); const http = require('http'); const { NO_OUTPUT_STRING } = require('../util/constants'); -const getTests = require('../models/services/TestsService'); +const runnableTestsResolver = require('../resolvers/TestPlanReport/runnableTestsResolver'); const getGraphQLContext = require('../graphql-context'); const httpAgent = new http.Agent({ family: 4 }); @@ -99,6 +100,19 @@ const updateJobStatus = async (req, res) => { ...(externalLogsUrl != null && { externalLogsUrl }) }; + // When new status is considered "final" ('COMPLETED' or 'ERROR' or 'CANCELLED') + // update any CollectionJobTestStatus children still 'QUEUED' to be 'CANCELLED' + if (isJobStatusFinal(status)) { + await updateCollectionJobTestStatusByQuery({ + where: { + collectionJobId: req.params.jobID, + status: COLLECTION_JOB_STATUS.QUEUED + }, + values: { status: COLLECTION_JOB_STATUS.CANCELLED }, + transaction: req.transaction + }); + } + const graphqlResponse = await updateCollectionJobById({ id: req.params.jobID, values: updatePayload, @@ -137,32 +151,25 @@ const getApprovedFinalizedTestResults = async (testPlanRun, context) => { return getFinalizedTestResults({ testPlanReport, context }); }; +const getTestByRowNumber = async ({ testPlanRun, testRowNumber, context }) => { + const tests = await runnableTestsResolver( + testPlanRun.testPlanReport, + null, + context + ); + return tests.find( + test => parseInt(test.rowNumber, 10) === parseInt(testRowNumber, 10) + ); +}; + const updateOrCreateTestResultWithResponses = async ({ - testRowIdentifier, + testId, testPlanRun, responses, atVersionId, browserVersionId, context }) => { - const allTestsForTestPlanVersion = await getTests( - testPlanRun.testPlanReport.testPlanVersion - ); - - const isV2 = - testPlanRun.testPlanReport.testPlanVersion.metadata - .testFormatVersion === 2; - - const testId = allTestsForTestPlanVersion.find( - test => - (!isV2 || test.at?.name === 'NVDA') && - parseInt(test.rowNumber, 10) === testRowIdentifier - )?.id; - - if (testId === undefined) { - throwNoTestFoundError(testRowIdentifier); - } - const { testResult } = await findOrCreateTestResult({ testId, testPlanRunId: testPlanRun.id, @@ -239,20 +246,20 @@ const updateOrCreateTestResultWithResponses = async ({ }; const updateJobResults = async (req, res) => { - const id = req.params.jobID; + const { jobID: id, testRowNumber } = req.params; const context = getGraphQLContext({ req }); const { transaction } = context; const { - testCsvRow, - presentationNumber, responses, + status, capabilities: { atName, atVersion: atVersionName, browserName, browserVersion: browserVersionName - } + } = {} } = req.body; + const job = await getCollectionJobById({ id, transaction }); if (!job) { throwNoJobFoundError(id); @@ -263,35 +270,65 @@ const updateJobResults = async (req, res) => { `Job with id ${id} is not running, cannot update results` ); } + if (status && !Object.values(COLLECTION_JOB_STATUS).includes(status)) { + throw new HttpQueryError(400, `Invalid status: ${status}`, true); + } + const { testPlanRun } = job; - /* TODO: Change this to use a better key based lookup system after gh-958 */ - const [at] = await getAts({ search: atName, transaction }); - const [browser] = await getBrowsers({ search: browserName, transaction }); - - const [atVersion, browserVersion] = await Promise.all([ - findOrCreateAtVersion({ - where: { atId: at.id, name: atVersionName }, - transaction - }), - findOrCreateBrowserVersion({ - where: { browserId: browser.id, name: browserVersionName }, - transaction + const testId = ( + await getTestByRowNumber({ + testPlanRun, + testRowNumber, + context }) - ]); + )?.id; + + if (testId === undefined) { + throwNoTestFoundError(testRowNumber); + } - const processedResponses = convertEmptyStringsToNoOutputMessages(responses); + // status only update, or responses were provided (default to complete) + if (status || responses) { + await updateCollectionJobTestStatusByQuery({ + where: { collectionJobId: id, testId }, + // default to completed if not specified (when results are present) + values: { status: status ?? COLLECTION_JOB_STATUS.COMPLETED }, + transaction: req.transaction + }); + } - // v1 tests store testCsvRow in rowNumber, v2 tests store presentationNumber in rowNumber - const testRowIdentifier = presentationNumber ?? testCsvRow; + // responses were provided + if (responses) { + /* TODO: Change this to use a better key based lookup system after gh-958 */ + const [at] = await getAts({ search: atName, transaction }); + const [browser] = await getBrowsers({ + search: browserName, + transaction + }); - await updateOrCreateTestResultWithResponses({ - testRowIdentifier, - responses: processedResponses, - testPlanRun: job.testPlanRun, - atVersionId: atVersion.id, - browserVersionId: browserVersion.id, - context - }); + const [atVersion, browserVersion] = await Promise.all([ + findOrCreateAtVersion({ + where: { atId: at.id, name: atVersionName }, + transaction + }), + findOrCreateBrowserVersion({ + where: { browserId: browser.id, name: browserVersionName }, + transaction + }) + ]); + + const processedResponses = + convertEmptyStringsToNoOutputMessages(responses); + + await updateOrCreateTestResultWithResponses({ + testId, + responses: processedResponses, + testPlanRun, + atVersionId: atVersion.id, + browserVersionId: browserVersion.id, + context + }); + } res.json({ success: true }); }; diff --git a/server/graphql-schema.js b/server/graphql-schema.js index 92cf9d25a..647607d99 100644 --- a/server/graphql-schema.js +++ b/server/graphql-schema.js @@ -102,6 +102,25 @@ const graphqlSchema = gql` The URL where the logs for the job can be found. """ externalLogsUrl: String + """ + An array of individual test status for every runnable test in the Job. + """ + testStatus: [CollectionJobTestStatus] + } + + """ + A status for a specific Test on a specific CollectionJob. + """ + type CollectionJobTestStatus { + """ + The test this status reflects. + """ + test: Test! + """ + The status of the test, which can be "QUEUED", "RUNNING", "COMPLETED", + "ERROR", or "CANCELLED" + """ + status: CollectionJobStatus! } type Browser { @@ -827,6 +846,10 @@ const graphqlSchema = gql` Whether the TestPlanRun was initiated by the Response Collection System """ initiatedByAutomation: Boolean! + """ + The CollectionJob related to this testPlanRun + """ + collectionJob: CollectionJob } """ diff --git a/server/migrations/20240404171101-addCollectionJobTestStatus.js b/server/migrations/20240404171101-addCollectionJobTestStatus.js new file mode 100644 index 000000000..7abf682ce --- /dev/null +++ b/server/migrations/20240404171101-addCollectionJobTestStatus.js @@ -0,0 +1,58 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.sequelize.transaction(async transaction => { + await queryInterface.createTable( + 'CollectionJobTestStatus', + { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + testId: { + type: Sequelize.STRING, + allowNull: false + }, + collectionJobId: { + type: Sequelize.INTEGER, + allowNull: false, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + references: { + model: 'CollectionJob', + key: 'id' + } + }, + status: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'QUEUED' + } + }, + { transaction } + ); + await queryInterface.addConstraint('CollectionJobTestStatus', { + type: 'unique', + name: 'CollectionJob_Test_unique', + fields: ['collectionJobId', 'testId'], + transaction + }); + }); + }, + + async down(queryInterface) { + return queryInterface.sequelize.transaction(async transaction => { + await queryInterface.removeConstraint( + 'CollectionJobTestStatus', + 'CollectionJob_Test_unique', + { transaction } + ); + await queryInterface.dropTable('CollectionJobTestStatus', { + transaction + }); + }); + } +}; diff --git a/server/models/CollectionJob.js b/server/models/CollectionJob.js index c66499a80..dc57b229d 100644 --- a/server/models/CollectionJob.js +++ b/server/models/CollectionJob.js @@ -45,6 +45,12 @@ module.exports = function (sequelize, DataTypes) { sourceKey: 'testPlanRunId', as: 'testPlanRun' }); + + Model.hasMany(models.CollectionJobTestStatus, { + as: 'testStatus', + foreignKey: 'collectionJobId', + sourceKey: 'id' + }); }; Model.QUEUED = COLLECTION_JOB_STATUS.QUEUED; diff --git a/server/models/CollectionJobTestStatus.js b/server/models/CollectionJobTestStatus.js new file mode 100644 index 000000000..88427a1dd --- /dev/null +++ b/server/models/CollectionJobTestStatus.js @@ -0,0 +1,56 @@ +const { COLLECTION_JOB_STATUS } = require('../util/enums'); + +const MODEL_NAME = 'CollectionJobTestStatus'; + +module.exports = function (sequelize, DataTypes) { + const Model = sequelize.define( + MODEL_NAME, + { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true + }, + collectionJobId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'CollectionJob', + key: 'id' + } + }, + testId: { + type: DataTypes.STRING, + allowNull: null + }, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: COLLECTION_JOB_STATUS.QUEUED + } + }, + { + timestamps: false, + tableName: MODEL_NAME + } + ); + + Model.associate = function (models) { + Model.belongsTo(models.CollectionJob, { + foreignKey: 'collectionJobId', + targetKey: 'id', + as: 'collectionJob', + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }); + }; + + Model.QUEUED = COLLECTION_JOB_STATUS.QUEUED; + Model.RUNNING = COLLECTION_JOB_STATUS.RUNNING; + Model.COMPLETED = COLLECTION_JOB_STATUS.COMPLETED; + Model.CANCELLED = COLLECTION_JOB_STATUS.CANCELLED; + Model.ERROR = COLLECTION_JOB_STATUS.ERROR; + + return Model; +}; diff --git a/server/models/services/CollectionJobService.js b/server/models/services/CollectionJobService.js index a73dbd20e..bbe756bd9 100644 --- a/server/models/services/CollectionJobService.js +++ b/server/models/services/CollectionJobService.js @@ -1,5 +1,5 @@ const ModelService = require('./ModelService'); -const { CollectionJob } = require('../'); +const { CollectionJob, CollectionJobTestStatus } = require('../'); const { COLLECTION_JOB_ATTRIBUTES, TEST_PLAN_ATTRIBUTES, @@ -8,7 +8,8 @@ const { TEST_PLAN_VERSION_ATTRIBUTES, AT_ATTRIBUTES, BROWSER_ATTRIBUTES, - USER_ATTRIBUTES + USER_ATTRIBUTES, + COLLECTION_JOB_TEST_STATUS_ATTRIBUTES } = require('./helpers'); const { COLLECTION_JOB_STATUS } = require('../../util/enums'); const { Op } = require('sequelize'); @@ -175,10 +176,21 @@ const userAssociation = userAttributes => ({ attributes: userAttributes }); +/** + * @param {string[]} collectionJobTestStatusAttributes - attributes to be returned in the result + * @returns {{association: string, attributes: string[]}} + */ +const collectionJobTestStatusAssociation = + collectionJobTestStatusAttributes => ({ + association: 'testStatus', + attributes: collectionJobTestStatusAttributes + }); + /** * @param {object} options * @param {object} options.values - CollectionJob to be created - * @param {string[]} options.collectionJobAttributes - TestPlanRun attributes to be returned in the result + * @param {string[]} options.collectionJobAttributes - CollectionJob attributes to be returned in the result + * @param {string[]} options.collectionJobTestStatusAttributes - CollectionJobTestStatus attributes to be returned in the result * @param {string[]} options.testPlanReportAttributes - TestPlanReport attributes to be returned in the result * @param {string[]} options.testPlanVersionAttributes - TestPlanVersion attributes to be returned in the result * @param {string[]} options.testPlanAttributes - TestPlanVersion attributes to be returned in the result @@ -195,6 +207,7 @@ const createCollectionJob = async ({ testPlanReportId }, collectionJobAttributes = COLLECTION_JOB_ATTRIBUTES, + collectionJobTestStatusAttributes = COLLECTION_JOB_TEST_STATUS_ATTRIBUTES, testPlanReportAttributes = TEST_PLAN_REPORT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, testPlanVersionAttributes = TEST_PLAN_VERSION_ATTRIBUTES, @@ -222,10 +235,29 @@ const createCollectionJob = async ({ transaction }); + // create QUEUED status entries for each test in the test plan run + const context = getGraphQLContext({ req: { transaction } }); + const tests = await runnableTestsResolver( + testPlanRun.testPlanReport, + null, + context + ); + await ModelService.bulkCreate(CollectionJobTestStatus, { + valuesList: tests.map(test => ({ + testId: test.id, + collectionJobId: collectionJobResult.id, + status: COLLECTION_JOB_STATUS.QUEUED + })), + transaction + }); + return ModelService.getById(CollectionJob, { id: collectionJobResult.id, attributes: collectionJobAttributes, include: [ + collectionJobTestStatusAssociation( + collectionJobTestStatusAttributes + ), testPlanRunAssociation( testPlanRunAttributes, userAttributes, @@ -243,7 +275,8 @@ const createCollectionJob = async ({ /** * @param {object} options * @param {string} options.id - id for the CollectionJob - * @param {string[]} options.collectionJobAttributes - TestPlanRun attributes to be returned in the result + * @param {string[]} options.collectionJobAttributes - CollectionJob attributes to be returned in the result + * @param {string[]} options.collectionJobTestStatusAttributes - CollectionJobTestStatus attributes to be returned in the result * @param {string[]} options.testPlanReportAttributes - TestPlanReport attributes to be returned in the result * @param {string[]} options.testPlanVersionAttributes - TestPlanVersion attributes to be returned in the result * @param {string[]} options.testPlanAttributes - TestPlanVersion attributes to be returned in the result @@ -256,6 +289,7 @@ const createCollectionJob = async ({ const getCollectionJobById = async ({ id, collectionJobAttributes = COLLECTION_JOB_ATTRIBUTES, + collectionJobTestStatusAttributes = COLLECTION_JOB_TEST_STATUS_ATTRIBUTES, testPlanReportAttributes = TEST_PLAN_REPORT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, testPlanVersionAttributes = TEST_PLAN_VERSION_ATTRIBUTES, @@ -269,6 +303,9 @@ const getCollectionJobById = async ({ id, attributes: collectionJobAttributes, include: [ + collectionJobTestStatusAssociation( + collectionJobTestStatusAttributes + ), testPlanRunAssociation( testPlanRunAttributes, userAttributes, @@ -287,7 +324,8 @@ const getCollectionJobById = async ({ * @param {object} options * @param {string|any} options.search - use this to combine with {@param filter} to be passed to Sequelize's where clause * @param {object} options.where - use this define conditions to be passed to Sequelize's where clause - * @param {string[]} options.collectionJobAttributes - Browser attributes to be returned in the result + * @param {string[]} options.collectionJobAttributes - CollectionJob attributes to be returned in the result + * @param {string[]} options.collectionJobTestStatusAttributes - CollectionJobTestStatus attributes to be returned in the result * @param {string[]} options.testPlanReportAttributes - TestPlanReport attributes to be returned in the result * @param {string[]} options.testPlanVersionAttributes - TestPlanVersion attributes to be returned in the result * @param {string[]} options.testPlanAttributes - TestPlanVersion attributes to be returned in the result @@ -306,6 +344,7 @@ const getCollectionJobs = async ({ search, where = {}, collectionJobAttributes = COLLECTION_JOB_ATTRIBUTES, + collectionJobTestStatusAttributes = COLLECTION_JOB_TEST_STATUS_ATTRIBUTES, testPlanReportAttributes = TEST_PLAN_REPORT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, testPlanVersionAttributes = TEST_PLAN_VERSION_ATTRIBUTES, @@ -324,6 +363,9 @@ const getCollectionJobs = async ({ where, attributes: collectionJobAttributes, include: [ + collectionJobTestStatusAssociation( + collectionJobTestStatusAttributes + ), testPlanRunAssociation( testPlanRunAttributes, userAttributes, @@ -382,7 +424,8 @@ const triggerWorkflow = async (job, testIds, { transaction }) => { * @param {object} options * @param {string} options.id - id of the CollectionJob to be updated * @param {object} options.values - values to be used to update columns for the record being referenced for {@param id} - * @param {string[]} options.collectionJobAttributes - Browser attributes to be returned in the result + * @param {string[]} options.collectionJobAttributes - CollectionJob attributes to be returned in the result + * @param {string[]} options.collectionJobTestStatusAttributes - CollectionJobTestStatus attributes to be returned in the result * @param {string[]} options.testPlanReportAttributes - TestPlanReport attributes to be returned in the result * @param {string[]} options.testPlanVersionAttributes - TestPlanVersion attributes to be returned in the result * @param {string[]} options.testPlanAttributes - TestPlanVersion attributes to be returned in the result @@ -396,6 +439,7 @@ const updateCollectionJobById = async ({ id, values = {}, collectionJobAttributes = COLLECTION_JOB_ATTRIBUTES, + collectionJobTestStatusAttributes = COLLECTION_JOB_TEST_STATUS_ATTRIBUTES, testPlanReportAttributes = TEST_PLAN_REPORT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, testPlanVersionAttributes = TEST_PLAN_VERSION_ATTRIBUTES, @@ -415,6 +459,9 @@ const updateCollectionJobById = async ({ id, attributes: collectionJobAttributes, include: [ + collectionJobTestStatusAssociation( + collectionJobTestStatusAttributes + ), testPlanRunAssociation( testPlanRunAttributes, userAttributes, @@ -592,6 +639,30 @@ const restartCollectionJob = async ({ id }, { transaction }) => { return triggerWorkflow(job, [], { transaction }); }; +/** + * CollectionJobTestStatus updates + */ + +/** + * Update CollectionJobTestStatus entries in bulk via query. + * @param {object} options + * @param {object} options.where - values of the CollectionJobTestStatus record to be updated + * @param {object} options.values - values to be used to update columns for the record being referenced + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<*>} + */ +const updateCollectionJobTestStatusByQuery = ({ + where, + values = {}, + transaction +}) => { + return ModelService.update(CollectionJobTestStatus, { + values, + where, + transaction + }); +}; + module.exports = { // Basic CRUD createCollectionJob, @@ -603,5 +674,7 @@ module.exports = { scheduleCollectionJob, restartCollectionJob, cancelCollectionJob, - retryCanceledCollections + retryCanceledCollections, + // Basic CRUD for CollectionJobTestStatus + updateCollectionJobTestStatusByQuery }; diff --git a/server/models/services/helpers.js b/server/models/services/helpers.js index 32c75b7cb..f7f188c71 100644 --- a/server/models/services/helpers.js +++ b/server/models/services/helpers.js @@ -11,7 +11,8 @@ const { User, UserRoles, UserAts, - CollectionJob + CollectionJob, + CollectionJobTestStatus } = require('../index'); /** @@ -39,5 +40,8 @@ module.exports = { USER_ATTRIBUTES: getSequelizeModelAttributes(User), USER_ROLES_ATTRIBUTES: getSequelizeModelAttributes(UserRoles), USER_ATS_ATTRIBUTES: getSequelizeModelAttributes(UserAts), - COLLECTION_JOB_ATTRIBUTES: getSequelizeModelAttributes(CollectionJob) + COLLECTION_JOB_ATTRIBUTES: getSequelizeModelAttributes(CollectionJob), + COLLECTION_JOB_TEST_STATUS_ATTRIBUTES: getSequelizeModelAttributes( + CollectionJobTestStatus + ) }; diff --git a/server/resolvers/CollectionJob/index.js b/server/resolvers/CollectionJob/index.js new file mode 100644 index 000000000..64347cf91 --- /dev/null +++ b/server/resolvers/CollectionJob/index.js @@ -0,0 +1,3 @@ +const testStatus = require('./testStatusResolver'); +const testPlanRun = require('./testPlanRunResolver'); +module.exports = { testStatus, testPlanRun }; diff --git a/server/resolvers/CollectionJob/testPlanRunResolver.js b/server/resolvers/CollectionJob/testPlanRunResolver.js new file mode 100644 index 000000000..62014f8bd --- /dev/null +++ b/server/resolvers/CollectionJob/testPlanRunResolver.js @@ -0,0 +1,10 @@ +// eslint-disable-next-line no-unused-vars +const testPlanRunResolver = (collectionJob, _, context) => { + if (collectionJob.__testPlanRunChild) { + throw new Error( + 'can not request TestPlanRun.collectionJob.testPlanRun' + ); + } + return collectionJob.testPlanRun; +}; +module.exports = testPlanRunResolver; diff --git a/server/resolvers/CollectionJob/testStatusResolver.js b/server/resolvers/CollectionJob/testStatusResolver.js new file mode 100644 index 000000000..1c46a5d2b --- /dev/null +++ b/server/resolvers/CollectionJob/testStatusResolver.js @@ -0,0 +1,15 @@ +// eslint-disable-next-line no-unused-vars +const testStatusResolver = (collectionJob, _, context) => { + // resolve testStatus.test for each test + // testPlanRun "might" be null if the testPlanRun had been deleted + const testPlanTests = + collectionJob.testPlanRun?.testPlanReport.testPlanVersion.tests ?? []; + const tests = new Map(testPlanTests.map(test => [test.id, test])); + + return collectionJob.testStatus.map(status => ({ + ...status.dataValues, + // if not found, at least return the test id + test: tests.get(status.testId) ?? { id: status.testId } + })); +}; +module.exports = testStatusResolver; diff --git a/server/resolvers/CollectionJobOperations/cancelCollectionJobResolver.js b/server/resolvers/CollectionJobOperations/cancelCollectionJobResolver.js index ea53c36e9..c7240ce50 100644 --- a/server/resolvers/CollectionJobOperations/cancelCollectionJobResolver.js +++ b/server/resolvers/CollectionJobOperations/cancelCollectionJobResolver.js @@ -2,7 +2,8 @@ const { AuthenticationError } = require('apollo-server'); const { updateCollectionJobById, - getCollectionJobById + getCollectionJobById, + updateCollectionJobTestStatusByQuery } = require('../../models/services/CollectionJobService'); const { COLLECTION_JOB_STATUS } = require('../../util/enums'); @@ -36,6 +37,11 @@ const cancelCollectionJobResolver = async ( if (collectionJob.status === COLLECTION_JOB_STATUS.COMPLETED) { return collectionJob; } else { + await updateCollectionJobTestStatusByQuery({ + where: { collectionJobId, status: COLLECTION_JOB_STATUS.QUEUED }, + values: { status: COLLECTION_JOB_STATUS.CANCELLED }, + transaction + }); return updateCollectionJobById({ id: collectionJobId, values: { status: COLLECTION_JOB_STATUS.CANCELLED }, diff --git a/server/resolvers/TestPlanRun/collectionJobResolver.js b/server/resolvers/TestPlanRun/collectionJobResolver.js new file mode 100644 index 000000000..ecfb6552c --- /dev/null +++ b/server/resolvers/TestPlanRun/collectionJobResolver.js @@ -0,0 +1,19 @@ +const collectionJobByTestPlanRunIdResolver = require('../collectionJobByTestPlanRunIdResolver'); + +const collectionJobResolver = async ( + testPlanRun, + args, // eslint-disable-line no-unused-vars + context // eslint-disable-line no-unused-vars +) => { + const collectionJob = await collectionJobByTestPlanRunIdResolver( + null, + { testPlanRunId: testPlanRun.id }, + context + ); + if (collectionJob) { + return { ...collectionJob.dataValues, __testPlanRunChild: true }; + } + return collectionJob; +}; + +module.exports = collectionJobResolver; diff --git a/server/resolvers/TestPlanRun/index.js b/server/resolvers/TestPlanRun/index.js index 74770a793..f542d26d4 100644 --- a/server/resolvers/TestPlanRun/index.js +++ b/server/resolvers/TestPlanRun/index.js @@ -1,7 +1,8 @@ const testResults = require('./testResultsResolver'); const testResultsLength = require('./testResultsLengthResolver'); - +const collectionJob = require('./collectionJobResolver'); module.exports = { + collectionJob, testResults, testResultsLength }; diff --git a/server/resolvers/index.js b/server/resolvers/index.js index 5e097b81d..5cf7dece6 100644 --- a/server/resolvers/index.js +++ b/server/resolvers/index.js @@ -41,6 +41,7 @@ const TestPlanRunOperations = require('./TestPlanRunOperations'); const TestResultOperations = require('./TestResultOperations'); const TestPlanVersionOperations = require('./TestPlanVersionOperations'); const CollectionJobOperations = require('./CollectionJobOperations'); +const CollectionJob = require('./CollectionJob'); const TestPlanRun = require('./TestPlanRun'); const Test = require('./Test'); const ScenarioResult = require('./ScenarioResult'); @@ -84,6 +85,7 @@ const resolvers = { AtOperations, AtVersionOperations, BrowserOperations, + CollectionJob, User, TestPlan, TestPlanVersion, diff --git a/server/routes/automation.js b/server/routes/automation.js index 460516af7..5d6540e64 100644 --- a/server/routes/automation.js +++ b/server/routes/automation.js @@ -10,9 +10,12 @@ const { handleError } = require('../middleware/handleError'); const router = Router(); -router.post('/:jobID/update', verifyAutomationScheduler, updateJobStatus); - -router.post('/:jobID/result', verifyAutomationScheduler, updateJobResults); +router.post('/:jobID', verifyAutomationScheduler, updateJobStatus); +router.post( + '/:jobID/test/:testRowNumber', + verifyAutomationScheduler, + updateJobResults +); router.use(handleError); diff --git a/server/services/GithubWorkflowService.js b/server/services/GithubWorkflowService.js index 9d21765d7..2fe571563 100644 --- a/server/services/GithubWorkflowService.js +++ b/server/services/GithubWorkflowService.js @@ -128,8 +128,8 @@ const createGithubWorkflow = async ({ job, directory, gitSha }) => { ); const browser = job.testPlanRun.testPlanReport.browser.name.toLowerCase(); const inputs = { - callback_url: `https://${callbackUrlHostname}/api/jobs/${job.id}/result`, - status_url: `https://${callbackUrlHostname}/api/jobs/${job.id}/update`, + callback_url: `https://${callbackUrlHostname}/api/jobs/${job.id}/test/:testRowNumber`, + status_url: `https://${callbackUrlHostname}/api/jobs/${job.id}`, callback_header: `x-automation-secret:${process.env.AUTOMATION_SCHEDULER_SECRET}`, work_dir: `tests/${directory}`, test_pattern: '{reference/**,test-*-nvda.*}', diff --git a/server/tests/integration/automation-scheduler.test.js b/server/tests/integration/automation-scheduler.test.js index 09f5b209b..0dcd800da 100644 --- a/server/tests/integration/automation-scheduler.test.js +++ b/server/tests/integration/automation-scheduler.test.js @@ -12,6 +12,7 @@ const markAsFinalResolver = require('../../resolvers/TestPlanReportOperations/ma const AtLoader = require('../../models/loaders/AtLoader'); const BrowserLoader = require('../../models/loaders/BrowserLoader'); const getGraphQLContext = require('../../graphql-context'); +const { COLLECTION_JOB_STATUS } = require('../../util/enums'); let mockAutomationSchedulerServer; let apiServer; @@ -138,6 +139,10 @@ const getTestCollectionJob = async (jobId, { transaction }) => } } } + testStatus { + test { id } + status + } } } `, @@ -166,6 +171,10 @@ const scheduleCollectionJobByMutation = async ({ transaction }) => } } } + testStatus { + test { id } + status + } } } `, @@ -288,7 +297,7 @@ describe('Automation controller', () => { }); }); - it('should cancel a job', async () => { + it('should cancel a job and all remaining tests', async () => { await dbCleaner(async transaction => { const { scheduleCollectionJob: job } = await scheduleCollectionJobByMutation({ transaction }); @@ -300,6 +309,9 @@ describe('Automation controller', () => { const { collectionJob: storedCollectionJob } = await getTestCollectionJob(job.id, { transaction }); expect(storedCollectionJob.status).toEqual('CANCELLED'); + for (const test of storedCollectionJob.testStatus) { + expect(test.status).toEqual('CANCELLED'); + } }); }); @@ -342,9 +354,7 @@ describe('Automation controller', () => { await dbCleaner(async transaction => { const { scheduleCollectionJob: job } = await scheduleCollectionJobByMutation({ transaction }); - const response = await sessionAgent.post( - `/api/jobs/${job.id}/update` - ); + const response = await sessionAgent.post(`/api/jobs/${job.id}`); expect(response.statusCode).toBe(403); expect(response.body).toEqual({ error: 'Unauthorized' @@ -357,7 +367,7 @@ describe('Automation controller', () => { const { scheduleCollectionJob: job } = await scheduleCollectionJobByMutation({ transaction }); const response = await sessionAgent - .post(`/api/jobs/${job.id}/update`) + .post(`/api/jobs/${job.id}`) .send({ status: 'INVALID' }) .set( 'x-automation-secret', @@ -372,7 +382,7 @@ describe('Automation controller', () => { it('should fail to update a job status for a non-existent jobId', async () => { const response = await sessionAgent - .post(`/api/jobs/${444}/update`) + .post(`/api/jobs/${444}`) .send({ status: 'RUNNING' }) .set( 'x-automation-secret', @@ -389,7 +399,7 @@ describe('Automation controller', () => { const { scheduleCollectionJob: job } = await scheduleCollectionJobByMutation({ transaction }); const response = await sessionAgent - .post(`/api/jobs/${job.id}/update`) + .post(`/api/jobs/${job.id}`) .send({ status: 'RUNNING' }) .set( 'x-automation-secret', @@ -421,7 +431,7 @@ describe('Automation controller', () => { const { scheduleCollectionJob: job } = await scheduleCollectionJobByMutation({ transaction }); const response = await sessionAgent - .post(`/api/jobs/${job.id}/update`) + .post(`/api/jobs/${job.id}`) .send({ status: 'CANCELLED', externalLogsUrl: 'https://www.aol.com/' @@ -462,7 +472,7 @@ describe('Automation controller', () => { transaction }); await sessionAgent - .post(`/api/jobs/${job.id}/update`) + .post(`/api/jobs/${job.id}`) .send({ status: 'RUNNING' }) .set( 'x-automation-secret', @@ -492,9 +502,8 @@ describe('Automation controller', () => { scenario => scenario.atId === at.id ).length; const response = await sessionAgent - .post(`/api/jobs/${job.id}/result`) + .post(`/api/jobs/${job.id}/test/${selectedTestRowNumber}`) .send({ - testCsvRow: selectedTestRowNumber, capabilities: { atName: at.name, atVersion: at.atVersions[0].name, @@ -543,6 +552,118 @@ describe('Automation controller', () => { ); }); }); + // also marks status for test as COMPLETED + const { collectionJob: storedCollectionJob } = + await getTestCollectionJob(job.id, { transaction }); + expect(storedCollectionJob.id).toEqual(job.id); + expect(storedCollectionJob.status).toEqual('RUNNING'); + const testStatus = storedCollectionJob.testStatus.find( + status => status.test.id === selectedTest.id + ); + expect(testStatus.status).toEqual(COLLECTION_JOB_STATUS.COMPLETED); + }); + }); + + it('should properly handle per-test status updates without capabilities present', async () => { + await apiServer.sessionAgentDbCleaner(async transaction => { + const { scheduleCollectionJob: job } = + await scheduleCollectionJobByMutation({ transaction }); + const collectionJob = await getCollectionJobById({ + id: job.id, + transaction + }); + // flag overall job as RUNNING + const externalLogsUrl = 'https://example.com/test/log/url'; + await sessionAgent + .post(`/api/jobs/${job.id}`) + .send({ status: 'RUNNING', externalLogsUrl }) + .set( + 'x-automation-secret', + process.env.AUTOMATION_SCHEDULER_SECRET + ) + .set('x-transaction-id', transaction.id); + + const { tests } = + collectionJob.testPlanRun.testPlanReport.testPlanVersion; + const selectedTestIndex = 0; + const selectedTestRowNumber = 1; + const selectedTest = tests[selectedTestIndex]; + let response = await sessionAgent + .post(`/api/jobs/${job.id}/test/${selectedTestRowNumber}`) + .send({ + status: COLLECTION_JOB_STATUS.RUNNING + }) + .set( + 'x-automation-secret', + process.env.AUTOMATION_SCHEDULER_SECRET + ) + .set('x-transaction-id', transaction.id); + expect(response.statusCode).toBe(200); + + let { collectionJob: storedCollectionJob } = + await getTestCollectionJob(job.id, { transaction }); + expect(storedCollectionJob.id).toEqual(job.id); + expect(storedCollectionJob.status).toEqual('RUNNING'); + expect(storedCollectionJob.externalLogsUrl).toEqual( + externalLogsUrl + ); + let foundStatus = false; + for (const testStatus of storedCollectionJob.testStatus) { + let expectedStatus = COLLECTION_JOB_STATUS.QUEUED; + if (testStatus.test.id === selectedTest.id) { + foundStatus = true; + expectedStatus = COLLECTION_JOB_STATUS.RUNNING; + } + expect(testStatus.status).toEqual(expectedStatus); + } + expect(foundStatus).toEqual(true); + + // check that putting this test into ERROR and sending an overall + // collection job ERROR will properly update things + response = await sessionAgent + .post(`/api/jobs/${job.id}/test/${selectedTestRowNumber}`) + .send({ + status: COLLECTION_JOB_STATUS.ERROR + }) + .set( + 'x-automation-secret', + process.env.AUTOMATION_SCHEDULER_SECRET + ) + .set('x-transaction-id', transaction.id); + expect(response.statusCode).toBe(200); + + response = await sessionAgent + .post(`/api/jobs/${job.id}`) + .send({ + // avoiding sending externalLogsUrl here to test that when + // missing it is not overwritten/emptied. + status: COLLECTION_JOB_STATUS.ERROR + }) + .set( + 'x-automation-secret', + process.env.AUTOMATION_SCHEDULER_SECRET + ) + .set('x-transaction-id', transaction.id); + expect(response.statusCode).toBe(200); + + storedCollectionJob = ( + await getTestCollectionJob(job.id, { transaction }) + ).collectionJob; + expect(storedCollectionJob.id).toEqual(job.id); + expect(storedCollectionJob.status).toEqual('ERROR'); + expect(storedCollectionJob.externalLogsUrl).toEqual( + externalLogsUrl + ); + foundStatus = false; + for (const testStatus of storedCollectionJob.testStatus) { + let expectedStatus = COLLECTION_JOB_STATUS.CANCELLED; + if (testStatus.test.id === selectedTest.id) { + foundStatus = true; + expectedStatus = COLLECTION_JOB_STATUS.ERROR; + } + expect(testStatus.status).toEqual(expectedStatus); + } + expect(foundStatus).toEqual(true); }); }); @@ -588,7 +709,7 @@ describe('Automation controller', () => { transaction }); await sessionAgent - .post(`/api/jobs/${job.id}/update`) + .post(`/api/jobs/${job.id}`) .send({ status: 'RUNNING' }) .set( 'x-automation-secret', @@ -597,9 +718,8 @@ describe('Automation controller', () => { .set('x-transaction-id', transaction.id); const response = await sessionAgent - .post(`/api/jobs/${job.id}/result`) + .post(`/api/jobs/${job.id}/test/${selectedTestRowNumber}`) .send({ - testCsvRow: selectedTestRowNumber, capabilities: { atName: at.name, atVersion: atVersion.name, diff --git a/server/tests/integration/graphql.test.js b/server/tests/integration/graphql.test.js index 9bfeca0d8..20f8a4bf6 100644 --- a/server/tests/integration/graphql.test.js +++ b/server/tests/integration/graphql.test.js @@ -140,7 +140,8 @@ describe('graphql', () => { // 'TestResult' 'Issue', 'Vendor', - 'scheduleCollectionJob' + 'scheduleCollectionJob', + 'CollectionJobTestStatus' ]; const excludedTypeNameAndField = [ // Items formatted like this: @@ -159,6 +160,7 @@ describe('graphql', () => { ['Command', 'atOperatingMode'], // TODO: Include when v2 test format CI tests are done ['CollectionJob', 'testPlanRun'], ['CollectionJob', 'externalLogsUrl'], + ['CollectionJob', 'testStatus'], // These interact with Response Scheduler API // which is mocked in other tests. ['Mutation', 'scheduleCollectionJob'], @@ -504,10 +506,11 @@ describe('graphql', () => { releasedAt } } - testPlanRun(id: 3) { + testPlanRun(id: 1) { __typename id initiatedByAutomation + collectionJob { id } testPlanReport { id } @@ -781,6 +784,31 @@ describe('graphql', () => { ); }); + // esure recursive query of collectionJob<>testPlanRun fails at some depth + await expect( + typeAwareQuery( + gql` + query { + collectionJob(id: 1) { + id + testPlanRun { + id + collectionJob { + id + testPlanRun { + id + } + } + } + } + } + `, + { + transaction: false + } + ) + ).rejects.toBeDefined(); + expect(() => { const missingTypes = checkForMissingTypes(); if (missingTypes.length) { diff --git a/server/tests/util/mock-automation-scheduler-server.js b/server/tests/util/mock-automation-scheduler-server.js index eeec2dd23..ca9c65ec1 100644 --- a/server/tests/util/mock-automation-scheduler-server.js +++ b/server/tests/util/mock-automation-scheduler-server.js @@ -12,6 +12,9 @@ const { } = require('../../middleware/transactionMiddleware'); const { query } = require('../util/graphql-test-utilities'); +// 0 = no chance of test errors, 1 = always errors +const TEST_ERROR_CHANCE = 0; + const setupMockAutomationSchedulerServer = async () => { const app = express(); app.use(express.json()); @@ -26,9 +29,22 @@ const setupMockAutomationSchedulerServer = async () => { shutdownManager = new GracefulShutdownManager(listener); }); + const timeout = ms => + new Promise(resolve => setTimeout(() => resolve(), ms)); + const simulateJobStatusUpdate = async (jobId, newStatus) => { await axios.post( - `${process.env.APP_SERVER}/api/jobs/${jobId}/update`, + `${process.env.APP_SERVER}/api/jobs/${jobId}`, + { + status: newStatus + }, + axiosConfig + ); + }; + + const simulateTestStatusUpdate = async (jobId, testId, newStatus) => { + await axios.post( + `${process.env.APP_SERVER}/api/jobs/${jobId}/test/${testId}`, { status: newStatus }, @@ -66,22 +82,35 @@ const setupMockAutomationSchedulerServer = async () => { responses }; - testResult[isV2 ? 'presentationNumber' : 'testCsvRow'] = - currentTest.rowNumber; try { - await axios.post( - `${process.env.APP_SERVER}/api/jobs/${jobId}/result`, - testResult, - axiosConfig + await simulateTestStatusUpdate( + jobId, + currentTest.rowNumber, + COLLECTION_JOB_STATUS.RUNNING ); - } catch (e) { - // Likely just means the test was cancelled - return; - } + await timeout(Math.random() * 2000); + + if (Math.random() < TEST_ERROR_CHANCE) { + await simulateTestStatusUpdate( + jobId, + currentTest.rowNumber, + COLLECTION_JOB_STATUS.ERROR + ); + return simulateJobStatusUpdate( + jobId, + COLLECTION_JOB_STATUS.ERROR + ); + } else { + await axios.post( + `${process.env.APP_SERVER}/api/jobs/${jobId}/test/${currentTest.rowNumber}`, + testResult, + axiosConfig + ); + } - if (currentTestIndex < tests.length - 1) { - setTimeout(() => { - simulateResultCompletion( + if (currentTestIndex < tests.length - 1) { + await timeout(Math.random() * 5000); + return simulateResultCompletion( tests, atName, atVersionName, @@ -91,19 +120,12 @@ const setupMockAutomationSchedulerServer = async () => { currentTestIndex + 1, isV2 ); - }, Math.random() * 5000); - } else { - setTimeout( - () => - simulateJobStatusUpdate( - jobId, - COLLECTION_JOB_STATUS.COMPLETED - ), - 1000 - ); + } else { + simulateJobStatusUpdate(jobId, COLLECTION_JOB_STATUS.COMPLETED); + } + } catch (error) { + console.error('Error simulating collection job', error); } - - return testResult; }; app.post('/jobs/new', async (req, res) => { diff --git a/server/util/enums.js b/server/util/enums.js index ce07982d2..56ab4c579 100644 --- a/server/util/enums.js +++ b/server/util/enums.js @@ -6,6 +6,12 @@ const COLLECTION_JOB_STATUS = { CANCELLED: 'CANCELLED' }; +const isJobStatusFinal = status => + status === COLLECTION_JOB_STATUS.COMPLETED || + status === COLLECTION_JOB_STATUS.CANCELLED || + status === COLLECTION_JOB_STATUS.ERROR; + module.exports = { - COLLECTION_JOB_STATUS + COLLECTION_JOB_STATUS, + isJobStatusFinal };