From 56ba94bc7d30cd862b600e884a9b8e10f223dcb4 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 17:58:46 -0700 Subject: [PATCH 1/7] Reproduce the problem --- test/app-forms.spec.js | 6 ++ test/collateral/forms/app/bug_269.xlsx | Bin 0 -> 13697 bytes test/collateral/forms/app/bug_269.xml | 78 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 test/collateral/forms/app/bug_269.xlsx create mode 100644 test/collateral/forms/app/bug_269.xml diff --git a/test/app-forms.spec.js b/test/app-forms.spec.js index 4d5ff6f..f0d9168 100644 --- a/test/app-forms.spec.js +++ b/test/app-forms.spec.js @@ -358,4 +358,10 @@ describe('forms that have caused bugs', () => { expect(result.errors).to.be.empty; expect(result.report.fields.fp_follow_up.display_is_muted).to.eq(actual); }); + + it('#269 - submit before checking for errors', async () => { + const result = await harness.fillForm('bug_123', ['1', '1']); + await new Promise(resolve => {}); + expect(result.errors).to.be.empty; + }); }); diff --git a/test/collateral/forms/app/bug_269.xlsx b/test/collateral/forms/app/bug_269.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..69e728f11a19dab968dbcfd25ace80ff56972e13 GIT binary patch literal 13697 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 + + + U5 Assessment + + + + + + + + + + + + + user + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + 1 + + + + 2 + + + + + + + 0 + + + + 1 + + + + 2 + + + + + From afe46aa08a6d2531d9ea9f5261e6a80becd5dd56 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 18:01:07 -0700 Subject: [PATCH 2/7] Form rename --- test/app-forms.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/app-forms.spec.js b/test/app-forms.spec.js index f0d9168..e28c774 100644 --- a/test/app-forms.spec.js +++ b/test/app-forms.spec.js @@ -360,7 +360,7 @@ describe('forms that have caused bugs', () => { }); it('#269 - submit before checking for errors', async () => { - const result = await harness.fillForm('bug_123', ['1', '1']); + const result = await harness.fillForm('bug_269', ['1', '1']); await new Promise(resolve => {}); expect(result.errors).to.be.empty; }); From 2c5e0a9a8a89aaa2dd845b155fd373533a091700 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 18:05:20 -0700 Subject: [PATCH 3/7] Confirmed test is passing in 3.x branch --- test/app-forms.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/app-forms.spec.js b/test/app-forms.spec.js index e28c774..8ba1e3e 100644 --- a/test/app-forms.spec.js +++ b/test/app-forms.spec.js @@ -361,7 +361,6 @@ describe('forms that have caused bugs', () => { it('#269 - submit before checking for errors', async () => { const result = await harness.fillForm('bug_269', ['1', '1']); - await new Promise(resolve => {}); expect(result.errors).to.be.empty; }); }); From f537fe60c7972949ded4819e6d27d1f30fb7eea9 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 18:36:12 -0700 Subject: [PATCH 4/7] Proposed fix --- dist/form-host.dev.js | 2 +- src/form-host/form-filler.js | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/dist/form-host.dev.js b/dist/form-host.dev.js index 323f681..2f564d4 100644 --- a/dist/form-host.dev.js +++ b/dist/form-host.dev.js @@ -548,7 +548,7 @@ eval("const { useFakeTimers } = __webpack_require__(/*! sinon/lib/sinon/util/fak \**************************************/ /***/ ((module, __unused_webpack_exports, __webpack_require__) => { -eval("const _ = __webpack_require__(/*! lodash */ \"./node_modules/lodash/lodash.js\");\n\nconst getForm = () => $('form');\nconst getValidationErrors = () => getForm()\n .find('.invalid-required:not(.disabled), .invalid-constraint:not(.disabled), .invalid-relevant:not(.disabled)')\n .children('span.active:not(.question-label)')\n .filter(function() {\n return $(this).css('display') === 'block';\n });\n\nconst getSiblingElement = ( element, selector = '*' ) =>{\n let found;\n let current = element.parentElement.firstElementChild;\n\n while ( current && !found ) {\n if ( current !== element && current.matches( selector ) ) {\n found = current;\n }\n current = current.nextElementSibling;\n }\n\n return found;\n};\n\n// Copied from https://github.com/enketo/enketo-core/blob/master/src/js/page.js\nconst getPages = () => {\n const form = getForm()[0];\n if(!form.classList.contains('pages')) {\n // This is not a multipage form\n return [form];\n }\n\n const allPages = [...getForm()[0].querySelectorAll( '[role=\"page\"]' )];\n return allPages.filter( el => {\n return !el.closest( '.disabled' ) &&\n ( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||\n // or-repeat-info is only considered a page by itself if it has no sibling repeats\n // When there are siblings repeats, we use CSS trickery to show the + button underneath the last\n // repeat.\n ( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );\n } );\n};\n\nconst getCurrentPage = () => {\n const pages = getPages();\n return pages.find( page => page.classList.contains( 'current' ) ) || pages[pages.length - 1];\n};\n\nclass FormFiller {\n constructor(options) {\n this.options = _.defaults(options, {\n verbose: true,\n });\n this.log = (...args) => this.options.verbose && console.log('FormFiller', ...args);\n }\n\n /**\n * An object describing the result of filling a form.\n * @typedef {Object} FillResult\n * @property {FillError[]} errors A list of errors\n * @property {string} section The page number on which the errors occurred\n * @property {Object} report The report object which resulted from submitting the filled report. Undefined if an error blocks form submission.\n * @property {Object[]} additionalDocs An array of database documents which are created in addition to the report.\n */\n\n /**\n * An object describing an error which has occurred while filling a form.\n * @typedef {Object} FillError\n * @property {string} type A classification of the error [ 'validation', 'general', 'page' ]\n * @property {string} msg Description of the error\n */\n\n async fillForm(multiPageAnswer) {\n const { isComplete, errors } = await fillForm(this, multiPageAnswer);\n return { isComplete, errors };\n }\n\n // Modified from enketo-core/src/js/Form.js validateContent\n async getVisibleValidationErrors() {\n const validationErrors = getValidationErrors();\n\n return Array.from(validationErrors)\n .map(span => ({\n type: 'validation',\n question: span.parentElement.innerText,\n msg: span.innerText,\n }));\n }\n}\n\nconst fillForm = async (self, multiPageAnswer) => {\n self.log(`Filling form in ${multiPageAnswer.length} pages.`);\n const results = [];\n for (const pageIndex in multiPageAnswer) {\n const pageAnswer = multiPageAnswer[pageIndex];\n const result = await fillPage(self, pageAnswer);\n results.push(result);\n\n if (result.errors.length > 0) {\n return {\n errors: result.errors,\n section: `page-${pageIndex}`,\n answers: pageAnswer,\n };\n }\n }\n\n let errors;\n let isComplete;\n let pageHasAdvanced;\n // attempt to submit all the way to the end (replacement for validateAll)\n do {\n pageHasAdvanced = await nextPage();\n errors = await self.getVisibleValidationErrors();\n\n const pages = getPages();\n isComplete = pages.indexOf(getCurrentPage()) === pages.length - 1;\n } while (pageHasAdvanced && !isComplete && !errors.length);\n const incompleteError = isComplete ? [] : [{ type: 'general', msg: 'Form is incomplete' }];\n\n return {\n isComplete,\n errors: [...incompleteError, ...errors],\n };\n};\n\nconst fillPage = async (self, pageAnswer) => {\n self.log(`Answering ${pageAnswer.length} questions.`);\n\n const answeredQuestions = new Set();\n for (let i = 0; i < pageAnswer.length; i++) {\n const answer = pageAnswer[i];\n const $questions = getVisibleQuestions();\n if ($questions.length <= i) {\n return {\n errors: [{\n type: 'page',\n answers: pageAnswer,\n section: `answer-${i}`,\n msg: `Attempted to fill ${pageAnswer.length} questions, but only ${$questions.length} are visible.`,\n }],\n };\n }\n\n const nextUnansweredQuestion = Array.from($questions).find(question => !answeredQuestions.has(question));\n answeredQuestions.add(nextUnansweredQuestion);\n fillQuestion(nextUnansweredQuestion, answer);\n }\n\n const allPagesSuccessful = await nextPage();\n const validationErrors = await self.getVisibleValidationErrors();\n const advanceFailure = allPagesSuccessful || validationErrors.length ? [] : [{\n type: 'general',\n msg: 'Failed to advance to next page',\n }];\n\n return {\n errors: [...advanceFailure, ...validationErrors],\n };\n};\n\nconst fillQuestion = (question, answer) => {\n if(answer === null || answer === undefined) {\n return;\n }\n\n const $question = $(question);\n const allInputs = $question.find('input:not([type=\"hidden\"]),textarea,button,select');\n const firstInput = Array.from(allInputs)[0];\n\n if (!firstInput) {\n throw 'No input field found within question';\n }\n\n if (firstInput.localName === 'textarea') {\n return allInputs.val(answer).trigger('change');\n }\n\n switch (firstInput.type) {\n case 'button':\n // select_one appearance:minimal\n if (firstInput.className.includes('dropdown-toggle')) {\n $question.find(`input[value=\"${answer}\"]:not([checked=\"checked\"])`).click();\n }\n\n // repeate section\n else {\n\n if (!Number.isInteger(answer)) {\n throw `Failed to answer question which is a \"+\" for repeat section. This question expects an answer which is an integer - representing how many times to click the +. \"${answer}\"`;\n }\n\n for (let i = 0; i < answer; ++i) {\n allInputs.click();\n }\n }\n break;\n case 'radio':\n $question.find(`input[value=\"${answer}\"]`).click();\n break;\n case 'date':\n case 'tel':\n case 'number':\n allInputs.val(answer).trigger('change');\n break;\n case 'text':\n if (allInputs.eq(0).parents('.datetimepicker').length) {\n const [date, time] = answer.split(' ', 2);\n if (!time) {\n throw new Error('Elements of type datetime expect input in format: \"2022-12-31 13:21\"');\n }\n\n allInputs.eq(0).datepicker('setDate', date);\n allInputs.eq(1).val(time).trigger('change');\n } else if (allInputs.eq(0).parents('.timepicker').length) {\n allInputs.eq(0).timepicker('setTime', answer);\n } else if (allInputs.parent().hasClass('date')) {\n allInputs.first().datepicker('setDate', answer);\n } else {\n allInputs.val(answer).trigger('change');\n }\n break;\n case 'checkbox': {\n /*\n There are two accepted formats for multi-select checkboxes\n Option 1 - A set of comma-delimited boolean strings representing the state of the boxes. eg. \"true,false,true\" checks the first and third box\n Option 2 - A set of comma-delimited values to be checked. eg. \"heart_condition,none\" checks the two boxes with corresponding values\n */\n const answerArray = Array.isArray(answer) ? answer.map(answer => answer.toString()) : answer.split(',');\n const isNonBooleanString = str => !str || !['true', 'false'].includes(str.toLowerCase());\n const answerContainsSpecificValues = answerArray.some(isNonBooleanString);\n\n // [value != \"\"] is necessary because blank lines in `choices` table of xlsx can cause empty unrendered input\n const options = $question.find('input[value!=\"\"]');\n\n if (!answerContainsSpecificValues) {\n answerArray.forEach((val, index) => {\n const propValue = val === true || val.toLowerCase() === 'true' ? 'checked' : '';\n $(options[index]).prop('checked', propValue).trigger('change');\n });\n } else {\n options.prop('checked', '');\n answerArray.forEach(val => $question.find(`input[value=\"${val}\"]`).prop('checked', 'checked').trigger('change'));\n }\n break;\n }\n case 'select-one':\n allInputs.val(answer).trigger('change');\n break;\n default:\n throw `Unhandled input type ${firstInput.type}`;\n }\n};\n\nconst getVisibleQuestions = () => {\n const currentPage = $(getCurrentPage());\n \n if (!currentPage) {\n throw Error('Form has no active pages');\n }\n\n if (currentPage.hasClass('question')) {\n return currentPage;\n }\n\n const findQuestionsInSection = section => {\n const inquisitiveChildren = Array.from($(section)\n .children(`\n section:not(.disabled,.or-appearance-hidden,.or-appearance-android-app-launcher),\n fieldset:not(.disabled,.note,.or-appearance-hidden,.or-appearance-label,#or-calculated-items),\n label:not(.disabled,.readonly,.or-appearance-hidden),\n div.or-repeat-info:not(.disabled,.or-appearance-hidden):not([data-repeat-count]),\n i,\n b\n `));\n\n const result = [];\n for (const child of inquisitiveChildren) {\n const questions = ['section', 'i', 'b'].includes(child.localName) ? findQuestionsInSection(child) : [child];\n result.push(...questions);\n }\n\n return result;\n };\n\n return findQuestionsInSection(currentPage);\n};\n\nconst nextPage = async () => {\n const currentPageIndex = getPages().indexOf(getCurrentPage());\n const nextButton = $('button.next-page');\n if(nextButton.is(':hidden')) {\n return !getValidationErrors().length;\n }\n\n return new Promise(resolve => {\n const observer = new MutationObserver(() => {\n if(getPages().indexOf(getCurrentPage()) > currentPageIndex) {\n observer.disconnect();\n return resolve(true);\n }\n if(getValidationErrors().length) {\n observer.disconnect();\n return resolve(false);\n }\n });\n\n observer.observe(getForm().get(0), {\n childList: true,\n subtree: true,\n attributeFilter: ['class', 'display'],\n });\n nextButton.click();\n });\n};\n\nmodule.exports = FormFiller;\n\n\n//# sourceURL=webpack://cht-conf-test-harness/./src/form-host/form-filler.js?"); +eval("const _ = __webpack_require__(/*! lodash */ \"./node_modules/lodash/lodash.js\");\n\nconst getForm = () => $('form');\nconst getValidationErrors = () => getForm()\n .find('.invalid-required:not(.disabled), .invalid-constraint:not(.disabled), .invalid-relevant:not(.disabled)')\n .children('span.active:not(.question-label)')\n .filter(function() {\n return $(this).css('display') === 'block';\n });\n\nconst getSiblingElement = ( element, selector = '*' ) =>{\n let found;\n let current = element.parentElement.firstElementChild;\n\n while ( current && !found ) {\n if ( current !== element && current.matches( selector ) ) {\n found = current;\n }\n current = current.nextElementSibling;\n }\n\n return found;\n};\n\n// Copied from https://github.com/enketo/enketo-core/blob/master/src/js/page.js\nconst getPages = () => {\n const form = getForm()[0];\n if(!form.classList.contains('pages')) {\n // This is not a multipage form\n return [form];\n }\n\n const allPages = [...getForm()[0].querySelectorAll( '[role=\"page\"]' )];\n return allPages.filter( el => {\n return !el.closest( '.disabled' ) &&\n ( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||\n // or-repeat-info is only considered a page by itself if it has no sibling repeats\n // When there are siblings repeats, we use CSS trickery to show the + button underneath the last\n // repeat.\n ( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );\n } );\n};\n\nconst getCurrentPage = () => {\n const pages = getPages();\n return pages.find( page => page.classList.contains( 'current' ) ) || pages[pages.length - 1];\n};\n\nclass FormFiller {\n constructor(options) {\n this.options = _.defaults(options, {\n verbose: true,\n });\n this.log = (...args) => this.options.verbose && console.log('FormFiller', ...args);\n }\n\n /**\n * An object describing the result of filling a form.\n * @typedef {Object} FillResult\n * @property {FillError[]} errors A list of errors\n * @property {string} section The page number on which the errors occurred\n * @property {Object} report The report object which resulted from submitting the filled report. Undefined if an error blocks form submission.\n * @property {Object[]} additionalDocs An array of database documents which are created in addition to the report.\n */\n\n /**\n * An object describing an error which has occurred while filling a form.\n * @typedef {Object} FillError\n * @property {string} type A classification of the error [ 'validation', 'general', 'page' ]\n * @property {string} msg Description of the error\n */\n\n async fillForm(multiPageAnswer) {\n const { isComplete, errors } = await fillForm(this, multiPageAnswer);\n return { isComplete, errors };\n }\n\n // Modified from enketo-core/src/js/Form.js validateContent\n async getVisibleValidationErrors() {\n const validationErrors = getValidationErrors();\n\n return Array.from(validationErrors)\n .map(span => ({\n type: 'validation',\n question: span.parentElement.innerText,\n msg: span.innerText,\n }));\n }\n}\n\nconst fillForm = async (self, multiPageAnswer) => {\n self.log(`Filling form in ${multiPageAnswer.length} pages.`);\n const results = [];\n for (const pageIndex in multiPageAnswer) {\n const pageAnswer = multiPageAnswer[pageIndex];\n const result = await fillPage(self, pageAnswer);\n results.push(result);\n\n if (result.errors.length > 0) {\n return {\n errors: result.errors,\n section: `page-${pageIndex}`,\n answers: pageAnswer,\n };\n }\n }\n\n let errors;\n let isComplete;\n let pageHasAdvanced;\n // attempt to submit all the way to the end (replacement for validateAll)\n do {\n pageHasAdvanced = await nextPage();\n errors = await self.getVisibleValidationErrors();\n\n const pages = getPages();\n isComplete = pages.indexOf(getCurrentPage()) === pages.length - 1;\n } while (pageHasAdvanced && !isComplete && !errors.length);\n const incompleteError = isComplete ? [] : [{ type: 'general', msg: 'Form is incomplete' }];\n\n return {\n isComplete,\n errors: [...incompleteError, ...errors],\n };\n};\n\nconst fillPage = async (self, pageAnswer) => {\n self.log(`Answering ${pageAnswer.length} questions.`);\n\n const answeredQuestions = new Set();\n for (let i = 0; i < pageAnswer.length; i++) {\n const answer = pageAnswer[i];\n const $questions = getVisibleQuestions();\n if ($questions.length <= i) {\n return {\n errors: [{\n type: 'page',\n answers: pageAnswer,\n section: `answer-${i}`,\n msg: `Attempted to fill ${pageAnswer.length} questions, but only ${$questions.length} are visible.`,\n }],\n };\n }\n\n const nextUnansweredQuestion = Array.from($questions).find(question => !answeredQuestions.has(question));\n answeredQuestions.add(nextUnansweredQuestion);\n fillQuestion(nextUnansweredQuestion, answer);\n }\n\n const allPagesSuccessful = await nextPage();\n const validationErrors = await self.getVisibleValidationErrors();\n const advanceFailure = allPagesSuccessful || validationErrors.length ? [] : [{\n type: 'general',\n msg: 'Failed to advance to next page',\n }];\n\n return {\n errors: [...advanceFailure, ...validationErrors],\n };\n};\n\nconst fillQuestion = (question, answer) => {\n if(answer === null || answer === undefined) {\n return;\n }\n\n const $question = $(question);\n const allInputs = $question.find('input:not([type=\"hidden\"]),textarea,button,select');\n const firstInput = Array.from(allInputs)[0];\n\n if (!firstInput) {\n throw 'No input field found within question';\n }\n\n if (firstInput.localName === 'textarea') {\n return allInputs.val(answer).trigger('change');\n }\n\n switch (firstInput.type) {\n case 'button':\n // select_one appearance:minimal\n if (firstInput.className.includes('dropdown-toggle')) {\n $question.find(`input[value=\"${answer}\"]:not([checked=\"checked\"])`).click();\n }\n\n // repeate section\n else {\n\n if (!Number.isInteger(answer)) {\n throw `Failed to answer question which is a \"+\" for repeat section. This question expects an answer which is an integer - representing how many times to click the +. \"${answer}\"`;\n }\n\n for (let i = 0; i < answer; ++i) {\n allInputs.click();\n }\n }\n break;\n case 'radio':\n $question.find(`input[value=\"${answer}\"]`).click();\n break;\n case 'date':\n case 'tel':\n case 'number':\n allInputs.val(answer).trigger('change');\n break;\n case 'text':\n if (allInputs.eq(0).parents('.datetimepicker').length) {\n const [date, time] = answer.split(' ', 2);\n if (!time) {\n throw new Error('Elements of type datetime expect input in format: \"2022-12-31 13:21\"');\n }\n\n allInputs.eq(0).datepicker('setDate', date);\n allInputs.eq(1).val(time).trigger('change');\n } else if (allInputs.eq(0).parents('.timepicker').length) {\n allInputs.eq(0).timepicker('setTime', answer);\n } else if (allInputs.parent().hasClass('date')) {\n allInputs.first().datepicker('setDate', answer);\n } else {\n allInputs.val(answer).trigger('change');\n }\n break;\n case 'checkbox': {\n /*\n There are two accepted formats for multi-select checkboxes\n Option 1 - A set of comma-delimited boolean strings representing the state of the boxes. eg. \"true,false,true\" checks the first and third box\n Option 2 - A set of comma-delimited values to be checked. eg. \"heart_condition,none\" checks the two boxes with corresponding values\n */\n const answerArray = Array.isArray(answer) ? answer.map(answer => answer.toString()) : answer.split(',');\n const isNonBooleanString = str => !str || !['true', 'false'].includes(str.toLowerCase());\n const answerContainsSpecificValues = answerArray.some(isNonBooleanString);\n\n // [value != \"\"] is necessary because blank lines in `choices` table of xlsx can cause empty unrendered input\n const options = $question.find('input[value!=\"\"]');\n\n if (!answerContainsSpecificValues) {\n answerArray.forEach((val, index) => {\n const propValue = val === true || val.toLowerCase() === 'true' ? 'checked' : '';\n $(options[index]).prop('checked', propValue).trigger('change');\n });\n } else {\n options.prop('checked', '');\n answerArray.forEach(val => $question.find(`input[value=\"${val}\"]`).prop('checked', 'checked').trigger('change'));\n }\n break;\n }\n case 'select-one':\n allInputs.val(answer).trigger('change');\n break;\n default:\n throw `Unhandled input type ${firstInput.type}`;\n }\n};\n\nconst getVisibleQuestions = () => {\n const currentPage = $(getCurrentPage());\n \n if (!currentPage) {\n throw Error('Form has no active pages');\n }\n\n if (currentPage.hasClass('question')) {\n return currentPage;\n }\n\n const findQuestionsInSection = section => {\n const inquisitiveChildren = Array.from($(section)\n .children(`\n section:not(.disabled,.or-appearance-hidden,.or-appearance-android-app-launcher),\n fieldset:not(.disabled,.note,.or-appearance-hidden,.or-appearance-label,#or-calculated-items),\n label:not(.disabled,.readonly,.or-appearance-hidden),\n div.or-repeat-info:not(.disabled,.or-appearance-hidden):not([data-repeat-count]),\n i,\n b\n `));\n\n const result = [];\n for (const child of inquisitiveChildren) {\n const questions = ['section', 'i', 'b'].includes(child.localName) ? findQuestionsInSection(child) : [child];\n result.push(...questions);\n }\n\n return result;\n };\n\n return findQuestionsInSection(currentPage);\n};\n\nconst nextPage = async () => {\n const currentPageIndex = getPages().indexOf(getCurrentPage());\n const nextButton = $('button.next-page');\n const submitButton = $('button.submit');\n const toClick = submitButton.is(':hidden') ? nextButton : submitButton;\n\n if(toClick.is(':hidden')) {\n return !getValidationErrors().length;\n }\n\n return new Promise(resolve => {\n const observer = new MutationObserver(() => {\n if(getPages().indexOf(getCurrentPage()) > currentPageIndex) {\n observer.disconnect();\n return resolve(true);\n }\n\n const success = !getValidationErrors().length;\n observer.disconnect();\n return resolve(success);\n });\n\n observer.observe(getForm().get(0), {\n childList: true,\n subtree: true,\n attributeFilter: ['class', 'display'],\n });\n toClick.click();\n });\n};\n\nmodule.exports = FormFiller;\n\n\n//# sourceURL=webpack://cht-conf-test-harness/./src/form-host/form-filler.js?"); /***/ }), diff --git a/src/form-host/form-filler.js b/src/form-host/form-filler.js index acc8eb8..ad519ff 100644 --- a/src/form-host/form-filler.js +++ b/src/form-host/form-filler.js @@ -289,7 +289,10 @@ const getVisibleQuestions = () => { const nextPage = async () => { const currentPageIndex = getPages().indexOf(getCurrentPage()); const nextButton = $('button.next-page'); - if(nextButton.is(':hidden')) { + const submitButton = $('button.submit'); + const toClick = submitButton.is(':hidden') ? nextButton : submitButton; + + if(toClick.is(':hidden')) { return !getValidationErrors().length; } @@ -299,10 +302,10 @@ const nextPage = async () => { observer.disconnect(); return resolve(true); } - if(getValidationErrors().length) { - observer.disconnect(); - return resolve(false); - } + + const success = !getValidationErrors().length; + observer.disconnect(); + return resolve(success); }); observer.observe(getForm().get(0), { @@ -310,7 +313,7 @@ const nextPage = async () => { subtree: true, attributeFilter: ['class', 'display'], }); - nextButton.click(); + toClick.click(); }); }; From 8982e4ba413c773fd8c2ddbf8b50a78867705333 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 21:15:33 -0700 Subject: [PATCH 5/7] This seems better --- dist/form-host.dev.js | 2 +- src/form-host/form-filler.js | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/dist/form-host.dev.js b/dist/form-host.dev.js index 2f564d4..9771974 100644 --- a/dist/form-host.dev.js +++ b/dist/form-host.dev.js @@ -548,7 +548,7 @@ eval("const { useFakeTimers } = __webpack_require__(/*! sinon/lib/sinon/util/fak \**************************************/ /***/ ((module, __unused_webpack_exports, __webpack_require__) => { -eval("const _ = __webpack_require__(/*! lodash */ \"./node_modules/lodash/lodash.js\");\n\nconst getForm = () => $('form');\nconst getValidationErrors = () => getForm()\n .find('.invalid-required:not(.disabled), .invalid-constraint:not(.disabled), .invalid-relevant:not(.disabled)')\n .children('span.active:not(.question-label)')\n .filter(function() {\n return $(this).css('display') === 'block';\n });\n\nconst getSiblingElement = ( element, selector = '*' ) =>{\n let found;\n let current = element.parentElement.firstElementChild;\n\n while ( current && !found ) {\n if ( current !== element && current.matches( selector ) ) {\n found = current;\n }\n current = current.nextElementSibling;\n }\n\n return found;\n};\n\n// Copied from https://github.com/enketo/enketo-core/blob/master/src/js/page.js\nconst getPages = () => {\n const form = getForm()[0];\n if(!form.classList.contains('pages')) {\n // This is not a multipage form\n return [form];\n }\n\n const allPages = [...getForm()[0].querySelectorAll( '[role=\"page\"]' )];\n return allPages.filter( el => {\n return !el.closest( '.disabled' ) &&\n ( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||\n // or-repeat-info is only considered a page by itself if it has no sibling repeats\n // When there are siblings repeats, we use CSS trickery to show the + button underneath the last\n // repeat.\n ( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );\n } );\n};\n\nconst getCurrentPage = () => {\n const pages = getPages();\n return pages.find( page => page.classList.contains( 'current' ) ) || pages[pages.length - 1];\n};\n\nclass FormFiller {\n constructor(options) {\n this.options = _.defaults(options, {\n verbose: true,\n });\n this.log = (...args) => this.options.verbose && console.log('FormFiller', ...args);\n }\n\n /**\n * An object describing the result of filling a form.\n * @typedef {Object} FillResult\n * @property {FillError[]} errors A list of errors\n * @property {string} section The page number on which the errors occurred\n * @property {Object} report The report object which resulted from submitting the filled report. Undefined if an error blocks form submission.\n * @property {Object[]} additionalDocs An array of database documents which are created in addition to the report.\n */\n\n /**\n * An object describing an error which has occurred while filling a form.\n * @typedef {Object} FillError\n * @property {string} type A classification of the error [ 'validation', 'general', 'page' ]\n * @property {string} msg Description of the error\n */\n\n async fillForm(multiPageAnswer) {\n const { isComplete, errors } = await fillForm(this, multiPageAnswer);\n return { isComplete, errors };\n }\n\n // Modified from enketo-core/src/js/Form.js validateContent\n async getVisibleValidationErrors() {\n const validationErrors = getValidationErrors();\n\n return Array.from(validationErrors)\n .map(span => ({\n type: 'validation',\n question: span.parentElement.innerText,\n msg: span.innerText,\n }));\n }\n}\n\nconst fillForm = async (self, multiPageAnswer) => {\n self.log(`Filling form in ${multiPageAnswer.length} pages.`);\n const results = [];\n for (const pageIndex in multiPageAnswer) {\n const pageAnswer = multiPageAnswer[pageIndex];\n const result = await fillPage(self, pageAnswer);\n results.push(result);\n\n if (result.errors.length > 0) {\n return {\n errors: result.errors,\n section: `page-${pageIndex}`,\n answers: pageAnswer,\n };\n }\n }\n\n let errors;\n let isComplete;\n let pageHasAdvanced;\n // attempt to submit all the way to the end (replacement for validateAll)\n do {\n pageHasAdvanced = await nextPage();\n errors = await self.getVisibleValidationErrors();\n\n const pages = getPages();\n isComplete = pages.indexOf(getCurrentPage()) === pages.length - 1;\n } while (pageHasAdvanced && !isComplete && !errors.length);\n const incompleteError = isComplete ? [] : [{ type: 'general', msg: 'Form is incomplete' }];\n\n return {\n isComplete,\n errors: [...incompleteError, ...errors],\n };\n};\n\nconst fillPage = async (self, pageAnswer) => {\n self.log(`Answering ${pageAnswer.length} questions.`);\n\n const answeredQuestions = new Set();\n for (let i = 0; i < pageAnswer.length; i++) {\n const answer = pageAnswer[i];\n const $questions = getVisibleQuestions();\n if ($questions.length <= i) {\n return {\n errors: [{\n type: 'page',\n answers: pageAnswer,\n section: `answer-${i}`,\n msg: `Attempted to fill ${pageAnswer.length} questions, but only ${$questions.length} are visible.`,\n }],\n };\n }\n\n const nextUnansweredQuestion = Array.from($questions).find(question => !answeredQuestions.has(question));\n answeredQuestions.add(nextUnansweredQuestion);\n fillQuestion(nextUnansweredQuestion, answer);\n }\n\n const allPagesSuccessful = await nextPage();\n const validationErrors = await self.getVisibleValidationErrors();\n const advanceFailure = allPagesSuccessful || validationErrors.length ? [] : [{\n type: 'general',\n msg: 'Failed to advance to next page',\n }];\n\n return {\n errors: [...advanceFailure, ...validationErrors],\n };\n};\n\nconst fillQuestion = (question, answer) => {\n if(answer === null || answer === undefined) {\n return;\n }\n\n const $question = $(question);\n const allInputs = $question.find('input:not([type=\"hidden\"]),textarea,button,select');\n const firstInput = Array.from(allInputs)[0];\n\n if (!firstInput) {\n throw 'No input field found within question';\n }\n\n if (firstInput.localName === 'textarea') {\n return allInputs.val(answer).trigger('change');\n }\n\n switch (firstInput.type) {\n case 'button':\n // select_one appearance:minimal\n if (firstInput.className.includes('dropdown-toggle')) {\n $question.find(`input[value=\"${answer}\"]:not([checked=\"checked\"])`).click();\n }\n\n // repeate section\n else {\n\n if (!Number.isInteger(answer)) {\n throw `Failed to answer question which is a \"+\" for repeat section. This question expects an answer which is an integer - representing how many times to click the +. \"${answer}\"`;\n }\n\n for (let i = 0; i < answer; ++i) {\n allInputs.click();\n }\n }\n break;\n case 'radio':\n $question.find(`input[value=\"${answer}\"]`).click();\n break;\n case 'date':\n case 'tel':\n case 'number':\n allInputs.val(answer).trigger('change');\n break;\n case 'text':\n if (allInputs.eq(0).parents('.datetimepicker').length) {\n const [date, time] = answer.split(' ', 2);\n if (!time) {\n throw new Error('Elements of type datetime expect input in format: \"2022-12-31 13:21\"');\n }\n\n allInputs.eq(0).datepicker('setDate', date);\n allInputs.eq(1).val(time).trigger('change');\n } else if (allInputs.eq(0).parents('.timepicker').length) {\n allInputs.eq(0).timepicker('setTime', answer);\n } else if (allInputs.parent().hasClass('date')) {\n allInputs.first().datepicker('setDate', answer);\n } else {\n allInputs.val(answer).trigger('change');\n }\n break;\n case 'checkbox': {\n /*\n There are two accepted formats for multi-select checkboxes\n Option 1 - A set of comma-delimited boolean strings representing the state of the boxes. eg. \"true,false,true\" checks the first and third box\n Option 2 - A set of comma-delimited values to be checked. eg. \"heart_condition,none\" checks the two boxes with corresponding values\n */\n const answerArray = Array.isArray(answer) ? answer.map(answer => answer.toString()) : answer.split(',');\n const isNonBooleanString = str => !str || !['true', 'false'].includes(str.toLowerCase());\n const answerContainsSpecificValues = answerArray.some(isNonBooleanString);\n\n // [value != \"\"] is necessary because blank lines in `choices` table of xlsx can cause empty unrendered input\n const options = $question.find('input[value!=\"\"]');\n\n if (!answerContainsSpecificValues) {\n answerArray.forEach((val, index) => {\n const propValue = val === true || val.toLowerCase() === 'true' ? 'checked' : '';\n $(options[index]).prop('checked', propValue).trigger('change');\n });\n } else {\n options.prop('checked', '');\n answerArray.forEach(val => $question.find(`input[value=\"${val}\"]`).prop('checked', 'checked').trigger('change'));\n }\n break;\n }\n case 'select-one':\n allInputs.val(answer).trigger('change');\n break;\n default:\n throw `Unhandled input type ${firstInput.type}`;\n }\n};\n\nconst getVisibleQuestions = () => {\n const currentPage = $(getCurrentPage());\n \n if (!currentPage) {\n throw Error('Form has no active pages');\n }\n\n if (currentPage.hasClass('question')) {\n return currentPage;\n }\n\n const findQuestionsInSection = section => {\n const inquisitiveChildren = Array.from($(section)\n .children(`\n section:not(.disabled,.or-appearance-hidden,.or-appearance-android-app-launcher),\n fieldset:not(.disabled,.note,.or-appearance-hidden,.or-appearance-label,#or-calculated-items),\n label:not(.disabled,.readonly,.or-appearance-hidden),\n div.or-repeat-info:not(.disabled,.or-appearance-hidden):not([data-repeat-count]),\n i,\n b\n `));\n\n const result = [];\n for (const child of inquisitiveChildren) {\n const questions = ['section', 'i', 'b'].includes(child.localName) ? findQuestionsInSection(child) : [child];\n result.push(...questions);\n }\n\n return result;\n };\n\n return findQuestionsInSection(currentPage);\n};\n\nconst nextPage = async () => {\n const currentPageIndex = getPages().indexOf(getCurrentPage());\n const nextButton = $('button.next-page');\n const submitButton = $('button.submit');\n const toClick = submitButton.is(':hidden') ? nextButton : submitButton;\n\n if(toClick.is(':hidden')) {\n return !getValidationErrors().length;\n }\n\n return new Promise(resolve => {\n const observer = new MutationObserver(() => {\n if(getPages().indexOf(getCurrentPage()) > currentPageIndex) {\n observer.disconnect();\n return resolve(true);\n }\n\n const success = !getValidationErrors().length;\n observer.disconnect();\n return resolve(success);\n });\n\n observer.observe(getForm().get(0), {\n childList: true,\n subtree: true,\n attributeFilter: ['class', 'display'],\n });\n toClick.click();\n });\n};\n\nmodule.exports = FormFiller;\n\n\n//# sourceURL=webpack://cht-conf-test-harness/./src/form-host/form-filler.js?"); +eval("const _ = __webpack_require__(/*! lodash */ \"./node_modules/lodash/lodash.js\");\n\nconst getForm = () => $('form');\nconst getValidationErrors = () => getForm()\n .find('.invalid-required:not(.disabled), .invalid-constraint:not(.disabled), .invalid-relevant:not(.disabled)')\n .children('span.active:not(.question-label)')\n .filter(function() {\n return $(this).css('display') === 'block';\n });\n\nconst getSiblingElement = ( element, selector = '*' ) =>{\n let found;\n let current = element.parentElement.firstElementChild;\n\n while ( current && !found ) {\n if ( current !== element && current.matches( selector ) ) {\n found = current;\n }\n current = current.nextElementSibling;\n }\n\n return found;\n};\n\n// Copied from https://github.com/enketo/enketo-core/blob/master/src/js/page.js\nconst getPages = () => {\n const form = getForm()[0];\n if(!form.classList.contains('pages')) {\n // This is not a multipage form\n return [form];\n }\n\n const allPages = [...getForm()[0].querySelectorAll( '[role=\"page\"]' )];\n return allPages.filter( el => {\n return !el.closest( '.disabled' ) &&\n ( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||\n // or-repeat-info is only considered a page by itself if it has no sibling repeats\n // When there are siblings repeats, we use CSS trickery to show the + button underneath the last\n // repeat.\n ( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );\n } );\n};\n\nconst getCurrentPage = () => {\n const pages = getPages();\n return pages.find( page => page.classList.contains( 'current' ) ) || pages[pages.length - 1];\n};\n\nclass FormFiller {\n constructor(options) {\n this.options = _.defaults(options, {\n verbose: true,\n });\n this.log = (...args) => this.options.verbose && console.log('FormFiller', ...args);\n }\n\n /**\n * An object describing the result of filling a form.\n * @typedef {Object} FillResult\n * @property {FillError[]} errors A list of errors\n * @property {string} section The page number on which the errors occurred\n * @property {Object} report The report object which resulted from submitting the filled report. Undefined if an error blocks form submission.\n * @property {Object[]} additionalDocs An array of database documents which are created in addition to the report.\n */\n\n /**\n * An object describing an error which has occurred while filling a form.\n * @typedef {Object} FillError\n * @property {string} type A classification of the error [ 'validation', 'general', 'page' ]\n * @property {string} msg Description of the error\n */\n\n async fillForm(multiPageAnswer) {\n const { isComplete, errors } = await fillForm(this, multiPageAnswer);\n return { isComplete, errors };\n }\n\n // Modified from enketo-core/src/js/Form.js validateContent\n async getVisibleValidationErrors() {\n const validationErrors = getValidationErrors();\n\n return Array.from(validationErrors)\n .map(span => ({\n type: 'validation',\n question: span.parentElement.innerText,\n msg: span.innerText,\n }));\n }\n}\n\nconst fillForm = async (self, multiPageAnswer) => {\n self.log(`Filling form in ${multiPageAnswer.length} pages.`);\n const results = [];\n for (const pageIndex in multiPageAnswer) {\n const pageAnswer = multiPageAnswer[pageIndex];\n const result = await fillPage(self, pageAnswer);\n results.push(result);\n\n if (result.errors.length > 0) {\n return {\n errors: result.errors,\n section: `page-${pageIndex}`,\n answers: pageAnswer,\n };\n }\n }\n\n let errors;\n let isComplete;\n let pageHasAdvanced;\n // attempt to submit all the way to the end (replacement for validateAll)\n do {\n pageHasAdvanced = await nextPage();\n errors = await self.getVisibleValidationErrors();\n\n const pages = getPages();\n isComplete = pages.indexOf(getCurrentPage()) === pages.length - 1;\n } while (pageHasAdvanced && !isComplete && !errors.length);\n const incompleteError = isComplete ? [] : [{ type: 'general', msg: 'Form is incomplete' }];\n\n return {\n isComplete,\n errors: [...incompleteError, ...errors],\n };\n};\n\nconst fillPage = async (self, pageAnswer) => {\n self.log(`Answering ${pageAnswer.length} questions.`);\n\n const answeredQuestions = new Set();\n for (let i = 0; i < pageAnswer.length; i++) {\n const answer = pageAnswer[i];\n const $questions = getVisibleQuestions();\n if ($questions.length <= i) {\n return {\n errors: [{\n type: 'page',\n answers: pageAnswer,\n section: `answer-${i}`,\n msg: `Attempted to fill ${pageAnswer.length} questions, but only ${$questions.length} are visible.`,\n }],\n };\n }\n\n const nextUnansweredQuestion = Array.from($questions).find(question => !answeredQuestions.has(question));\n answeredQuestions.add(nextUnansweredQuestion);\n fillQuestion(nextUnansweredQuestion, answer);\n }\n\n const allPagesSuccessful = await nextPage();\n const validationErrors = await self.getVisibleValidationErrors();\n const advanceFailure = allPagesSuccessful || validationErrors.length ? [] : [{\n type: 'general',\n msg: 'Failed to advance to next page',\n }];\n\n return {\n errors: [...advanceFailure, ...validationErrors],\n };\n};\n\nconst fillQuestion = (question, answer) => {\n if(answer === null || answer === undefined) {\n return;\n }\n\n const $question = $(question);\n const allInputs = $question.find('input:not([type=\"hidden\"]),textarea,button,select');\n const firstInput = Array.from(allInputs)[0];\n\n if (!firstInput) {\n throw 'No input field found within question';\n }\n\n if (firstInput.localName === 'textarea') {\n return allInputs.val(answer).trigger('change');\n }\n\n switch (firstInput.type) {\n case 'button':\n // select_one appearance:minimal\n if (firstInput.className.includes('dropdown-toggle')) {\n $question.find(`input[value=\"${answer}\"]:not([checked=\"checked\"])`).click();\n }\n\n // repeate section\n else {\n\n if (!Number.isInteger(answer)) {\n throw `Failed to answer question which is a \"+\" for repeat section. This question expects an answer which is an integer - representing how many times to click the +. \"${answer}\"`;\n }\n\n for (let i = 0; i < answer; ++i) {\n allInputs.click();\n }\n }\n break;\n case 'radio':\n $question.find(`input[value=\"${answer}\"]`).click();\n break;\n case 'date':\n case 'tel':\n case 'number':\n allInputs.val(answer).trigger('change');\n break;\n case 'text':\n if (allInputs.eq(0).parents('.datetimepicker').length) {\n const [date, time] = answer.split(' ', 2);\n if (!time) {\n throw new Error('Elements of type datetime expect input in format: \"2022-12-31 13:21\"');\n }\n\n allInputs.eq(0).datepicker('setDate', date);\n allInputs.eq(1).val(time).trigger('change');\n } else if (allInputs.eq(0).parents('.timepicker').length) {\n allInputs.eq(0).timepicker('setTime', answer);\n } else if (allInputs.parent().hasClass('date')) {\n allInputs.first().datepicker('setDate', answer);\n } else {\n allInputs.val(answer).trigger('change');\n }\n break;\n case 'checkbox': {\n /*\n There are two accepted formats for multi-select checkboxes\n Option 1 - A set of comma-delimited boolean strings representing the state of the boxes. eg. \"true,false,true\" checks the first and third box\n Option 2 - A set of comma-delimited values to be checked. eg. \"heart_condition,none\" checks the two boxes with corresponding values\n */\n const answerArray = Array.isArray(answer) ? answer.map(answer => answer.toString()) : answer.split(',');\n const isNonBooleanString = str => !str || !['true', 'false'].includes(str.toLowerCase());\n const answerContainsSpecificValues = answerArray.some(isNonBooleanString);\n\n // [value != \"\"] is necessary because blank lines in `choices` table of xlsx can cause empty unrendered input\n const options = $question.find('input[value!=\"\"]');\n\n if (!answerContainsSpecificValues) {\n answerArray.forEach((val, index) => {\n const propValue = val === true || val.toLowerCase() === 'true' ? 'checked' : '';\n $(options[index]).prop('checked', propValue).trigger('change');\n });\n } else {\n options.prop('checked', '');\n answerArray.forEach(val => $question.find(`input[value=\"${val}\"]`).prop('checked', 'checked').trigger('change'));\n }\n break;\n }\n case 'select-one':\n allInputs.val(answer).trigger('change');\n break;\n default:\n throw `Unhandled input type ${firstInput.type}`;\n }\n};\n\nconst getVisibleQuestions = () => {\n const currentPage = $(getCurrentPage());\n \n if (!currentPage) {\n throw Error('Form has no active pages');\n }\n\n if (currentPage.hasClass('question')) {\n return currentPage;\n }\n\n const findQuestionsInSection = section => {\n const inquisitiveChildren = Array.from($(section)\n .children(`\n section:not(.disabled,.or-appearance-hidden,.or-appearance-android-app-launcher),\n fieldset:not(.disabled,.note,.or-appearance-hidden,.or-appearance-label,#or-calculated-items),\n label:not(.disabled,.readonly,.or-appearance-hidden),\n div.or-repeat-info:not(.disabled,.or-appearance-hidden):not([data-repeat-count]),\n i,\n b\n `));\n\n const result = [];\n for (const child of inquisitiveChildren) {\n const questions = ['section', 'i', 'b'].includes(child.localName) ? findQuestionsInSection(child) : [child];\n result.push(...questions);\n }\n\n return result;\n };\n\n return findQuestionsInSection(currentPage);\n};\n\nconst nextPage = async () => {\n const currentPageIndex = getPages().indexOf(getCurrentPage());\n const nextButton = $('button.next-page');\n if(nextButton.is(':hidden')) {\n const submitButton = $('button.submit');\n if (!submitButton.is(':hidden')) {\n // revalidate constraints before checking errors\n submitButton.click();\n }\n \n return !getValidationErrors().length;\n }\n\n return new Promise(resolve => {\n const observer = new MutationObserver(() => {\n if(getPages().indexOf(getCurrentPage()) > currentPageIndex) {\n observer.disconnect();\n return resolve(true);\n }\n\n if(getValidationErrors().length) {\n observer.disconnect();\n return resolve(false);\n }\n });\n\n observer.observe(getForm().get(0), {\n childList: true,\n subtree: true,\n attributeFilter: ['class', 'display'],\n });\n nextButton.click();\n });\n};\n\nmodule.exports = FormFiller;\n\n\n//# sourceURL=webpack://cht-conf-test-harness/./src/form-host/form-filler.js?"); /***/ }), diff --git a/src/form-host/form-filler.js b/src/form-host/form-filler.js index ad519ff..ec86c10 100644 --- a/src/form-host/form-filler.js +++ b/src/form-host/form-filler.js @@ -289,10 +289,13 @@ const getVisibleQuestions = () => { const nextPage = async () => { const currentPageIndex = getPages().indexOf(getCurrentPage()); const nextButton = $('button.next-page'); - const submitButton = $('button.submit'); - const toClick = submitButton.is(':hidden') ? nextButton : submitButton; - - if(toClick.is(':hidden')) { + if(nextButton.is(':hidden')) { + const submitButton = $('button.submit'); + if (!submitButton.is(':hidden')) { + // revalidate constraints before checking errors + submitButton.click(); + } + return !getValidationErrors().length; } @@ -303,9 +306,10 @@ const nextPage = async () => { return resolve(true); } - const success = !getValidationErrors().length; - observer.disconnect(); - return resolve(success); + if(getValidationErrors().length) { + observer.disconnect(); + return resolve(false); + } }); observer.observe(getForm().get(0), { @@ -313,7 +317,7 @@ const nextPage = async () => { subtree: true, attributeFilter: ['class', 'display'], }); - toClick.click(); + nextButton.click(); }); }; From 9930afeaed37b4a87e6872e85f8114dba4012ce8 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 21:16:41 -0700 Subject: [PATCH 6/7] This test has healthy but different errors after the change --- test/app-forms.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/app-forms.spec.js b/test/app-forms.spec.js index 8ba1e3e..ac541f0 100644 --- a/test/app-forms.spec.js +++ b/test/app-forms.spec.js @@ -326,7 +326,7 @@ describe('forms that have caused bugs', () => { it('#234 - datetime field fails enketo validation', async () => { const result = await harness.fillForm('bug_234', ['2023-01-01 x']); expect(result.errors).to.not.be.empty; - expect(result.errors[1].msg).to.eq('enketo.constraint.required'); + expect(result.errors[0].msg).to.eq('enketo.constraint.required'); }); it('#249 - time field with correct input format', async () => { From 2e6c46202f8385f59a66624281502abc17467989 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 21:17:09 -0700 Subject: [PATCH 7/7] 5.0.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d0301f..28ff81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cht-conf-test-harness", - "version": "5.0.1", + "version": "5.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cht-conf-test-harness", - "version": "5.0.1", + "version": "5.0.2", "dependencies": { "lodash": "^4.17.15", "luxon": "^1.27.0", diff --git a/package.json b/package.json index 2ba7677..e6e1170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cht-conf-test-harness", - "version": "5.0.1", + "version": "5.0.2", "description": "Test Framework for CHT Projects", "repository": { "type": "git",