test: Migrate cypress batch to playwright (#19854)

This commit is contained in:
Artem Sorokin 2025-09-22 17:25:49 +02:00 committed by GitHub
parent 2d7990920d
commit ded6db694a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 635 additions and 263 deletions

View File

@ -1,128 +0,0 @@
import generateOTPToken from 'cypress-otp';
import { INSTANCE_OWNER, INSTANCE_ADMIN, BACKEND_BASE_URL } from '../../constants';
import { SigninPage } from '../../pages';
import { MfaLoginPage } from '../../pages/mfa-login';
import { successToast } from '../../pages/notifications';
import { PersonalSettingsPage } from '../../pages/settings-personal';
import { MainSidebar } from './../../pages/sidebar/main-sidebar';
const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD';
const RECOVERY_CODE = 'd04ea17f-e8b2-4afa-a9aa-57a2c735b30e';
const user = {
email: INSTANCE_OWNER.email,
password: INSTANCE_OWNER.password,
firstName: 'User',
lastName: 'A',
mfaEnabled: false,
mfaSecret: MFA_SECRET,
mfaRecoveryCodes: [RECOVERY_CODE],
};
const admin = {
email: INSTANCE_ADMIN.email,
password: INSTANCE_ADMIN.password,
firstName: 'Admin',
lastName: 'B',
mfaEnabled: false,
mfaSecret: MFA_SECRET,
mfaRecoveryCodes: [RECOVERY_CODE],
};
const mfaLoginPage = new MfaLoginPage();
const signinPage = new SigninPage();
const personalSettingsPage = new PersonalSettingsPage();
const mainSidebar = new MainSidebar();
describe('Two-factor authentication', { disableAutoLogin: true }, () => {
beforeEach(() => {
cy.request('POST', `${BACKEND_BASE_URL}/rest/e2e/reset`, {
owner: user,
members: [],
admin,
});
cy.on('uncaught:exception', (error) => {
expect(error.message).to.include('Not logged in');
return false;
});
cy.intercept('GET', '/rest/mfa/qr').as('getMfaQrCode');
});
it('Should be able to login with MFA code', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout();
const mfaCode = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
mainSidebar.actions.signout();
});
it('Should be able to login with MFA recovery code', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout();
mfaLoginPage.actions.loginWithMfaRecoveryCode(email, password, user.mfaRecoveryCodes[0]);
mainSidebar.actions.signout();
});
it('Should be able to disable MFA in account with MFA code', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout();
const mfaCode = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
const disableToken = generateOTPToken(user.mfaSecret);
personalSettingsPage.actions.disableMfa(disableToken);
personalSettingsPage.getters.enableMfaButton().should('exist');
mainSidebar.actions.signout();
});
it('Should prompt for MFA code when email changes', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
personalSettingsPage.actions.updateEmail('newemail@test.com');
const mfaCode = generateOTPToken(user.mfaSecret);
personalSettingsPage.getters.mfaCodeOrMfaRecoveryCodeInput().type(mfaCode);
personalSettingsPage.getters.mfaSaveButton().click();
successToast().should('exist');
mainSidebar.actions.signout();
});
it('Should prompt for MFA recovery code when email changes', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
personalSettingsPage.actions.updateEmail('newemail@test.com');
personalSettingsPage.getters.mfaCodeOrMfaRecoveryCodeInput().type(RECOVERY_CODE);
personalSettingsPage.getters.mfaSaveButton().click();
successToast().should('exist');
mainSidebar.actions.signout();
});
it('Should not prompt for MFA code or recovery code when first name or last name changes', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
personalSettingsPage.actions.updateFirstAndLastName('newFirstName', 'newLastName');
successToast().should('exist');
mainSidebar.actions.signout();
});
it('Should be able to disable MFA in account with recovery code', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout();
const mfaCode = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
personalSettingsPage.actions.disableMfa(user.mfaRecoveryCodes[0]);
personalSettingsPage.getters.enableMfaButton().should('exist');
mainSidebar.actions.signout();
});
});

View File

