From 4e1fb1983263508df10255b6bd22b0c360a6b223 Mon Sep 17 00:00:00 2001 From: Jakub P Date: Mon, 10 Mar 2025 22:40:28 +0100 Subject: [PATCH] test: add import and export flow tests --- .../e2e-tests/fixtures/flow-editor-page.js | 13 +- packages/e2e-tests/fixtures/flows-page.js | 10 + .../e2e-tests/fixtures/import-flow-dialog.js | 20 + packages/e2e-tests/fixtures/index.js | 4 + packages/e2e-tests/helpers/flow-api-helper.js | 25 ++ .../import-export-flow.spec.js | 399 ++++++++++++++++++ .../web/src/components/FlowStep/index.jsx | 1 + .../src/components/ImportFlowDialog/index.jsx | 6 +- 8 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 packages/e2e-tests/fixtures/flows-page.js create mode 100644 packages/e2e-tests/fixtures/import-flow-dialog.js create mode 100644 packages/e2e-tests/tests/import-export-flow/import-export-flow.spec.js diff --git a/packages/e2e-tests/fixtures/flow-editor-page.js b/packages/e2e-tests/fixtures/flow-editor-page.js index af321b59..dd2f32ae 100644 --- a/packages/e2e-tests/fixtures/flow-editor-page.js +++ b/packages/e2e-tests/fixtures/flow-editor-page.js @@ -12,12 +12,16 @@ export class FlowEditorPage extends AuthenticatedPage { super(page); this.appAutocomplete = this.page.getByTestId('choose-app-autocomplete'); + this.appAutocompleteInput = this.appAutocomplete.locator('input'); this.eventAutocomplete = this.page.getByTestId('choose-event-autocomplete'); + this.eventAutocompleteInput = this.eventAutocomplete.locator('input'); this.continueButton = this.page.getByTestId('flow-substep-continue-button'); this.testAndContinueButton = this.page.getByText('Test & Continue'); this.connectionAutocomplete = this.page.getByTestId( 'choose-connection-autocomplete' ); + this.connectionAutocompleteInput = + this.connectionAutocomplete.locator('input'); this.addNewConnectionItem = this.page.getByText('Add new connection'); this.testOutput = this.page.getByTestId('flow-test-substep-output'); this.hasNoOutput = this.page.getByTestId('flow-test-substep-no-output'); @@ -32,6 +36,9 @@ export class FlowEditorPage extends AuthenticatedPage { .locator('input'); this.flowStep = this.page.getByTestId('flow-step'); + this.goBackButton = this.page.getByTestId('editor-go-back-button'); + this.exportFlowButton = page.getByTestId('export-flow-button'); + this.stepName = page.getByTestId('step-name'); } async createWebhookTrigger(workSynchronously) { @@ -76,7 +83,11 @@ export class FlowEditorPage extends AuthenticatedPage { await expect(this.eventAutocomplete).toBeVisible(); await this.eventAutocomplete.click(); await Promise.all([ - this.page.waitForResponse(resp => /(apps\/.*\/actions\/.*\/substeps)/.test(resp.url()) && resp.status() === 200), + this.page.waitForResponse( + (resp) => + /(apps\/.*\/actions\/.*\/substeps)/.test(resp.url()) && + resp.status() === 200 + ), this.page.getByRole('option', { name: eventName }).click(), ]); await this.continueButton.click(); diff --git a/packages/e2e-tests/fixtures/flows-page.js b/packages/e2e-tests/fixtures/flows-page.js new file mode 100644 index 00000000..14c8cb97 --- /dev/null +++ b/packages/e2e-tests/fixtures/flows-page.js @@ -0,0 +1,10 @@ +const { AuthenticatedPage } = require('./authenticated-page'); + +export class FlowsPage extends AuthenticatedPage { + constructor(page) { + super(page); + + this.flowRow = this.page.getByTestId('flow-row'); + this.importFlowButton = page.getByTestId('import-flow-button'); + } +} diff --git a/packages/e2e-tests/fixtures/import-flow-dialog.js b/packages/e2e-tests/fixtures/import-flow-dialog.js new file mode 100644 index 00000000..33ea6b6b --- /dev/null +++ b/packages/e2e-tests/fixtures/import-flow-dialog.js @@ -0,0 +1,20 @@ +const { AuthenticatedPage } = require('./authenticated-page'); + +export class ImportFlowDialog extends AuthenticatedPage { + constructor(page) { + super(page); + + this.fileName = page.getByTestId('file-name'); + this.fileNameWrapper = page.getByTestId('file-name-wrapper'); + this.importButton = page.getByTestId('import-flow-dialog-import-button'); + this.fileInput = page.locator("input[type='file']"); + this.genericImportError = page.getByTestId( + 'import-flow-dialog-generic-error-alert' + ); + this.importParsingError = page.getByTestId( + 'import-flow-dialog-parsing-error-alert' + ); + this.successAlert = page.getByTestId('import-flow-dialog-success-alert'); + this.successAlertLink = this.successAlert.getByRole('link'); + } +} diff --git a/packages/e2e-tests/fixtures/index.js b/packages/e2e-tests/fixtures/index.js index 6a7b3557..6f651b64 100644 --- a/packages/e2e-tests/fixtures/index.js +++ b/packages/e2e-tests/fixtures/index.js @@ -10,6 +10,7 @@ const { AcceptInvitation } = require('./accept-invitation-page'); const { adminFixtures } = require('./admin'); const { AdminSetupPage } = require('./admin-setup-page'); const { AdminCreateUserPage } = require('./admin/create-user-page'); +const { FlowsPage } = require('./flows-page'); exports.test = test.extend({ page: async ({ page }, use) => { @@ -36,6 +37,9 @@ exports.test = test.extend({ flowEditorPage: async ({ page }, use) => { await use(new FlowEditorPage(page)); }, + flowsPage: async ({ page }, use) => { + await use(new FlowsPage(page)); + }, userInterfacePage: async ({ page }, use) => { await use(new UserInterfacePage(page)); }, diff --git a/packages/e2e-tests/helpers/flow-api-helper.js b/packages/e2e-tests/helpers/flow-api-helper.js index 27f9194d..4933bfd2 100644 --- a/packages/e2e-tests/helpers/flow-api-helper.js +++ b/packages/e2e-tests/helpers/flow-api-helper.js @@ -112,3 +112,28 @@ export const addWebhookFlow = async (request, token) => { return flowId; }; + +export const createConnection = async ( + request, + token, + appName, + requestBody +) => { + const response = await request.post( + `${process.env.BACKEND_APP_URL}/api/v1/apps/${appName}/connections`, + { headers: { Authorization: token }, data: requestBody } + ); + await expect(response.status()).toBe(201); + + return await response.json(); +}; + +export const verifyConnection = async (request, token, connectionId) => { + const response = await request.post( + `${process.env.BACKEND_APP_URL}/api/v1/connections/${connectionId}/verify`, + { headers: { Authorization: token } } + ); + await expect(response.status()).toBe(200); + + return await response.json(); +}; diff --git a/packages/e2e-tests/tests/import-export-flow/import-export-flow.spec.js b/packages/e2e-tests/tests/import-export-flow/import-export-flow.spec.js new file mode 100644 index 00000000..ff0a8053 --- /dev/null +++ b/packages/e2e-tests/tests/import-export-flow/import-export-flow.spec.js @@ -0,0 +1,399 @@ +const { test, expect } = require('../../fixtures/index'); +const axios = require('axios'); +const { + createFlow, + updateFlowName, + getFlow, + updateFlowStep, + testStep, + createConnection, + verifyConnection, +} = require('../../helpers/flow-api-helper'); +const { getToken } = require('../../helpers/auth-api-helper'); +const { ImportFlowDialog } = require('../../fixtures/import-flow-dialog'); + +test.describe('Import/Export flow', () => { + test('export flow from the details with variables, step names', async ({ + page, + request, + flowEditorPage, + flowsPage, + }) => { + let flowId; + const importFlowDialog = new ImportFlowDialog(page); + + await test.step('create flow', async () => { + const tokenJsonResponse = await getToken(request); + const token = tokenJsonResponse.data.token; + let flow = await createFlow(request, token); + flowId = flow.data.id; + await updateFlowName(request, token, flowId); + flow = await getFlow(request, token, flowId); + const flowSteps = flow.data.steps; + + const triggerStepId = flowSteps.find( + (step) => step.type === 'trigger' + ).id; + const actionStepId = flowSteps.find((step) => step.type === 'action').id; + + const triggerStep = await updateFlowStep(request, token, triggerStepId, { + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'triggerStep', + parameters: { + workSynchronously: true, + }, + }); + await request.get(triggerStep.data.webhookUrl); + await testStep(request, token, triggerStepId); + + await updateFlowStep(request, token, actionStepId, { + appKey: 'webhook', + key: 'respondWith', + name: 'actionStep', + parameters: { + statusCode: '200', + body: `{{step.${triggerStepId}.headers.host}}`, + headers: [ + { + key: '', + value: '', + }, + ], + }, + }); + await testStep(request, token, actionStepId); + }); + + await test.step('open added flow', async () => { + await page.goto(`/editor/${flowId}`); + }); + + await test.step('export and import flow', async () => { + const downloadPromise = page.waitForEvent('download'); + await flowEditorPage.exportFlowButton.click(); + const download = await downloadPromise; + + await download.saveAs(download.suggestedFilename()); + + await flowEditorPage.goBackButton.click(); + + await flowsPage.importFlowButton.click(); + await importFlowDialog.fileInput.setInputFiles( + download.suggestedFilename() + ); + await expect(importFlowDialog.fileNameWrapper).toHaveText( + `Selected file:${flowId}.json` + ); + await importFlowDialog.importButton.click(); + + await expect(importFlowDialog.successAlert).toHaveText( + 'The flow has been successfully imported. You can view it here.' + ); + await importFlowDialog.successAlertLink.click(); + }); + + await test.step('verify imported flow', async () => { + await expect(flowEditorPage.stepName.first()).toHaveText( + '1. triggerStep' + ); + await flowEditorPage.continueButton.click(); + await expect( + page + .getByTestId('parameters.workSynchronously-autocomplete') + .first() + .getByRole('combobox') + ).toHaveValue('Yes'); + + await flowEditorPage.continueButton.last().click(); + + const webhookUrl = await page.locator('input[name="webhookUrl"]'); + await axios.get(await webhookUrl.inputValue()); + + await flowEditorPage.testAndContinueButton.click(); + + await expect(flowEditorPage.stepName.nth(1)).toHaveText('2. actionStep'); + await flowEditorPage.continueButton.last().click(); + await expect(flowEditorPage.appAutocompleteInput).toHaveValue('Webhook'); + + await flowEditorPage.continueButton.last().click(); + await expect( + page + .getByTestId('parameters.body-power-input') + .locator('[contenteditable="true"]') + ).toContainText('step1.headers.host: localhost:3000'); + }); + }); + + test('export flow from the Flow List', async ({ + page, + request, + flowsPage, + }) => { + const tokenJsonResponse = await getToken(request); + const token = tokenJsonResponse.data.token; + let flow = await createFlow(request, token); + const flowId = flow.data.id; + await updateFlowName(request, token, flowId); + await page.goto('/flows'); + + await expect(page).toHaveURL('/flows'); + await expect( + flowsPage.flowRow.filter({ + hasText: flowId, + }) + ).toHaveCount(1); + await flowsPage.flowRow + .filter({ + hasText: flowId, + }) + .getByRole('button') + .click(); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('menuitem', { name: 'Export' }).click(); + const download = await downloadPromise; + await download.saveAs(download.suggestedFilename()); + }); + + test('import flow and check if flow is not in status to publish', async ({ + page, + flowEditorPage, + flowsPage, + }) => { + const importFlowDialog = new ImportFlowDialog(page); + + await flowsPage.importFlowButton.click(); + await importFlowDialog.fileInput.setInputFiles({ + name: 'flow.json', + mimeType: 'application/json', + buffer: Buffer.from(` + { + "id": "b85d6ceb-4220-4afa-965a-c43fa01e96e0", + "name": "Name your flow", + "steps": [ + { + "id": "3987bcb5-b33c-4f1c-964a-eff5a54d1fd4", + "key": "catchRawWebhook", + "name": "Catch raw webhook", + "appKey": "webhook", + "type": "trigger", + "parameters": { + "workSynchronously": false + }, + "position": 1, + "webhookPath": "/webhooks/flows/b85d6ceb-4220-4afa-965a-c43fa01e96e0" + }, + { + "id": "49ba3181-d7b0-462b-b62f-005d50689f87", + "key": "respondWith", + "name": "Respond with", + "appKey": "webhook", + "type": "action", + "parameters": { + "body": "{{step.3987bcb5-b33c-4f1c-964a-eff5a54d1fd4.headers.host}}", + "headers": [ + { + "key": "abc", + "__id": "21a0bfa9-7928-4e42-9e56-f4d68b8ff7aa", + "value": "{{step.3da3f847-efda-42b7-adbe-f0203a3491b8.headers.host}}" + } + ], + "statusCode": "200" + }, + "position": 2 + } + ] + }`), + }); + await expect(importFlowDialog.fileNameWrapper).toHaveText( + 'Selected file:flow.json' + ); + await importFlowDialog.importButton.click(); + + await expect(importFlowDialog.successAlert).toHaveText( + 'The flow has been successfully imported. You can view it here.' + ); + await importFlowDialog.successAlertLink.click(); + + await flowEditorPage.publishFlowButton.click(); + const snackbar = await page.getByTestId('snackbar-error'); + await expect(snackbar).toHaveText( + 'All steps should be completed before updating flow status!' + ); + }); + + test('connection should not be exported', async ({ + page, + request, + flowEditorPage, + flowsPage, + }) => { + let flowId; + const importFlowDialog = new ImportFlowDialog(page); + + await test.step('create flow', async () => { + const tokenJsonResponse = await getToken(request); + const token = tokenJsonResponse.data.token; + let flow = await createFlow(request, token); + flowId = flow.data.id; + await updateFlowName(request, token, flowId); + flow = await getFlow(request, token, flowId); + const flowSteps = flow.data.steps; + + const triggerStepId = flowSteps.find( + (step) => step.type === 'trigger' + ).id; + const actionStepId = flowSteps.find((step) => step.type === 'action').id; + + const triggerStep = await updateFlowStep(request, token, triggerStepId, { + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'triggerStep', + parameters: { + workSynchronously: true, + }, + }); + await request.get(triggerStep.data.webhookUrl); + await testStep(request, token, triggerStepId); + + const connection = await createConnection(request, token, 'postgresql', { + formattedData: { + version: '14.5', + host: process.env.POSTGRES_HOST, + port: '5432', + enableSsl: 'false', + database: process.env.POSTGRES_DATABASE, + user: process.env.POSTGRES_USERNAME, + password: process.env.POSTGRES_PASSWORD, + }, + }); + + await verifyConnection(request, token, connection.data.id); + await updateFlowStep(request, token, actionStepId, { + appKey: 'postgresql', + key: 'SQLQuery', + name: 'SQLQuery', + connectionId: connection.data.id, + parameters: { + queryStatement: 'select * from users;', + params: [ + { + parameter: '', + value: '', + }, + ], + }, + }); + await testStep(request, token, actionStepId); + }); + + await test.step('open added flow', async () => { + await page.goto(`/editor/${flowId}`); + }); + + await test.step('export and import flow', async () => { + const downloadPromise = page.waitForEvent('download'); + await flowEditorPage.exportFlowButton.click(); + const download = await downloadPromise; + await download.saveAs(download.suggestedFilename()); + + await expect(page.getByTestId('snackbar')).toHaveText( + 'The flow export has been successfully generated.' + ); + await page.getByTestId('snackbar').click(); + + await flowEditorPage.goBackButton.click(); + + await flowsPage.importFlowButton.click(); + await importFlowDialog.fileInput.setInputFiles( + download.suggestedFilename() + ); + + await expect(importFlowDialog.fileNameWrapper).toHaveText( + `Selected file:${flowId}.json` + ); + await importFlowDialog.importButton.click(); + await expect(importFlowDialog.successAlert).toHaveText( + 'The flow has been successfully imported. You can view it here.' + ); + await importFlowDialog.successAlertLink.click(); + }); + + await test.step('verify imported flow', async () => { + await flowEditorPage.flowStep.last().click(); + await expect(flowEditorPage.appAutocompleteInput).toHaveCount(1); + await expect(flowEditorPage.appAutocompleteInput).toHaveValue( + 'PostgreSQL' + ); + await expect(flowEditorPage.eventAutocompleteInput).toHaveValue( + 'SQL query' + ); + await flowEditorPage.continueButton.click(); + await expect(flowEditorPage.connectionAutocompleteInput).toHaveValue(''); + await page.getByText('Set up action').click(); + await expect( + page + .getByTestId('parameters.queryStatement-power-input') + .locator('[contenteditable]') + ).toHaveText('select * from users;'); + }); + }); + + // ellipsis if not verified properly + test.skip('handle long file names', async ({ flowsPage, page }) => { + const importFlowDialog = new ImportFlowDialog(page); + + await flowsPage.importFlowButton.click(); + await importFlowDialog.fileInput.setInputFiles({ + name: 'very_long_file_name_with_some_additional_remarks_that_should_not_be_visible.txt', + mimeType: 'text/plain', + buffer: Buffer.from('this is test'), + }); + + await expect(importFlowDialog.fileName).toHaveText( + 'very_long_file_name_with_some_additional_remark...' + ); + }); + + test('should fail on import different file than json', async ({ + flowsPage, + page, + }) => { + const importFlowDialog = new ImportFlowDialog(page); + + await flowsPage.importFlowButton.click(); + await importFlowDialog.fileInput.setInputFiles({ + name: 'abc.txt', + mimeType: 'text/plain', + buffer: Buffer.from('this is test'), + }); + + await expect(importFlowDialog.fileNameWrapper).toHaveText( + 'Selected file:abc.txt' + ); + await importFlowDialog.importButton.click(); + await expect(importFlowDialog.importParsingError).toHaveText( + 'Something has gone wrong with parsing the selected file.' + ); + }); + + test('import invalid nonflow json', async ({ flowsPage, page }) => { + const importFlowDialog = new ImportFlowDialog(page); + + await flowsPage.importFlowButton.click(); + await importFlowDialog.fileInput.setInputFiles({ + name: 'abcd.json', + mimeType: 'application/json', + buffer: Buffer.from('{"this": "is test"}'), + }); + + await expect(importFlowDialog.fileNameWrapper).toHaveText( + 'Selected file:abcd.json' + ); + await importFlowDialog.importButton.click(); + await expect(importFlowDialog.genericImportError).toHaveText( + 'Something went wrong. Please try again.' + ); + }); +}); diff --git a/packages/web/src/components/FlowStep/index.jsx b/packages/web/src/components/FlowStep/index.jsx index ed17f221..92adeb1c 100644 --- a/packages/web/src/components/FlowStep/index.jsx +++ b/packages/web/src/components/FlowStep/index.jsx @@ -265,6 +265,7 @@ function FlowStep(props) { {selectedFile && ( - + {formatMessage('importFlowDialog.selectedFileInformation')} - {selectedFile.name} + + {selectedFile.name} + )}