diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 90146ab374..9ec6f3ba19 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -2,7 +2,7 @@ * Getters */ -import { getVisibleSelect } from '../utils/popper'; +import { getVisiblePopper, getVisibleSelect } from '../utils/popper'; export function getCredentialSelect(eq = 0) { return cy.getByTestId('node-credentials-select').eq(eq); @@ -36,6 +36,18 @@ export function getOutputPanel() { return cy.getByTestId('output-panel'); } +export function getFixedCollection(collectionName: string) { + return cy.getByTestId(`fixed-collection-${collectionName}`); +} + +export function getResourceLocator(paramName: string) { + return cy.getByTestId(`resource-locator-${paramName}`); +} + +export function getResourceLocatorInput(paramName: string) { + return getResourceLocator(paramName).find('[data-test-id="rlc-input-container"]'); +} + export function getOutputPanelDataContainer() { return getOutputPanel().getByTestId('ndv-data-container'); } @@ -84,6 +96,30 @@ export function getOutputPanelRelatedExecutionLink() { return getOutputPanel().getByTestId('related-execution-link'); } +export function getNodeOutputHint() { + return cy.getByTestId('ndv-output-run-node-hint'); +} + +export function getWorkflowCards() { + return cy.getByTestId('resources-list-item'); +} + +export function getWorkflowCard(workflowName: string) { + return getWorkflowCards().contains(workflowName).parents('[data-test-id="resources-list-item"]'); +} + +export function getWorkflowCardContent(workflowName: string) { + return getWorkflowCard(workflowName).findChildByTestId('card-content'); +} + +export function getNodeRunInfoStale() { + return cy.getByTestId('node-run-info-stale'); +} + +export function getNodeOutputErrorMessage() { + return getOutputPanel().findChildByTestId('node-error-message'); +} + /** * Actions */ @@ -110,12 +146,20 @@ export function clickExecuteNode() { getExecuteNodeButton().click(); } +export function clickResourceLocatorInput(paramName: string) { + getResourceLocatorInput(paramName).click(); +} + export function setParameterInputByName(name: string, value: string) { getParameterInputByName(name).clear().type(value); } -export function toggleParameterCheckboxInputByName(name: string) { - getParameterInputByName(name).find('input[type="checkbox"]').realClick(); +export function checkParameterCheckboxInputByName(name: string) { + getParameterInputByName(name).find('input[type="checkbox"]').check({ force: true }); +} + +export function uncheckParameterCheckboxInputByName(name: string) { + getParameterInputByName(name).find('input[type="checkbox"]').uncheck({ force: true }); } export function setParameterSelectByContent(name: string, content: string) { @@ -127,3 +171,86 @@ export function changeOutputRunSelector(runName: string) { getOutputRunSelector().click(); getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click(); } + +export function addItemToFixedCollection(collectionName: string) { + getFixedCollection(collectionName).getByTestId('fixed-collection-add').click(); +} + +export function typeIntoFixedCollectionItem(collectionName: string, index: number, value: string) { + getFixedCollection(collectionName).within(() => + cy.getByTestId('parameter-input').eq(index).type(value), + ); +} + +export function selectResourceLocatorItem( + resourceLocator: string, + index: number, + expectedText: string, +) { + clickResourceLocatorInput(resourceLocator); + + getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist'); + getVisiblePopper() + .findChildByTestId('rlc-item') + .eq(index) + .find('span') + .should('contain.text', expectedText) + .click(); +} + +export function clickWorkflowCardContent(workflowName: string) { + getWorkflowCardContent(workflowName).click(); +} + +export function assertNodeOutputHintExists() { + getNodeOutputHint().should('exist'); +} + +export function assertNodeOutputErrorMessageExists() { + return getNodeOutputErrorMessage().should('exist'); +} + +// Note that this only validates the expectedContent is *included* in the output table +export function assertOutputTableContent(expectedContent: unknown[][]) { + for (const [i, row] of expectedContent.entries()) { + for (const [j, value] of row.entries()) { + // + 1 to skip header + getOutputTbodyCell(1 + i, j).should('have.text', value); + } + } +} + +export function populateMapperFields(fields: ReadonlyArray<[string, string]>) { + for (const [name, value] of fields) { + getParameterInputByName(name).type(value); + + // Click on a parent to dismiss the pop up which hides the field below. + getParameterInputByName(name).parent().parent().parent().click('topLeft'); + } +} + +/** + * Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing + * + * @param items - 2D array of items to populate, i.e. [["myField1", "String"], ["myField2", "Number"]] + * @param collectionName - name of the fixedCollection to populate + * @param offset - amount of 'parameter-input's before start, e.g. from a controlling dropdown that makes the fields appear + * @returns + */ +export function populateFixedCollection( + items: readonly T[], + collectionName: string, + offset: number = 0, +) { + if (items.length === 0) return; + const n = items[0].length; + for (const [i, params] of items.entries()) { + addItemToFixedCollection(collectionName); + for (const [j, param] of params.entries()) { + getFixedCollection(collectionName) + .getByTestId('parameter-input') + .eq(offset + i * n + j) + .type(`${param}{downArrow}{enter}`); + } + } +} diff --git a/cypress/composables/workflowsPage.ts b/cypress/composables/workflowsPage.ts new file mode 100644 index 0000000000..c7bcf39888 --- /dev/null +++ b/cypress/composables/workflowsPage.ts @@ -0,0 +1,15 @@ +/** + * Getters + */ + +export function getWorkflowsPageUrl() { + return '/home/workflows'; +} + +/** + * Actions + */ + +export function visitWorkflowsPage() { + cy.visit(getWorkflowsPageUrl()); +} diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index 96a03be961..b6f1b56eed 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -28,7 +28,7 @@ import { clickGetBackToCanvas, getRunDataInfoCallout, getOutputPanelTable, - toggleParameterCheckboxInputByName, + checkParameterCheckboxInputByName, } from '../composables/ndv'; import { addLanguageModelNodeToParent, @@ -97,7 +97,7 @@ describe('Langchain Integration', () => { it('should add nodes to all Agent node input types', () => { addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true); addNodeToCanvas(AGENT_NODE_NAME, true, true); - toggleParameterCheckboxInputByName('hasOutputParser'); + checkParameterCheckboxInputByName('hasOutputParser'); clickGetBackToCanvas(); addLanguageModelNodeToParent( diff --git a/cypress/e2e/45-workflow-selector-parameter.cy.ts b/cypress/e2e/45-workflow-selector-parameter.cy.ts index 38df9a29b8..38de780490 100644 --- a/cypress/e2e/45-workflow-selector-parameter.cy.ts +++ b/cypress/e2e/45-workflow-selector-parameter.cy.ts @@ -94,7 +94,7 @@ describe('Workflow Selector Parameter', () => { .findChildByTestId('rlc-item') .eq(0) .find('span') - .should('have.text', 'Create a new sub-workflow'); + .should('contain.text', 'Create a'); // Due to some inconsistency we're sometimes in a project and sometimes not, this covers both cases getVisiblePopper().findChildByTestId('rlc-item').eq(0).click(); diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts index 0e2755b9f0..aababf4cb6 100644 --- a/cypress/e2e/48-subworkflow-inputs.cy.ts +++ b/cypress/e2e/48-subworkflow-inputs.cy.ts @@ -1,60 +1,226 @@ -import { clickGetBackToCanvas, getOutputTableHeaders } from '../composables/ndv'; import { + addItemToFixedCollection, + assertNodeOutputHintExists, + clickExecuteNode, + clickGetBackToCanvas, + getExecuteNodeButton, + getOutputTableHeaders, + getParameterInputByName, + populateFixedCollection, + selectResourceLocatorItem, + typeIntoFixedCollectionItem, + clickWorkflowCardContent, + assertOutputTableContent, + populateMapperFields, + getNodeRunInfoStale, + assertNodeOutputErrorMessageExists, + checkParameterCheckboxInputByName, + uncheckParameterCheckboxInputByName, +} from '../composables/ndv'; +import { + clickExecuteWorkflowButton, clickZoomToFit, navigateToNewWorkflowPage, openNode, pasteWorkflow, saveWorkflowOnButtonClick, } from '../composables/workflow'; +import { visitWorkflowsPage } from '../composables/workflowsPage'; import SUB_WORKFLOW_INPUTS from '../fixtures/Test_Subworkflow-Inputs.json'; -import { NDV, WorkflowsPage, WorkflowPage } from '../pages'; import { errorToast, successToast } from '../pages/notifications'; import { getVisiblePopper } from '../utils'; -const ndv = new NDV(); -const workflowsPage = new WorkflowsPage(); -const workflow = new WorkflowPage(); - const DEFAULT_WORKFLOW_NAME = 'My workflow'; const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1'; const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2'; -type FieldRow = readonly string[]; - -const exampleFields = [ +const EXAMPLE_FIELDS = [ ['aNumber', 'Number'], ['aString', 'String'], ['aArray', 'Array'], ['aObject', 'Object'], ['aAny', 'Allow Any Type'], - // bool last since it's not an inputField so we'll skip it for some cases + // bool last because it's a switch instead of a normal inputField so we'll skip it for some cases ['aBool', 'Boolean'], ] as const; -/** - * Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing - * - * @param items - 2D array of items to populate, i.e. [["myField1", "String"], [""] - * @param collectionName - name of the fixedCollection to populate - * @param offset - amount of 'parameter-input's before the fixedCollection under test - * @returns - */ -function populateFixedCollection( - items: readonly FieldRow[], - collectionName: string, - offset: number, -) { - if (items.length === 0) return; - const n = items[0].length; - for (const [i, params] of items.entries()) { - ndv.actions.addItemToFixedCollection(collectionName); - for (const [j, param] of params.entries()) { - ndv.getters - .fixedCollectionParameter(collectionName) - .getByTestId('parameter-input') - .eq(offset + i * n + j) - .type(`${param}{downArrow}{enter}`); - } +type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object'; + +describe('Sub-workflow creation and typed usage', () => { + beforeEach(() => { + navigateToNewWorkflowPage(); + pasteWorkflow(SUB_WORKFLOW_INPUTS); + saveWorkflowOnButtonClick(); + clickZoomToFit(); + + openNode('Execute Workflow'); + + // Prevent sub-workflow from opening in new window + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + cy.visit(url); + }); + }); + selectResourceLocatorItem('workflowId', 0, 'Create a'); + // ************************** + // NAVIGATE TO CHILD WORKFLOW + // ************************** + + openNode('Workflow Input Trigger'); + }); + + it('works with type-checked values', () => { + populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_1, + 1, + EXAMPLE_FIELDS.map((f) => f[0]), + ); + + const values = [ + '-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it + ...EXAMPLE_FIELDS.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // the `}}` at the end are added automatically + ]; + + // this matches with the pinned data provided in the fixture + populateMapperFields(values.map((x, i) => [EXAMPLE_FIELDS[i][0], x])); + + clickExecuteNode(); + + const expected = [ + ['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'], + ['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'], + ]; + assertOutputTableContent(expected); + + // Test the type-checking options + populateMapperFields([['aString', '{selectAll}{backspace}{{}{{} 5']]); + + getNodeRunInfoStale().should('exist'); + clickExecuteNode(); + + assertNodeOutputErrorMessageExists(); + + // attemptToConvertTypes enabled + checkParameterCheckboxInputByName('attemptToConvertTypes'); + + getNodeRunInfoStale().should('exist'); + clickExecuteNode(); + + const expected2 = [ + ['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'], + ['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'], + ]; + + assertOutputTableContent(expected2); + + // disabled again + uncheckParameterCheckboxInputByName('attemptToConvertTypes'); + + getNodeRunInfoStale().should('exist'); + clickExecuteNode(); + + assertNodeOutputErrorMessageExists(); + }); + + it('works with Fields input source, and can then be changed to JSON input source', () => { + assertNodeOutputHintExists(); + + populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_1, + 1, + EXAMPLE_FIELDS.map((f) => f[0]), + ); + + cy.window().then((win) => { + cy.stub(win, 'open').callsFake((url) => { + cy.visit(url); + }); + }); + selectResourceLocatorItem('workflowId', 0, 'Create a'); + + openNode('Workflow Input Trigger'); + + getParameterInputByName('inputSource').click(); + + getVisiblePopper() + .getByTestId('parameter-input') + .eq(0) + .type('Using JSON Example{downArrow}{enter}'); + + const exampleJson = + '{{}' + EXAMPLE_FIELDS.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}'; + getParameterInputByName('jsonExample') + .find('.cm-line') + .eq(0) + .type(`{selectAll}{backspace}${exampleJson}{enter}`); + + // first one doesn't work for some reason, might need to wait for something? + clickExecuteNode(); + + validateAndReturnToParent( + DEFAULT_SUBWORKFLOW_NAME_2, + 2, + EXAMPLE_FIELDS.map((f) => f[0]), + ); + + assertOutputTableContent([ + ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'], + ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'], + ]); + + clickExecuteNode(); + }); + + it('should show node issue when no fields are defined in manual mode', () => { + getExecuteNodeButton().should('be.disabled'); + clickGetBackToCanvas(); + // Executing the workflow should show an error toast + clickExecuteWorkflowButton(); + errorToast().should('contain', 'The workflow has issues'); + openNode('Workflow Input Trigger'); + // Add a field to the workflowInputs fixedCollection + addItemToFixedCollection('workflowInputs'); + typeIntoFixedCollectionItem('workflowInputs', 0, 'test'); + // Executing the workflow should not show error now + clickGetBackToCanvas(); + clickExecuteWorkflowButton(); + successToast().should('contain', 'Workflow executed successfully'); + }); +}); + +// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields +// It then navigates back to the parent and validates the outputPanel matches our changes +function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) { + clickExecuteNode(); + + // + 1 to account for formatting-only column + getOutputTableHeaders().should('have.length', fields.length + 1); + for (const [i, name] of fields.entries()) { + getOutputTableHeaders().eq(i).should('have.text', name); + } + + clickGetBackToCanvas(); + saveWorkflowOnButtonClick(); + + visitWorkflowsPage(); + + clickWorkflowCardContent(DEFAULT_WORKFLOW_NAME); + + openNode('Execute Workflow'); + + // Note that outside of e2e tests this will be pre-selected correctly. + // Due to our workaround to remain in the same tab we need to select the correct tab manually + selectResourceLocatorItem('workflowId', offset, targetChild); + + clickExecuteNode(); + + getOutputTableHeaders().should('have.length', fields.length + 1); + for (const [i, name] of fields.entries()) { + getOutputTableHeaders().eq(i).should('have.text', name); } } @@ -74,215 +240,3 @@ function makeExample(type: TypeField) { return 'null'; } } - -type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object'; -function populateFields(items: ReadonlyArray) { - populateFixedCollection(items, 'workflowInputs', 1); -} - -function navigateWorkflowSelectionDropdown(index: number, expectedText: string) { - ndv.getters.resourceLocator('workflowId').should('be.visible'); - ndv.getters.resourceLocatorInput('workflowId').click(); - - getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist'); - getVisiblePopper() - .findChildByTestId('rlc-item') - .eq(index) - .find('span') - .should('have.text', expectedText) - .click(); -} - -function populateMapperFields(values: readonly string[], offset: number) { - for (const [i, value] of values.entries()) { - cy.getByTestId('parameter-input') - .eq(offset + i) - .type(value); - - // Click on a parent to dismiss the pop up hiding the field below. - cy.getByTestId('parameter-input') - .eq(offset + i) - .parent() - .parent() - .click('topLeft'); - } -} - -// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields -// It then navigates back to the parent and validates output -function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) { - ndv.actions.execute(); - - // + 1 to account for formatting-only column - getOutputTableHeaders().should('have.length', fields.length + 1); - for (const [i, name] of fields.entries()) { - getOutputTableHeaders().eq(i).should('have.text', name); - } - - clickGetBackToCanvas(); - saveWorkflowOnButtonClick(); - - cy.visit(workflowsPage.url); - - workflowsPage.getters.workflowCardContent(DEFAULT_WORKFLOW_NAME).click(); - - openNode('Execute Workflow'); - - // Note that outside of e2e tests this will be pre-selected correctly. - // Due to our workaround to remain in the same tab we need to select the correct tab manually - navigateWorkflowSelectionDropdown(offset, targetChild); - - // This fails, pointing to `usePushConnection` `const triggerNode = subWorkflow?.nodes.find` being `undefined.find()`I - ndv.actions.execute(); - - getOutputTableHeaders().should('have.length', fields.length + 1); - for (const [i, name] of fields.entries()) { - getOutputTableHeaders().eq(i).should('have.text', name); - } - - // todo: verify the fields appear and show the correct types - - // todo: fill in the input fields (and mock previous node data in the json fixture to match) - - // todo: validate the actual output data -} - -function setWorkflowInputFieldValue(index: number, value: string) { - ndv.actions.addItemToFixedCollection('workflowInputs'); - ndv.actions.typeIntoFixedCollectionItem('workflowInputs', index, value); -} - -describe('Sub-workflow creation and typed usage', () => { - beforeEach(() => { - navigateToNewWorkflowPage(); - pasteWorkflow(SUB_WORKFLOW_INPUTS); - saveWorkflowOnButtonClick(); - clickZoomToFit(); - - openNode('Execute Workflow'); - - // Prevent sub-workflow from opening in new window - cy.window().then((win) => { - cy.stub(win, 'open').callsFake((url) => { - cy.visit(url); - }); - }); - navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow'); - // ************************** - // NAVIGATE TO CHILD WORKFLOW - // ************************** - - openNode('Workflow Input Trigger'); - }); - - it('works with type-checked values', () => { - populateFields(exampleFields); - - validateAndReturnToParent( - DEFAULT_SUBWORKFLOW_NAME_1, - 1, - exampleFields.map((f) => f[0]), - ); - - const values = [ - '-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it - ...exampleFields.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // }} are added automatically - ]; - - // this matches with the pinned data provided in the fixture - populateMapperFields(values, 2); - - ndv.actions.execute(); - - // todo: - // - validate output lines up - // - change input to need casts - // - run - // - confirm error - // - switch `attemptToConvertTypes` flag - // - confirm success and changed output - // - change input to be invalid despite cast - // - run - // - confirm error - // - switch type option flags - // - run - // - confirm success - // - turn off attempt to cast flag - // - confirm a value was not cast - }); - - it('works with Fields input source into JSON input source', () => { - ndv.getters.nodeOutputHint().should('exist'); - - populateFields(exampleFields); - - validateAndReturnToParent( - DEFAULT_SUBWORKFLOW_NAME_1, - 1, - exampleFields.map((f) => f[0]), - ); - - cy.window().then((win) => { - cy.stub(win, 'open').callsFake((url) => { - cy.visit(url); - }); - }); - navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow'); - - openNode('Workflow Input Trigger'); - - cy.getByTestId('parameter-input').eq(0).click(); - - // Todo: Check if there's a better way to interact with option dropdowns - // This PR would add this child testId - getVisiblePopper() - .getByTestId('parameter-input') - .eq(0) - .type('Using JSON Example{downArrow}{enter}'); - - const exampleJson = - '{{}' + exampleFields.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}'; - cy.getByTestId('parameter-input-jsonExample') - .find('.cm-line') - .eq(0) - .type(`{selectAll}{backspace}${exampleJson}{enter}`); - - // first one doesn't work for some reason, might need to wait for something? - ndv.actions.execute(); - - validateAndReturnToParent( - DEFAULT_SUBWORKFLOW_NAME_2, - 2, - exampleFields.map((f) => f[0]), - ); - - // test for either InputSource mode and options combinations: - // + we're showing the notice in the output panel - // + we start with no fields - // + Test Step works and we create the fields - // + create field of each type (string, number, boolean, object, array, any) - // + exit ndv - // + save - // + go back to parent workflow - // - verify fields appear [needs Ivan's PR] - // - link fields [needs Ivan's PR] - // + run parent - // - verify output with `null` defaults exists - // - }); - - it('should show node issue when no fields are defined in manual mode', () => { - ndv.getters.nodeExecuteButton().should('be.disabled'); - ndv.actions.close(); - // Executing the workflow should show an error toast - workflow.actions.executeWorkflow(); - errorToast().should('contain', 'The workflow has issues'); - openNode('Workflow Input Trigger'); - // Add a field to the workflowInputs fixedCollection - setWorkflowInputFieldValue(0, 'test'); - // Executing the workflow should not show error now - ndv.actions.close(); - workflow.actions.executeWorkflow(); - successToast().should('contain', 'Workflow executed successfully'); - }); -});