@ -1,116 +0,0 @@
import {
getCancelSaveChangesButton,
getCloseSaveChangesButton,
getSaveChangesModal,
} from '../../composables/modals/save-changes-modal';
import { getNdvContainer } from '../../composables/ndv';
import { getHomeButton } from '../../composables/projects';
import { addNodeToCanvas, saveWorkflowOnButtonClick } from '../../composables/workflow';
import {
getCreateWorkflowButton,
getNewWorkflowCardButton,
getWorkflowsPageUrl,
visitWorkflowsPage,
} from '../../composables/workflowsPage';
import { EDIT_FIELDS_SET_NODE_NAME } from '../../constants';
import { warningToast } from '../../pages/notifications';
describe('Workflows', () => {
beforeEach(() => {
visitWorkflowsPage();
});
it('should ask to save unsaved changes before leaving route', () => {
getNewWorkflowCardButton().should('be.visible');
getNewWorkflowCardButton().click();
cy.createFixtureWorkflow('Test_workflow_1.json');
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
getHomeButton().click();
// We expect to still be on the workflow route here
cy.url().should('include', '/workflow/');
getSaveChangesModal().should('be.visible');
getCancelSaveChangesButton().click();
// Only now do we switch
cy.url().should('include', getWorkflowsPageUrl());
});
it('should correct route after cancelling saveChangesModal', () => {
getCreateWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_1.json');
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
// Here we go back via browser rather than the home button
// As this already updates the route
cy.go(-1);
cy.url().should('include', getWorkflowsPageUrl());
getSaveChangesModal().should('be.visible');
getCloseSaveChangesButton().click();
// Confirm the url is back to the workflow
cy.url().should('include', '/workflow/');
});
it('should correct route when opening and closing NDV', () => {
getCreateWorkflowButton().click();
saveWorkflowOnButtonClick();
cy.url().then((startUrl) => {
cy.createFixtureWorkflow('Test_workflow_1.json');
cy.url().should('equal', startUrl);
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
// Getting the generated nodeId is awkward, so we just ensure the URL changed
cy.url().should('not.equal', startUrl);
cy.get('body').type('{esc}');
cy.url().should('equal', startUrl);
});
});
it('should open ndv via URL', () => {
getCreateWorkflowButton().click();
saveWorkflowOnButtonClick();
cy.createFixtureWorkflow('Test_workflow_1.json');
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
cy.url().then((ndvUrl) => {
cy.get('body').type('{esc}');
saveWorkflowOnButtonClick();
getNdvContainer().should('not.be.visible');
cy.visit(ndvUrl);
getNdvContainer().should('be.visible');
});
});
it('should open show warning and drop nodeId from URL if it contained an unknown nodeId', () => {
getCreateWorkflowButton().click();
saveWorkflowOnButtonClick();
cy.createFixtureWorkflow('Test_workflow_1.json');
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
cy.url().then((ndvUrl) => {
cy.get('body').type('{esc}');
saveWorkflowOnButtonClick();
getNdvContainer().should('not.be.visible');
cy.visit(ndvUrl + 'thisMessesUpTheNodeId');
warningToast().should('be.visible');
cy.url().should('equal', ndvUrl.split('/').slice(0, -1).join('/'));
});
});
});

View File

