feat: Add credentials E2E test suite and page object (#4596)

* fix: Fix inferred type of X cannot be named error after pnpm update

* feat: Change page objects to expose actions and getters. Add credential creation suite
This commit is contained in:
Alex Grozav 2022-11-22 11:37:26 +02:00 committed by GitHub
parent 772ec78349
commit b5b44d1b59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 159 additions and 67 deletions

View file

@ -5,7 +5,7 @@ module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:5678',
video: false,
screenshotOnRunFailure: false,
screenshotOnRunFailure: true,
experimentalSessionAndOrigin: true,
experimentalInteractiveRunEvents: true,
}

View file

@ -6,7 +6,7 @@ const password = DEFAULT_USER_PASSWORD;
const firstName = randFirstName();
const lastName = randLastName();
describe('Authentication flow', () => {
describe('Authentication', () => {
it('should sign user up', () => {
cy.signup(username, firstName, lastName, password);
});

View file

@ -11,7 +11,7 @@ const lastName = randLastName();
const WorkflowsPage = new WorkflowsPageClass();
const WorkflowPage = new WorkflowPageClass();
describe('Workflows flow', () => {
describe('Workflows', () => {
beforeEach(() => {
cy.signup(username, firstName, lastName, password);
@ -26,59 +26,61 @@ describe('Workflows flow', () => {
});
it('should create a new workflow using empty state card', () => {
WorkflowsPage.get('newWorkflowButtonCard').should('be.visible');
WorkflowsPage.get('newWorkflowButtonCard').click();
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
WorkflowsPage.getters.newWorkflowButtonCard().click();
cy.createFixtureWorkflow('Test_workflow_1.json', `Empty State Card Workflow ${uuid()}`);
WorkflowPage.get('workflowTags').should('contain.text', 'some-tag-1');
WorkflowPage.get('workflowTags').should('contain.text', 'some-tag-2');
WorkflowPage.getters.workflowTags().should('contain.text', 'some-tag-1');
WorkflowPage.getters.workflowTags().should('contain.text', 'some-tag-2');
})
it('should create a new workflow using add workflow button', () => {
WorkflowsPage.get('newWorkflowButtonCard').should('not.exist');
WorkflowsPage.get('createWorkflowButton').click();
WorkflowsPage.getters.newWorkflowButtonCard().should('not.exist');
WorkflowsPage.getters.createWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_2.json', `Add Workflow Button Workflow ${uuid()}`);
WorkflowPage.get('workflowTags').should('contain.text', 'other-tag-1');
WorkflowPage.get('workflowTags').should('contain.text', 'other-tag-2');
WorkflowPage.getters.workflowTags().should('contain.text', 'other-tag-1');
WorkflowPage.getters.workflowTags().should('contain.text', 'other-tag-2');
})
it('should search for a workflow', () => {
WorkflowsPage.get('searchBar').type('Empty State Card Workflow');
WorkflowsPage.getters.searchBar().type('Empty State Card Workflow');
WorkflowsPage.get('workflowCards').should('have.length', 1);
WorkflowsPage.get('workflowCard', 'Empty State Card Workflow').should('contain.text', 'Empty State Card Workflow');
WorkflowsPage.getters.workflowCards().should('have.length', 1);
WorkflowsPage.getters.workflowCard('Empty State Card Workflow').should('contain.text', 'Empty State Card Workflow');
WorkflowsPage.get('searchBar').clear().type('Add Workflow Button Workflow');
WorkflowsPage.getters.searchBar().clear().type('Add Workflow Button Workflow');
WorkflowsPage.get('workflowCards').should('have.length', 1);
WorkflowsPage.get('workflowCard', 'Add Workflow Button Workflow').should('contain.text', 'Add Workflow Button Workflow');
WorkflowsPage.getters.workflowCards().should('have.length', 1);
WorkflowsPage.getters.workflowCard('Add Workflow Button Workflow').should('contain.text', 'Add Workflow Button Workflow');
WorkflowsPage.getters.searchBar().clear().type('Some non-existent workflow');
WorkflowsPage.getters.workflowCards().should('not.exist');
WorkflowsPage.get('searchBar').clear().type('Some non-existent workflow');
WorkflowsPage.get('workflowCards').should('not.exist');
cy.contains('No workflows found').should('be.visible');
})
it('should delete all the workflows', () => {
WorkflowsPage.get('workflowCards').should('have.length', 2);
WorkflowsPage.getters.workflowCards().should('have.length', 2);
WorkflowsPage.get('workflowCards').each(($el) => {
WorkflowsPage.getters.workflowCards().each(($el) => {
const workflowName = $el.find('[data-test-id="workflow-card-name"]').text();
WorkflowsPage.get('workflowCardActions', workflowName).click();
WorkflowsPage.get('workflowDeleteButton').click();
WorkflowsPage.getters.workflowCardActions(workflowName).click();
WorkflowsPage.getters.workflowDeleteButton().click();
cy.get('button').contains('delete').click();
})
WorkflowsPage.get('newWorkflowButtonCard').should('be.visible');
WorkflowsPage.get('newWorkflowTemplateCard').should('be.visible');
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
WorkflowsPage.getters.newWorkflowTemplateCard().should('be.visible');
})
it('should contain empty state cards', () => {
WorkflowsPage.get('newWorkflowButtonCard').should('be.visible');
WorkflowsPage.get('newWorkflowTemplateCard').should('be.visible');
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
WorkflowsPage.getters.newWorkflowTemplateCard().should('be.visible');
});
});

View file

@ -0,0 +1,41 @@
import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from "../constants";
import { randFirstName, randLastName } from "@ngneat/falso";
import { CredentialsPage, CredentialsModal } from '../pages';
// import { v4 as uuid } from 'uuid';
const username = DEFAULT_USER_EMAIL;
const password = DEFAULT_USER_PASSWORD;
const firstName = randFirstName();
const lastName = randLastName();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
describe('Credentials', () => {
beforeEach(() => {
cy.signup(username, firstName, lastName, password);
cy.on('uncaught:exception', (err, runnable) => {
expect(err.message).to.include('Not logged in');
return false;
})
cy.signin(username, password);
cy.visit(credentialsPage.url);
});
it('should create a new credential using empty state', () => {
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('API Key').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account');
credentialsModal.actions.save();
});
});

View file

@ -1,15 +1,6 @@
import { IE2ETestPage, IE2ETestPageElement } from "../types";
export class BasePage implements IE2ETestPage {
elements: Record<string, IE2ETestPageElement> = {};
get(id: keyof BasePage['elements'], ...args: unknown[]): ReturnType<IE2ETestPageElement> {
const getter = this.elements[id];
if (!getter) {
throw new Error(`No element with id "${id}" found. Check your page object definition.`);
}
return getter(...args);
}
getters: Record<string, IE2ETestPageElement> = {};
actions: Record<string, (...args: any[]) => void> = {};
}

View file

@ -0,0 +1,17 @@
import { BasePage } from "./base";
export class CredentialsPage extends BasePage {
url = '/credentials';
getters = {
emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'),
createCredentialButton: () => cy.getByTestId('resources-list-add'),
searchBar: () => cy.getByTestId('resources-list-search'),
credentialCards: () => cy.getByTestId('credential-card'),
credentialCard: (credentialName: string) => cy.getByTestId('credential-card')
.contains(credentialName)
.parents('[data-test-id="credential-card"]'),
credentialCardActions: (credentialName: string) => this.getters.credentialCard(credentialName)
.findChildByTestId('credential-card-actions'),
credentialDeleteButton: () => cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete')
};
}

View file

@ -1,4 +1,6 @@
export * from './base';
export * from './credentials';
export * from './signin';
export * from './signup';
export * from './workflows';
export * from './modals';

View file

@ -0,0 +1,26 @@
import { BasePage } from "../base";
export class CredentialsModal extends BasePage {
getters = {
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
newCredentialTypeSelect: () => cy.getByTestId('new-credential-type-select'),
newCredentialTypeOption: (credentialType: string) => cy.getByTestId('new-credential-type-select-option').contains(credentialType),
newCredentialTypeButton: () => cy.getByTestId('new-credential-type-button'),
connectionParameters: () => cy.getByTestId('credential-connection-parameter'),
connectionParameter: (fieldName: string) => this.getters.connectionParameters().contains(fieldName)
.parents('[data-test-id="credential-connection-parameter"]')
.find('.n8n-input input'),
name: () => cy.getByTestId('credential-name'),
nameInput: () => cy.getByTestId('credential-name').find('input'),
saveButton: () => cy.getByTestId('credential-save-button')
};
actions = {
setName: (name: string) => {
this.getters.name().click();
this.getters.nameInput().clear().type(name);
},
save: () => {
this.getters.saveButton().click();
}
};
}

View file

@ -0,0 +1 @@
export * from './credentials-modal';

View file

@ -2,7 +2,7 @@ import { BasePage } from "./base";
export class SigninPage extends BasePage {
url = '/signin';
elements = {
getters = {
form: () => cy.getByTestId('auth-form'),
email: () => cy.getByTestId('email'),
password: () => cy.getByTestId('password'),

View file

@ -2,7 +2,7 @@ import { BasePage } from "./base";
export class SignupPage extends BasePage {
url = '/setup';
elements = {
getters = {
form: () => cy.getByTestId('auth-form'),
email: () => cy.getByTestId('email'),
firstName: () => cy.getByTestId('firstName'),

View file

@ -2,7 +2,7 @@ import { BasePage } from "./base";
export class WorkflowPage extends BasePage {
url = '/workflow/new';
elements = {
getters = {
workflowNameInput: () => cy.getByTestId('workflow-name-input').then($el => cy.wrap($el.find('input'))),
workflowImportInput: () => cy.getByTestId('workflow-import-input'),
workflowTags: () => cy.getByTestId('workflow-tags'),

View file

@ -2,7 +2,7 @@ import { BasePage } from "./base";
export class WorkflowsPage extends BasePage {
url = '/workflows';
elements = {
getters = {
newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'),
newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'),
searchBar: () => cy.getByTestId('resources-list-search'),
@ -11,13 +11,13 @@ export class WorkflowsPage extends BasePage {
workflowCard: (workflowName: string) => cy.getByTestId(`workflow-card`)
.contains(workflowName)
.parents('[data-test-id="workflow-card"]'),
workflowTags: (workflowName: string) => this.elements.workflowCard(workflowName)
workflowTags: (workflowName: string) => this.getters.workflowCard(workflowName)
.findChildByTestId('workflow-card-tags'),
workflowActivator: (workflowName: string) => this.elements.workflowCard(workflowName)
workflowActivator: (workflowName: string) => this.getters.workflowCard(workflowName)
.findChildByTestId('workflow-card-activator'),
workflowActivatorStatus: (workflowName: string) => this.elements.workflowActivator(workflowName)
workflowActivatorStatus: (workflowName: string) => this.getters.workflowActivator(workflowName)
.findChildByTestId('workflow-activator-status'),
workflowCardActions: (workflowName: string) => this.elements.workflowCard(workflowName)
workflowCardActions: (workflowName: string) => this.getters.workflowCard(workflowName)
.findChildByTestId('workflow-card-actions'),
workflowDeleteButton: () => cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete')
// Not yet implemented

View file

@ -36,13 +36,13 @@ Cypress.Commands.add('createFixtureWorkflow', (fixtureKey, workflowName) => {
const WorkflowPage = new WorkflowPageClass()
// We need to force the click because the input is hidden
WorkflowPage.get('workflowImportInput').selectFile(`cypress/fixtures/${fixtureKey}`, { force: true});
WorkflowPage.get('workflowNameInput').should('be.disabled');
WorkflowPage.get('workflowNameInput').parent().click()
WorkflowPage.get('workflowNameInput').should('be.enabled');
WorkflowPage.get('workflowNameInput').clear().type(workflowName).type('{enter}');
WorkflowPage.getters.workflowImportInput().selectFile(`cypress/fixtures/${fixtureKey}`, { force: true});
WorkflowPage.getters.workflowNameInput().should('be.disabled');
WorkflowPage.getters.workflowNameInput().parent().click()
WorkflowPage.getters.workflowNameInput().should('be.enabled');
WorkflowPage.getters.workflowNameInput().clear().type(workflowName).type('{enter}');
WorkflowPage.get('saveButton').should('contain', 'Saved');
WorkflowPage.getters.saveButton().should('contain', 'Saved');
})
Cypress.Commands.add('findChildByTestId', { prevSubject: true }, (subject: Cypress.Chainable<JQuery<HTMLElement>>, childTestId) => {
@ -58,10 +58,10 @@ Cypress.Commands.add(
cy.session([email, password], () => {
cy.visit(signinPage.url);
signinPage.get('form').within(() => {
signinPage.get('email').type(email);
signinPage.get('password').type(password);
signinPage.get('submit').click();
signinPage.getters.form().within(() => {
signinPage.getters.email().type(email);
signinPage.getters.password().type(password);
signinPage.getters.submit().click();
});
// we should be redirected to /workflows
@ -79,14 +79,14 @@ Cypress.Commands.add('signup', (email, firstName, lastName, password) => {
cy.visit(signupPage.url);
signupPage.get('form').within(() => {
signupPage.getters.form().within(() => {
cy.url().then((url) => {
if (url.endsWith(signupPage.url)) {
signupPage.get('email').type(email);
signupPage.get('firstName').type(firstName);
signupPage.get('lastName').type(lastName);
signupPage.get('password').type(password);
signupPage.get('submit').click();
signupPage.getters.email().type(email);
signupPage.getters.firstName().type(firstName);
signupPage.getters.lastName().type(lastName);
signupPage.getters.password().type(password);
signupPage.getters.submit().click();
} else {
cy.log('User already signed up');
}

View file

@ -1,9 +1,10 @@
export type IE2ETestPageElement = (...args: any[]) =>
| Cypress.Chainable<JQuery<HTMLElement>>
| Cypress.Chainable<JQuery<HTMLInputElement>>
| Cypress.Chainable<JQuery<HTMLButtonElement>>;
export interface IE2ETestPage {
url?: string;
elements: Record<string, IE2ETestPageElement>;
get(id: string, ...args: unknown[]): ReturnType<IE2ETestPageElement>;
getters: Record<string, IE2ETestPageElement>;
actions: Record<string, (...args: any[]) => void>;
}

View file

@ -26,11 +26,11 @@
"webhook": "./packages/cli/bin/n8n webhook",
"worker": "./packages/cli/bin/n8n worker",
"cypress:install": "cypress install",
"test:e2e:db:clean": "rimraf ~/.n8n/cypress.sqlite ~/.n8n/cypress.sqlite.bak",
"test:e2e:db:clean": "rimraf ~/.n8n/cypress.sqlite",
"test:e2e:cypress:run": "cypress run",
"test:e2e": "pnpm test:e2e:db:clean && cross-env DB_SQLITE_DATABASE=cypress.sqlite N8N_DIAGNOSTICS_ENABLED=false start-server-and-test start http://localhost:5678/favicon.ico test:e2e:cypress:run",
"test:e2e:cypress:dev": "cypress open",
"test:e2e:dev": "pnpm test:e2e:db:clean && cross-env DB_SQLITE_DATABASE=cypress.sqlite N8N_DIAGNOSTICS_ENABLED=false start-server-and-test start http://localhost:5678/favicon.ico test:e2e:cypress:dev",
"test:e2e:dev": "pnpm test:e2e:db:clean && cross-env DB_SQLITE_DATABASE=cypress.sqlite N8N_DIAGNOSTICS_ENABLED=false CYPRESS_BASE_URL=http://localhost:8080 start-server-and-test dev http://localhost:8080/favicon.ico test:e2e:cypress:dev",
"test:e2e:cypress:ci:smoke": "cypress run --headless --spec \"cypress/e2e/0-smoke.cy.ts\"",
"test:e2e:ci:smoke": "pnpm test:e2e:db:clean && cross-env DB_SQLITE_DATABASE=cypress.sqlite N8N_DIAGNOSTICS_ENABLED=false start-server-and-test start http://localhost:5678/favicon.ico test:e2e:cypress:ci:smoke"
},

View file

@ -2,6 +2,7 @@
<n8n-card
:class="$style['card-link']"
@click="onClick"
data-test-id="credential-card"
>
<template #prepend>
<credential-icon :credential-type-name="credentialType ? credentialType.name : ''" />

View file

@ -20,6 +20,7 @@
:readonly="!credentialPermissions.updateName"
type="Credential"
@input="onNameEdit"
data-test-id="credential-name"
/>
</div>
<div :class="$style.credActions">
@ -32,6 +33,7 @@
:disabled="isSaving"
:loading="isDeleting"
@click="deleteCredential"
data-test-id="credential-delete-button"
/>
<SaveButton
v-if="(hasUnsavedChanges || credentialId) && credentialPermissions.save"
@ -41,6 +43,7 @@
? $locale.baseText('credentialEdit.credentialEdit.testing')
: $locale.baseText('credentialEdit.credentialEdit.saving')"
@click="saveCredential"
data-test-id="credential-save-button"
/>
</div>
</div>

View file

@ -1,6 +1,6 @@
<template>
<div @keydown.stop :class="$style.container" v-if="credentialProperties.length">
<form v-for="parameter in credentialProperties" :key="parameter.name" autocomplete="off">
<form v-for="parameter in credentialProperties" :key="parameter.name" autocomplete="off" data-test-id="credential-connection-parameter">
<!-- Why form? to break up inputs, to prevent Chrome autofill -->
<n8n-notice
v-if="parameter.type === 'notice'"

View file

@ -13,12 +13,14 @@
@keydown.stop
@focus="$emit('setFocus')"
@blur="$emit('onBlur')"
data-test-id="credential-select"
>
<n8n-option
v-for="credType in supportedCredentialTypes"
:value="credType.name"
:key="credType.name"
:label="credType.displayName"
data-test-id="credential-select-option"
>
<div class="list-option">
<div class="option-headline">

View file

@ -22,6 +22,7 @@
ref="select"
:value="selected"
@change="onSelect"
data-test-id="new-credential-type-select"
>
<template #prefix>
<font-awesome-icon icon="search" />
@ -32,6 +33,7 @@
:key="credential.name"
:label="credential.displayName"
filterable
data-test-id="new-credential-type-select-option"
/>
</n8n-select>
</div>
@ -44,6 +46,7 @@
size="large"
:disabled="!selected"
@click="openCredentialType"
data-test-id="new-credential-type-button"
/>
</div>
</template>

View file

@ -10,6 +10,7 @@
:close-on-press-escape="closeOnPressEscape"
:style="styles"
append-to-body
:data-test-id="`${this.$props.name}-modal`"
>
<template #title v-if="$scopedSlots.header">
<slot name="header" v-if="!loading" />

View file

@ -31,6 +31,7 @@
<div class="ph-no-capture" v-if="resources.length === 0">
<slot name="empty">
<n8n-action-box
data-test-id="empty-resources-list"
emoji="👋"
:heading="$locale.baseText(usersStore.currentUser.firstName ? `${resourceKey}.empty.heading` : `${resourceKey}.empty.heading.userNotSetup`, {
interpolate: { name: usersStore.currentUser.firstName }