diff --git a/airbyte-webapp-e2e-tests/README.md b/airbyte-webapp-e2e-tests/README.md index 858176610a0b..c0014ec5982a 100644 --- a/airbyte-webapp-e2e-tests/README.md +++ b/airbyte-webapp-e2e-tests/README.md @@ -8,12 +8,13 @@ Except as noted, all commands are written as if run from inside the `airbyte-web Steps: 1) If you have not already done so, run `npm install` to install the e2e test dependencies. 2) Build the OSS backend for the current commit with `SUB_BUILD=PLATFORM ../gradlew clean build`. -3) Create the test database: `npm run createdbsource`. -4) Start the OSS backend: `BASIC_AUTH_USERNAME="" BASIC_AUTH_PASSWORD="" VERSION=dev docker-compose --file ../docker-compose.yaml up`. If you want, follow this with `docker-compose stop webapp` to turn off the dockerized frontend build; interactive cypress sessions don't use it. -5) The following two commands will start a separate long-running server, so open another terminal window. In it, `cd` into the `airbyte-webapp/` directory. -6) If you have not already done so, run `npm install` to install the frontend app's dependencies. -7) Start the frontend development server with `npm start`. -8) Back in the `airbyte-webapp-e2e-tests/` directory, start the cypress test runner with `npm run cypress:open`. +3) Create the test database: `npm run createdbsource` and `npm run createdbdestination`. +4) When running the connector builder tests, start the dummy API server: `npm run createdummyapi` +5) Start the OSS backend: `BASIC_AUTH_USERNAME="" BASIC_AUTH_PASSWORD="" VERSION=dev docker-compose --file ../docker-compose.yaml up`. If you want, follow this with `docker-compose stop webapp` to turn off the dockerized frontend build; interactive cypress sessions don't use it. +6) The following two commands will start a separate long-running server, so open another terminal window. In it, `cd` into the `airbyte-webapp/` directory. +7) If you have not already done so, run `npm install` to install the frontend app's dependencies. +8) Start the frontend development server with `npm start`. +9) Back in the `airbyte-webapp-e2e-tests/` directory, start the cypress test runner with `npm run cypress:open`. ## Reproducing CI test results with `npm run cypress:ci` or `npm run cypress:ci:record` Unlike `npm run cypress:open`, `npm run cypress:ci` and `npm run cypress:ci:record` use the dockerized UI (i.e. they expect the UI at port 8000, rather than port 3000). If the OSS backend is running but you have run `docker-compose stop webapp`, you'll have to re-enable it with `docker-compose start webapp`. These trigger headless runs: you won't have a live browser to interact with, just terminal output. @@ -23,6 +24,15 @@ Except as noted, all commands are written as if run from inside the `airbyte-web Steps: 1) If you have not already done so, run `npm install` to install the e2e test dependencies. 2) Build the OSS backend for the current commit with `SUB_BUILD=PLATFORM ../gradlew clean build`. -3) Create the test database: `npm run createdbsource`. -4) Start the OSS backend: `BASIC_AUTH_USERNAME="" BASIC_AUTH_PASSWORD="" VERSION=dev docker-compose --file ../docker-compose.yaml up`. -5) Start the cypress test run with `npm run cypress:ci` or `npm run cypress:ci:record`. +3) Create the test database: `npm run createdbsource` and `npm run createdbdestination`. +4) When running the connector builder tests, start the dummy API server: `npm run createdummyapi` +5) Start the OSS backend: `BASIC_AUTH_USERNAME="" BASIC_AUTH_PASSWORD="" VERSION=dev docker-compose --file ../docker-compose.yaml up`. +6) Start the cypress test run with `npm run cypress:ci` or `npm run cypress:ci:record`. + +## Test setup + +When the tests are run as described above, the platform under test is started via docker compose on the local docker host. To test connections from real sources and destinations, additional docker containers are started for hosting these. For basic connections, additional postgres instances are started (`createdbsource` and `createdbdestination`). + +For testing the connector builder UI, a dummy api server based on a node script is started (`createdummyapi`). It is providing a simple http API with bearer authentication returning a few records of hardcoded data. By running it in the internal airbyte network, the connector builder server can access it under its container name. + +The tests in here are instrumenting a Chrome instance to test the full functionality of Airbyte from the frontend, so other components of the platform (scheduler, worker, connector builder server) are also tested in a rudimentary way. \ No newline at end of file diff --git a/airbyte-webapp-e2e-tests/cypress/commands/connectorBuilder.ts b/airbyte-webapp-e2e-tests/cypress/commands/connectorBuilder.ts new file mode 100644 index 000000000000..e6c2e8ee17b4 --- /dev/null +++ b/airbyte-webapp-e2e-tests/cypress/commands/connectorBuilder.ts @@ -0,0 +1,69 @@ +import { + addStream, + configureOffsetPagination, + enterName, + enterRecordSelector, + enterStreamName, + enterTestInputs, + enterUrlBase, + enterUrlPath, + goToTestPage, + goToView, + openTestInputs, + selectAuthMethod, + submitForm, + togglePagination +} from "pages/connectorBuilderPage"; + +export const configureGlobals = () => { + goToView("global"); + enterName("Dummy API"); + enterUrlBase("http://dummy_api:6767/"); +} + +export const configureStream = () => { + addStream(); + enterStreamName("Items"); + enterUrlPath("items/"); + submitForm(); + enterRecordSelector("items"); +} + +export const configureAuth = () => { + goToView("global"); + selectAuthMethod("Bearer"); + openTestInputs(); + enterTestInputs({ apiKey: "theauthkey" }) + submitForm(); +} + +export const configurePagination = () => { + goToView("0"); + togglePagination(); + configureOffsetPagination("2", "header", "offset"); +} + +const testPanelContains = (str: string) => { + cy.get("pre").contains(str).should("exist"); +} + +export const assertTestReadAuthFailure = () => { + testPanelContains('"error": "Bad credentials"'); +}; + +export const assertTestReadItems = () => { + testPanelContains('"name": "abc"'); + testPanelContains('"name": "def"'); +}; + +export const assertMultiPageReadItems = () => { + goToTestPage(1); + assertTestReadItems(); + + goToTestPage(2); + testPanelContains('"name": "xxx"'); + testPanelContains('"name": "yyy"'); + + goToTestPage(3); + testPanelContains('[]'); +}; \ No newline at end of file diff --git a/airbyte-webapp-e2e-tests/cypress/integration/connectorBuilder.spec.ts b/airbyte-webapp-e2e-tests/cypress/integration/connectorBuilder.spec.ts new file mode 100644 index 000000000000..059e317cfbf8 --- /dev/null +++ b/airbyte-webapp-e2e-tests/cypress/integration/connectorBuilder.spec.ts @@ -0,0 +1,30 @@ +import { goToConnectorBuilderPage, testStream } from "pages/connectorBuilderPage"; +import { assertTestReadItems, assertTestReadAuthFailure, configureAuth, configureGlobals, configureStream, configurePagination, assertMultiPageReadItems } from "commands/connectorBuilder"; + +describe("Connector builder", () => { + before(() => { + goToConnectorBuilderPage(); + }); + + it("Configure basic connector", () => { + configureGlobals(); + configureStream(); + }); + + it("Fail on missing auth", () => { + testStream(); + assertTestReadAuthFailure(); + }); + + it("Succeed on provided auth", () => { + configureAuth(); + testStream(); + assertTestReadItems(); + }); + + it("Pagination", () => { + configurePagination(); + testStream(); + assertMultiPageReadItems(); + }); +}); diff --git a/airbyte-webapp-e2e-tests/cypress/pages/connectorBuilderPage.ts b/airbyte-webapp-e2e-tests/cypress/pages/connectorBuilderPage.ts new file mode 100644 index 000000000000..b7ee8db12d53 --- /dev/null +++ b/airbyte-webapp-e2e-tests/cypress/pages/connectorBuilderPage.ts @@ -0,0 +1,91 @@ +const nameInput = "input[name='global.connectorName']"; +const urlBaseInput = "input[name='global.urlBase']"; +const addStreamButton = "button[data-testid='add-stream']"; +const apiKeyInput = "input[name='connectionConfiguration.api_key']"; +const toggleInput = "input[data-testid='toggle']"; +const streamNameInput = "input[name='streamName']"; +const streamUrlPath = "input[name='urlPath']"; +const recordSelectorInput = "[data-testid='tag-input'] input"; +const authType = "[data-testid='global.authenticator.type']"; +const testInputsButton = "[data-testid='test-inputs']"; +const limitInput = "[name='streams[0].paginator.strategy.page_size']"; +const injectOffsetInto = "[data-testid$='paginator.pageTokenOption.inject_into']"; +const injectOffsetFieldName = "[name='streams[0].paginator.pageTokenOption.field_name']"; +const testPageItem = "[data-testid='test-pages'] li"; +const submit = "button[type='submit']" +const testStreamButton = "button[data-testid='read-stream']"; + +export const goToConnectorBuilderPage = () => { + cy.visit("/connector-builder"); + cy.wait(3000); +}; + +export const enterName = (name: string) => { + cy.get(nameInput).type(name); +}; + +export const enterUrlBase = (urlBase: string) => { + cy.get(urlBaseInput).type(urlBase); +}; + +export const enterRecordSelector = (recordSelector: string) => { + cy.get(recordSelectorInput).first().type(recordSelector, { force: true }).type("{enter}", { force: true }); +}; + +const selectFromDropdown = (selector: string, value: string) => { + cy.get(`${selector} .react-select__dropdown-indicator`).last().click({ force: true }); + + cy.get(`.react-select__option`).contains(value).click(); +} + +export const selectAuthMethod = (value: string) => { + selectFromDropdown(authType, value); +}; + +export const goToView = (view: string) => { + cy.get(`button[data-testid=navbutton-${view}]`).click(); +} + +export const openTestInputs = () => { + cy.get(testInputsButton).click(); +} + +export const enterTestInputs = ({ apiKey }: { apiKey: string }) => { + cy.get(apiKeyInput).type(apiKey); +} + +export const goToTestPage = (page: number) => { + cy.get(testPageItem).contains(page).click(); +} + +export const togglePagination = () => { + cy.get(toggleInput).first().click({ force: true }); +} + +export const configureOffsetPagination = (limit: string, into: string, fieldName: string) => { + cy.get(limitInput).type(limit); + selectFromDropdown(injectOffsetInto, into); + cy.get(injectOffsetFieldName).type(fieldName); +} + +export const addStream = () => { + cy.get(addStreamButton).click(); +}; + +export const enterStreamName = (streamName: string) => { + cy.get(streamNameInput).type(streamName); +}; + +export const enterUrlPath = (urlPath: string) => { + cy.get(streamUrlPath).type(urlPath); +}; + +export const submitForm = () => { + cy.get(submit).click(); +}; + +export const testStream = () => { + // wait for debounced form + cy.wait(500); + cy.get(testStreamButton).click(); +}; \ No newline at end of file diff --git a/airbyte-webapp-e2e-tests/dummy_api.js b/airbyte-webapp-e2e-tests/dummy_api.js new file mode 100644 index 000000000000..83a878b2f75f --- /dev/null +++ b/airbyte-webapp-e2e-tests/dummy_api.js @@ -0,0 +1,28 @@ +// Script starting a basic webserver returning mocked data over an authenticated API to test the connector builder UI and connector builder server in an +// end to end fashion. + +// Start with `npm run createdummyapi` + +const http = require('http'); + +const items = [{ name: "abc" }, { name: "def" }, { name: "xxx" }, { name: "yyy" }]; + +const requestListener = function (req, res) { + if (req.headers["authorization"] !== "Bearer theauthkey") { + res.writeHead(403); res.end(JSON.stringify({ error: "Bad credentials" })); return; + } + if (req.url !== "/items") { + res.writeHead(404); res.end(JSON.stringify({ error: "Not found" })); return; + } + // Add more dummy logic in here + res.setHeader("Content-Type", "application/json"); + res.writeHead(200); + res.end(JSON.stringify({ items: [...items].splice(req.headers["offset"] ? Number(req.headers["offset"]) : 0, 2) })); +} + +const server = http.createServer(requestListener); +server.listen(6767); + +process.on('SIGINT', function () { + process.exit() +}) diff --git a/airbyte-webapp-e2e-tests/package.json b/airbyte-webapp-e2e-tests/package.json index a6351b0d96b3..7d700aa6732a 100644 --- a/airbyte-webapp-e2e-tests/package.json +++ b/airbyte-webapp-e2e-tests/package.json @@ -11,6 +11,7 @@ "cypress:ci:record": "CYPRESS_BASE_URL=http://localhost:8000 cypress run --record --key $CYPRESS_KEY", "createdbsource": "docker run --rm -d -p 5433:5432 -e POSTGRES_PASSWORD=secret_password -e POSTGRES_DB=airbyte_ci_source --name airbyte_ci_pg_source postgres", "createdbdestination": "docker run --rm -d -p 5434:5432 -e POSTGRES_PASSWORD=secret_password -e POSTGRES_DB=airbyte_ci_destination --name airbyte_ci_pg_destination postgres", + "createdummyapi": "docker run --rm -d -p 6767:6767 --network=airbyte_airbyte_internal --mount type=bind,source=\"$(pwd)\"/dummy_api.js,target=/index.js --name=dummy_api node:16-alpine \"index.js\"", "lint": "eslint --ext js,ts,tsx cypress" }, "devDependencies": { diff --git a/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx b/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx index cffa2cad32f8..5c6d685997bf 100644 --- a/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx +++ b/airbyte-webapp/src/components/connectorBuilder/Builder/AddStreamButton.tsx @@ -25,9 +25,15 @@ interface AddStreamButtonProps { onAddStream: (addedStreamNum: number) => void; button?: React.ReactElement; initialValues?: Partial; + "data-testid"?: string; } -export const AddStreamButton: React.FC = ({ onAddStream, button, initialValues }) => { +export const AddStreamButton: React.FC = ({ + onAddStream, + button, + initialValues, + "data-testid": testId, +}) => { const { formatMessage } = useIntl(); const [isOpen, setIsOpen] = useState(false); const [streamsField, , helpers] = useField("streams"); @@ -42,9 +48,10 @@ export const AddStreamButton: React.FC = ({ onAddStream, b {button ? ( React.cloneElement(button, { onClick: buttonClickHandler, + "data-testid": testId, }) ) : ( -