@ -0,0 +1,69 @@
import { expect } from '@playwright/test';
import { authenticator } from 'otplib';
import type { n8nPage } from '../pages/n8nPage';
export class MfaComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Enable MFA for a user using predefined secret
* @param email - User email
* @param password - User password
* @param mfaSecret - Known MFA secret to use for token generation
*/
async enableMfa(email: string, password: string, mfaSecret: string): Promise<void> {
await this.n8n.signIn.loginWithEmailAndPassword(email, password, true);
await this.n8n.settings.goToPersonalSettings();
await this.n8n.settings.clickEnableMfa();
await this.n8n.mfaSetupModal.getModalContainer().waitFor({ state: 'visible' });
await this.n8n.mfaSetupModal.clickCopySecretToClipboard();
const token = authenticator.generate(mfaSecret);
await this.n8n.mfaSetupModal.fillToken(token);
await expect(this.n8n.mfaSetupModal.getDownloadRecoveryCodesButton()).toBeVisible();
await this.n8n.mfaSetupModal.clickDownloadRecoveryCodes();
await this.n8n.mfaSetupModal.clickSave();
await this.n8n.mfaSetupModal.waitForHidden();
}
/**
* Login with MFA code
* @param email - User email
* @param password - User password
* @param mfaSecret - Known MFA secret for token generation
*/
async loginWithMfaCode(email: string, password: string, mfaSecret: string): Promise<void> {
await this.n8n.signIn.fillEmail(email);
await this.n8n.signIn.fillPassword(password);
await this.n8n.signIn.clickSubmit();
await expect(this.n8n.mfaLogin.getForm()).toBeVisible();
const loginMfaCode = authenticator.generate(mfaSecret);
await this.n8n.mfaLogin.submitMfaCode(loginMfaCode);
await expect(this.n8n.page).toHaveURL(/workflows/);
}
/**
* Login with MFA recovery code
* @param email - User email
* @param password - User password
* @param recoveryCode - Known recovery code
*/
async loginWithMfaRecoveryCode(
email: string,
password: string,
recoveryCode: string,
): Promise<void> {
await this.n8n.signIn.fillEmail(email);
await this.n8n.signIn.fillPassword(password);
await this.n8n.signIn.clickSubmit();
await expect(this.n8n.mfaLogin.getForm()).toBeVisible();
await this.n8n.mfaLogin.submitMfaRecoveryCode(recoveryCode);
await expect(this.n8n.page).toHaveURL(/workflows/);
}
}

View File

@ -5,6 +5,9 @@ export interface UserCredentials {
password: string;
firstName: string;
lastName: string;
mfaEnabled?: boolean;
mfaSecret?: string;
mfaRecoveryCodes?: string[];
}
// Simple name generators
@ -58,6 +61,9 @@ export const INSTANCE_OWNER_CREDENTIALS: UserCredentials = {
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
mfaEnabled: false,
mfaSecret: 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD',
mfaRecoveryCodes: ['d04ea17f-e8b2-4afa-a9aa-57a2c735b30e'],
};
export const INSTANCE_ADMIN_CREDENTIALS: UserCredentials = {

View File

@ -36,6 +36,7 @@
"n8n-workflow": "workspace:*",
"flatted": "catalog:",
"nanoid": "catalog:",
"otplib": "^12.0.1",
"tsx": "catalog:",
"mockserver-client": "^5.15.0",
"zod": "catalog:"

View File

@ -8,6 +8,7 @@ import { CredentialModal } from './components/CredentialModal';
import { FocusPanel } from './components/FocusPanel';
import { LogsPanel } from './components/LogsPanel';
import { NodeCreator } from './components/NodeCreator';
import { SaveChangesModal } from './components/SaveChangesModal';
import { StickyComponent } from './components/StickyComponent';
export class CanvasPage extends BasePage {
@ -16,6 +17,7 @@ export class CanvasPage extends BasePage {
readonly focusPanel = new FocusPanel(this.page.getByTestId('focus-panel'));
readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal'));
readonly nodeCreator = new NodeCreator(this.page);
readonly saveChangesModal = new SaveChangesModal(this.page.locator('.el-overlay'));
saveWorkflowButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });

View File

@ -0,0 +1,67 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for the MFA login page that appears after entering email/password when MFA is enabled.
*/
export class MfaLoginPage extends BasePage {
getForm(): Locator {
return this.page.getByTestId('mfa-login-form');
}
getMfaCodeField(): Locator {
return this.getForm().locator('input[name="mfaCode"]');
}
getMfaRecoveryCodeField(): Locator {
return this.getForm().locator('input[name="mfaRecoveryCode"]');
}
getEnterRecoveryCodeButton(): Locator {
return this.page.getByTestId('mfa-enter-recovery-code-button');
}
getSubmitButton(): Locator {
return this.page.getByRole('button', { name: /^(Continue|Verify)$/ });
}
async goToMfaLogin(): Promise<void> {
await this.page.goto('/mfa');
}
async fillMfaCode(code: string): Promise<void> {
await this.getMfaCodeField().fill(code);
}
async fillMfaRecoveryCode(recoveryCode: string): Promise<void> {
await this.getMfaRecoveryCodeField().fill(recoveryCode);
}
async clickEnterRecoveryCode(): Promise<void> {
await this.clickByTestId('mfa-enter-recovery-code-button');
}
async clickSubmit(): Promise<void> {
await this.getSubmitButton().click();
}
/**
* Fill MFA code and submit the form
* @param code - The MFA token to submit
*/
async submitMfaCode(code: string): Promise<void> {
await this.fillMfaCode(code);
// Form auto-submits
}
/**
* Switch to recovery code mode, fill recovery code and submit
* @param recoveryCode - The recovery code to submit
*/
async submitMfaRecoveryCode(recoveryCode: string): Promise<void> {
await this.clickEnterRecoveryCode();
await this.fillMfaRecoveryCode(recoveryCode);
// Form auto-submits
}
}

View File

@ -0,0 +1,52 @@
import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for the MFA setup modal that appears when enabling two-factor authentication.
*/
export class MfaSetupModal extends BasePage {
getModalContainer(): Locator {
return this.page.getByTestId('mfaSetup-modal');
}
getTokenInput(): Locator {
return this.page.getByTestId('mfa-token-input');
}
getCopySecretToClipboardButton(): Locator {
return this.page.getByTestId('mfa-secret-button');
}
getDownloadRecoveryCodesButton(): Locator {
return this.page.getByTestId('mfa-recovery-codes-button');
}
getSaveButton(): Locator {
return this.page.getByTestId('mfa-save-button');
}
async fillToken(token: string): Promise<void> {
await this.getTokenInput().fill(token);
}
async clickCopySecretToClipboard(): Promise<void> {
await this.clickByTestId('mfa-secret-button');
}
async clickDownloadRecoveryCodes(): Promise<void> {
await this.clickByTestId('mfa-recovery-codes-button');
}
async clickSave(): Promise<void> {
await this.getModalContainer().getByTestId('mfa-save-button').click();
}
/**
* Wait for the MFA setup modal to be hidden from view
*/
async waitForHidden(): Promise<void> {
await expect(this.getModalContainer()).toBeHidden();
}
}

View File

@ -1,5 +1,10 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* Page object for Settings including Personal Settings where users can update their profile and manage MFA.
*/
export class SettingsPage extends BasePage {
getMenuItems() {
return this.page.getByTestId('menu-item');
@ -17,32 +22,106 @@ export class SettingsPage extends BasePage {
await this.page.goto('/settings');
}
async goToPersonalSettings() {
async goToPersonalSettings(): Promise<void> {
await this.page.goto('/settings/personal');
}
getPersonalDataForm() {
getPersonalDataForm(): Locator {
return this.page.getByTestId('personal-data-form');
}
getFirstNameField() {
getFirstNameField(): Locator {
return this.getPersonalDataForm().locator('input[name="firstName"]');
}
getLastNameField() {
getLastNameField(): Locator {
return this.getPersonalDataForm().locator('input[name="lastName"]');
}
getSaveSettingsButton() {
getEmailField(): Locator {
return this.getPersonalDataForm().locator('input[name="email"]');
}
getSaveSettingsButton(): Locator {
return this.page.getByTestId('save-settings-button');
}
async fillPersonalData(firstName: string, lastName: string) {
async fillPersonalData(firstName: string, lastName: string): Promise<void> {
await this.getFirstNameField().fill(firstName);
await this.getLastNameField().fill(lastName);
}
async saveSettings() {
async fillEmail(email: string): Promise<void> {
await this.getEmailField().fill(email);
}
async pressEnterOnEmail(): Promise<void> {
await this.getEmailField().press('Enter');
}
async saveSettings(): Promise<void> {
await this.getSaveSettingsButton().click();
}
/**
* Complete workflow to update user's email address
* @param newEmail - The new email address to set
*/
async updateEmail(newEmail: string): Promise<void> {
await this.goToPersonalSettings();
await this.fillEmail(newEmail);
await this.saveSettings();
}
/**
* Complete workflow to update user's first and last name
* @param firstName - The new first name
* @param lastName - The new last name
*/
async updateFirstAndLastName(firstName: string, lastName: string): Promise<void> {
await this.goToPersonalSettings();
await this.fillPersonalData(firstName, lastName);
await this.saveSettings();
}
getEnableMfaButton(): Locator {
return this.page.getByTestId('enable-mfa-button');
}
getDisableMfaButton(): Locator {
return this.page.getByTestId('disable-mfa-button');
}
getMfaCodeOrRecoveryCodeInput(): Locator {
return this.page.locator('input[name="mfaCodeOrMfaRecoveryCode"]');
}
getMfaSaveButton(): Locator {
return this.page.getByTestId('mfa-save-button');
}
async clickEnableMfa(): Promise<void> {
await this.clickByTestId('enable-mfa-button');
}
async clickDisableMfa(): Promise<void> {
await this.getDisableMfaButton().click();
}
/**
* Navigate to personal settings and initiate MFA disable workflow
*/
async triggerDisableMfa(): Promise<void> {
await this.goToPersonalSettings();
await this.clickDisableMfa();
}
/**
* Fill in MFA code or recovery code and save the form
* @param code - MFA token or recovery code
*/
async fillMfaCodeAndSave(code: string): Promise<void> {
await this.getMfaCodeOrRecoveryCodeInput().fill(code);
await this.getMfaSaveButton().click();
}
}

View File

@ -11,6 +11,10 @@ export class SidebarPage {
await this.page.getByTestId('project-plus-button').click();
}
async clickHomeButton() {
await this.page.getByTestId('project-home-menu-item').click();
}
async universalAdd() {
await this.page.getByTestId('universal-add').click();
}
@ -44,6 +48,33 @@ export class SidebarPage {
return this.page.getByTestId('add-first-project-button');
}
getUserMenu(): Locator {
return this.page.getByTestId('user-menu');
}
getLogoutMenuItem(): Locator {
return this.page.getByTestId('user-menu-item-logout');
}
async openUserMenu(): Promise<void> {
await this.getUserMenu().click();
}
async clickSignout(): Promise<void> {
await this.expand();
await this.openUserMenu();
await this.getLogoutMenuItem().click();
}
async signOutFromWorkflows(): Promise<void> {
await this.page.goto('/workflows');
await this.clickSignout();
}
async goToWorkflows(): Promise<void> {
await this.page.goto('/workflows');
}
async expand() {
const collapseButton = this.page.locator('#collapse-change-button');
const chevronRight = this.page.locator(

View File

@ -0,0 +1,58 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SignInPage extends BasePage {
getForm(): Locator {
return this.page.getByTestId('auth-form');
}
getEmailField(): Locator {
return this.page.getByTestId('emailOrLdapLoginId').locator('input');
}
getPasswordField(): Locator {
return this.page.getByTestId('password').locator('input');
}
getSubmitButton(): Locator {
return this.page.getByTestId('form-submit-button');
}
async goToSignIn(): Promise<void> {
await this.page.goto('/signin');
}
async fillEmail(email: string): Promise<void> {
await this.getEmailField().fill(email);
}
async fillPassword(password: string): Promise<void> {
await this.getPasswordField().fill(password);
}
async clickSubmit(): Promise<void> {
await this.getSubmitButton().click();
}
/**
* Complete login flow with email and password
* @param email - User email
* @param password - User password
* @param waitForWorkflow - Whether to wait for redirect to workflow page after login
*/
async loginWithEmailAndPassword(
email: string,
password: string,
waitForWorkflow = false,
): Promise<void> {
await this.goToSignIn();
await this.fillEmail(email);
await this.fillPassword(password);
await this.clickSubmit();
if (waitForWorkflow) {
await this.page.waitForURL(/workflows/);
}
}
}

View File

@ -0,0 +1,37 @@
import type { Locator } from '@playwright/test';
/**
* Save Changes Modal component for handling unsaved changes dialogs.
* Appears when navigating away from workflow with unsaved changes.
*/
export class SaveChangesModal {
constructor(private root: Locator) {}
getModal(): Locator {
return this.root.filter({ hasText: 'Save changes before leaving?' });
}
getCancelButton(): Locator {
return this.root.locator('.btn--cancel');
}
getCloseButton(): Locator {
return this.root.locator('.el-message-box__headerbtn');
}
getSaveButton(): Locator {
return this.root.getByRole('button', { name: 'Save' });
}
async clickCancel(): Promise<void> {
await this.getCancelButton().click();
}
async clickClose(): Promise<void> {
await this.getCloseButton().click();
}
async clickSave(): Promise<void> {
await this.getSaveButton().click();
}
}

View File

@ -9,6 +9,8 @@ import { DemoPage } from './DemoPage';
import { ExecutionsPage } from './ExecutionsPage';
import { IframePage } from './IframePage';
import { InteractionsPage } from './InteractionsPage';
import { MfaLoginPage } from './MfaLoginPage';
import { MfaSetupModal } from './MfaSetupModal';
import { NodeDetailsViewPage } from './NodeDetailsViewPage';
import { NotificationsPage } from './NotificationsPage';
import { NpsSurveyPage } from './NpsSurveyPage';
@ -16,6 +18,7 @@ import { ProjectSettingsPage } from './ProjectSettingsPage';
import { SettingsLogStreamingPage } from './SettingsLogStreamingPage';
import { SettingsPage } from './SettingsPage';
import { SidebarPage } from './SidebarPage';
import { SignInPage } from './SignInPage';
import { VariablesPage } from './VariablesPage';
import { VersionsPage } from './VersionsPage';
import { WorkerViewPage } from './WorkerViewPage';
@ -25,6 +28,7 @@ import { WorkflowSharingModal } from './WorkflowSharingModal';
import { WorkflowsPage } from './WorkflowsPage';
import { CanvasComposer } from '../composables/CanvasComposer';
import { CredentialsComposer } from '../composables/CredentialsComposer';
import { MfaComposer } from '../composables/MfaComposer';
import { PartialExecutionComposer } from '../composables/PartialExecutionComposer';
import { ProjectComposer } from '../composables/ProjectComposer';
import { TestEntryComposer } from '../composables/TestEntryComposer';
@ -45,6 +49,7 @@ export class n8nPage {
readonly demo: DemoPage;
readonly iframe: IframePage;
readonly interactions: InteractionsPage;
readonly mfaLogin: MfaLoginPage;
readonly ndv: NodeDetailsViewPage;
readonly npsSurvey: NpsSurveyPage;
readonly projectSettings: ProjectSettingsPage;
@ -58,17 +63,20 @@ export class n8nPage {
readonly credentials: CredentialsPage;
readonly executions: ExecutionsPage;
readonly sideBar: SidebarPage;
readonly signIn: SignInPage;
// Modals
readonly workflowActivationModal: WorkflowActivationModal;
readonly workflowSettingsModal: WorkflowSettingsModal;
readonly workflowSharingModal: WorkflowSharingModal;
readonly mfaSetupModal: MfaSetupModal;
// Composables
readonly workflowComposer: WorkflowComposer;
readonly projectComposer: ProjectComposer;
readonly canvasComposer: CanvasComposer;
readonly credentialsComposer: CredentialsComposer;
readonly mfaComposer: MfaComposer;
readonly partialExecutionComposer: PartialExecutionComposer;
readonly start: TestEntryComposer;
@ -87,6 +95,7 @@ export class n8nPage {
this.demo = new DemoPage(page);
this.iframe = new IframePage(page);
this.interactions = new InteractionsPage(page);
this.mfaLogin = new MfaLoginPage(page);
this.ndv = new NodeDetailsViewPage(page);
this.npsSurvey = new NpsSurveyPage(page);
this.projectSettings = new ProjectSettingsPage(page);
@ -100,17 +109,20 @@ export class n8nPage {
this.credentials = new CredentialsPage(page);
this.executions = new ExecutionsPage(page);
this.sideBar = new SidebarPage(page);
this.signIn = new SignInPage(page);
this.workflowSharingModal = new WorkflowSharingModal(page);
// Modals
this.workflowActivationModal = new WorkflowActivationModal(page);
this.workflowSettingsModal = new WorkflowSettingsModal(page);
this.mfaSetupModal = new MfaSetupModal(page);
// Composables
this.workflowComposer = new WorkflowComposer(this);
this.projectComposer = new ProjectComposer(this);
this.canvasComposer = new CanvasComposer(this);
this.credentialsComposer = new CredentialsComposer(this);
this.mfaComposer = new MfaComposer(this);
this.partialExecutionComposer = new PartialExecutionComposer(this);
this.start = new TestEntryComposer(this);

View File

@ -0,0 +1,99 @@
import { authenticator } from 'otplib';
import { INSTANCE_OWNER_CREDENTIALS } from '../../config/test-users';
import { test, expect } from '../../fixtures/base';
const TEST_DATA = {
NEW_EMAIL: 'newemail@test.com',
NEW_FIRST_NAME: 'newFirstName',
NEW_LAST_NAME: 'newLastName',
};
const NOTIFICATIONS = {
PERSONAL_DETAILS_UPDATED: 'Personal details updated',
};
const { email, password, mfaSecret, mfaRecoveryCodes } = INSTANCE_OWNER_CREDENTIALS;
const RECOVERY_CODE = mfaRecoveryCodes![0];
test.describe('Two-factor authentication @auth:none @db:reset', () => {
test('Should be able to login with MFA code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
await expect(n8n.page).toHaveURL(/workflows/);
});
test('Should be able to login with MFA recovery code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaRecoveryCode(email, password, RECOVERY_CODE);
await expect(n8n.page).toHaveURL(/workflows/);
});
test('Should be able to disable MFA in account with MFA code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
const disableToken = authenticator.generate(mfaSecret!);
await n8n.settings.triggerDisableMfa();
await n8n.settings.fillMfaCodeAndSave(disableToken);
await expect(n8n.settings.getEnableMfaButton()).toBeVisible();
});
test('Should prompt for MFA code when email changes', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settings.goToPersonalSettings();
await n8n.settings.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settings.pressEnterOnEmail();
const mfaCode = authenticator.generate(mfaSecret!);
await n8n.settings.fillMfaCodeAndSave(mfaCode);
await expect(
n8n.notifications.getNotificationByTitleOrContent(NOTIFICATIONS.PERSONAL_DETAILS_UPDATED),
).toBeVisible();
});
test('Should prompt for MFA recovery code when email changes', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settings.goToPersonalSettings();
await n8n.settings.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settings.pressEnterOnEmail();
await expect(n8n.settings.getMfaCodeOrRecoveryCodeInput()).toBeVisible();
});
test('Should not prompt for MFA code or recovery code when first name or last name changes', async ({
n8n,
}) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settings.updateFirstAndLastName(TEST_DATA.NEW_FIRST_NAME, TEST_DATA.NEW_LAST_NAME);
await expect(
n8n.notifications.getNotificationByTitleOrContent(NOTIFICATIONS.PERSONAL_DETAILS_UPDATED),
).toBeVisible();
});
test('Should be able to disable MFA in account with recovery code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
await n8n.settings.triggerDisableMfa();
await n8n.settings.fillMfaCodeAndSave(RECOVERY_CODE);
await expect(n8n.settings.getEnableMfaButton()).toBeVisible();
});
});

View File

@ -0,0 +1,100 @@
import { EDIT_FIELDS_SET_NODE_NAME } from '../../config/constants';
import { test, expect } from '../../fixtures/base';
test.describe('Routing @db:reset', () => {
test('should ask to save unsaved changes before leaving route', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.workflows.getNewWorkflowCard()).toBeVisible();
await n8n.workflows.clickNewWorkflowCard();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test Workflow');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.sideBar.clickHomeButton();
await expect(n8n.page).toHaveURL(/workflow/);
await expect(n8n.canvas.saveChangesModal.getModal()).toBeVisible();
await n8n.canvas.saveChangesModal.clickCancel();
await expect(n8n.page).toHaveURL(/home\/workflows/);
});
test('should correct route after cancelling saveChangesModal', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test Workflow');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
await n8n.page.goBack();
await expect(n8n.page).toHaveURL(/home\/workflows/);
await expect(n8n.canvas.saveChangesModal.getModal()).toBeVisible();
await n8n.canvas.saveChangesModal.clickClose();
await expect(n8n.page).toHaveURL(/workflow/);
});
test('should correct route when opening and closing NDV', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickSaveWorkflowButton();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test Workflow');
const baselineUrl = n8n.page.url();
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
expect(n8n.page.url()).not.toBe(baselineUrl);
await n8n.page.keyboard.press('Escape');
expect(n8n.page.url()).toBe(baselineUrl);
});
test('should open ndv via URL', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickSaveWorkflowButton();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test Workflow');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
const ndvUrl = n8n.page.url();
await n8n.page.keyboard.press('Escape');
await n8n.canvas.clickSaveWorkflowButton();
await expect(n8n.ndv.getContainer()).toBeHidden();
await n8n.page.goto(ndvUrl);
await expect(n8n.ndv.getContainer()).toBeVisible();
});
test('should open show warning and drop nodeId from URL if it contained an unknown nodeId', async ({
n8n,
}) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickSaveWorkflowButton();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test Workflow');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
const ndvUrl = n8n.page.url();
await n8n.page.keyboard.press('Escape');
await n8n.canvas.clickSaveWorkflowButton();
await expect(n8n.ndv.getContainer()).toBeHidden();
await n8n.page.goto(ndvUrl + 'thisMessesUpTheNodeId');
await expect(n8n.notifications.getWarningNotifications()).toBeVisible();
const urlWithoutNodeId = ndvUrl.split('/').slice(0, -1).join('/');
expect(n8n.page.url()).toBe(urlWithoutNodeId);
});
});

27
pnpm-lock.yaml generated
View File

@ -1044,7 +1044,7 @@ importers:
version: 4.3.0
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@ -1071,7 +1071,7 @@ importers:
version: 0.3.4(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)
'@langchain/community':
specifier: 'catalog:'
version: 0.3.50(8ac6ecc2064042e5620199e694862b5d)
version: 0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
@ -1194,7 +1194,7 @@ importers:
version: 23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
langchain:
specifier: 0.3.33
version: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
version: 0.3.33(f461b118585bdb288345da9017188aa6)
lodash:
specifier: 'catalog:'
version: 4.17.21
@ -3228,6 +3228,9 @@ importers:
nanoid:
specifier: 'catalog:'
version: 3.3.8
otplib:
specifier: ^12.0.1
version: 12.0.1
tsx:
specifier: 'catalog:'
version: 4.19.3
@ -19285,7 +19288,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))':
dependencies:
form-data: 4.0.4
node-fetch: 2.7.0(encoding@0.1.13)
@ -19294,7 +19297,7 @@ snapshots:
zod: 3.25.67
optionalDependencies:
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
transitivePeerDependencies:
- encoding
@ -19861,7 +19864,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.50(8ac6ecc2064042e5620199e694862b5d)':
'@langchain/community@0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.54.2)(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.6.1)(encoding@0.1.13)(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(utf-8-validate@5.0.10)(zod@3.25.67)
'@ibm-cloud/watsonx-ai': 1.1.2
@ -19873,7 +19876,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.3.2
js-yaml: 4.1.0
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
langsmith: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
openai: 5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)
uuid: 10.0.0
@ -19887,7 +19890,7 @@ snapshots:
'@aws-sdk/credential-provider-node': 3.808.0
'@azure/storage-blob': 12.26.0
'@browserbasehq/sdk': 2.6.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 3.4.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@ -27020,7 +27023,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 20.19.11
'@types/tough-cookie': 4.0.5
axios: 1.12.0(debug@4.3.6)
axios: 1.12.0(debug@4.4.1)
camelcase: 6.3.0
debug: 4.4.1(supports-color@8.1.1)
dotenv: 16.6.1
@ -27030,7 +27033,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.12.0(debug@4.4.1))
retry-axios: 2.6.0(axios@1.12.0)
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@ -28288,7 +28291,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e):
langchain@0.3.33(f461b118585bdb288345da9017188aa6):
dependencies:
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
'@langchain/openai': 0.6.7(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))
@ -31101,7 +31104,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.12.0(debug@4.4.1)):
retry-axios@2.6.0(axios@1.12.0):
dependencies:
axios: 1.12.0(debug@4.3.6)