n8n/packages/testing/playwright/pages/components/CredentialModal.ts
Svetoslav Dekov 3df6611fb3
chore(editor): Refactoring instance-ai workflow setup FE code (no-changelog) (#30012)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Charlie Kolb <charlie@n8n.io>
2026-05-11 13:33:15 +00:00

259 lines
7.6 KiB
TypeScript

import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import { BaseModal } from './BaseModal';
/**
* Credential modal component for canvas and credentials interactions.
* Used within CanvasPage as `n8n.canvas.credentialModal.*`
* Used within CredentialsPage as `n8n.credentials.modal.*`
*
* @example
* // Access via canvas page or credentials page
* await n8n.canvas.credentialModal.addCredential();
* await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
*/
export class CredentialModal extends BaseModal {
constructor(private root: Locator) {
super(root.page());
}
getModal(): Locator {
return this.root;
}
getCredentialName(): Locator {
return this.root.getByTestId('credential-name');
}
getNameInput(): Locator {
return this.getCredentialName().getByTestId('inline-edit-input');
}
getCredentialInputs(): Locator {
return this.root.getByTestId('credential-connection-parameter');
}
async waitForModal(): Promise<void> {
await this.root.waitFor({ state: 'visible' });
}
async fillField(key: string, value: string): Promise<void> {
const parameterInput = this.root.getByTestId(`parameter-input-${key}`);
const input = parameterInput.locator('input, textarea');
// Wait for input to be visible before filling
await input.waitFor({ state: 'visible', timeout: 10000 });
await input.fill(value);
await expect(input).toHaveValue(value);
}
/**
* Switch a credential field to expression mode and fill it with an expression.
*
* Expression mode is activated by clicking the "Expression" radio button that
* appears when hovering over a parameter input.
*
* @example
* await modal.fillExpressionField('value', "{{ $secrets['myVault']['apikey'] }}");
*/
async fillExpressionField(key: string, expression: string): Promise<void> {
const parameterInput = this.root
.getByTestId('credential-connection-parameter')
.getByTestId(key);
// Hover to reveal the Fixed / Expression toggle
await parameterInput.locator('label').first().hover();
await parameterInput.getByTestId('parameter-options-container').waitFor({ state: 'visible' });
// Click the "Expression" radio option
await parameterInput.getByTestId('radio-button-expression').click();
// After switching modes, the field becomes a CodeMirror editor
const cmContent = parameterInput.locator('.cm-content');
await cmContent.waitFor({ state: 'visible', timeout: 10_000 });
await cmContent.click();
await cmContent.fill(expression);
}
async fillAllFields(values: Record<string, string>): Promise<void> {
for (const [key, val] of Object.entries(values)) {
await this.fillField(key, val);
}
}
getSaveButton(): Locator {
return this.root.getByTestId('credential-save-button');
}
getParameterInputHint(): Locator {
return this.container.getByTestId('parameter-input-hint');
}
/**
* Wait for save to fully complete.
* After saving (and optional credential testing), the button either shows a
* "Saved" label or settles back to a disabled "Save" state.
*/
async waitForSaveComplete(): Promise<void> {
const saveCompleted = this.root.getByText('Saved', { exact: true }).or(
this.getSaveButton()
.locator('button[disabled]')
.filter({ hasText: /^Save$/ }),
);
await expect(saveCompleted).toBeVisible({ timeout: 20_000 });
}
async save(): Promise<void> {
await this.getSaveButton().click();
await this.waitForSaveComplete();
}
async close(): Promise<void> {
const closeBtn = this.root.locator('.el-dialog__close').first();
if (await closeBtn.isVisible()) {
await closeBtn.click();
}
}
/**
* Add a credential to the modal
* @param fields - The fields to fill in the modal
* @param options - The options to pass to the modal
* @param options.closeDialog - Whether to close the modal after saving
* @param options.name - The name of the credential
*/
async addCredential(
fields: Record<string, string>,
options?: { closeDialog?: boolean; skipSave?: boolean; name?: string },
): Promise<void> {
await this.fillAllFields(fields);
if (options?.name) {
await this.getCredentialName().click();
const nameInput = this.getNameInput();
await nameInput.fill(options.name);
await nameInput.press('Enter');
await expect(this.getCredentialName()).toContainText(options.name);
}
if (!options?.skipSave) {
await expect(this.getSaveButton()).toBeEnabled();
await this.save();
}
const shouldClose = options?.closeDialog ?? true;
if (shouldClose) {
await this.close();
}
}
get oauthConnectButton() {
return this.root.getByTestId('quick-connect-button');
}
get oauthConnectSuccessBanner() {
return this.root.getByTestId('oauth-connect-success-banner');
}
async editCredential(): Promise<void> {
await this.root.page().getByTestId('credential-edit-button').click();
}
async deleteCredential(): Promise<void> {
await this.root.page().getByTestId('credential-delete-button').click();
}
async confirmDelete(): Promise<void> {
await this.root.page().getByRole('button', { name: 'Yes' }).click();
}
async renameCredential(newName: string): Promise<void> {
await this.getCredentialName().click();
await this.getNameInput().fill(newName);
await this.getNameInput().press('Enter');
}
getOAuthRedirectUrl() {
return this.root.page().getByTestId('oauth-redirect-url');
}
getModeSelector() {
return this.root.getByTestId('credential-mode-selector');
}
getModeDropdownTrigger() {
return this.root.getByTestId('credential-mode-dropdown-trigger');
}
async selectAuthTypeFromDropdown(optionName: string | RegExp): Promise<void> {
await this.getModeDropdownTrigger().click();
await this.root.page().getByRole('menuitem', { name: optionName }).click();
}
async changeTab(tabName: 'Sharing'): Promise<void> {
await this.root.getByTestId('menu-item').filter({ hasText: tabName }).click();
}
/**
* Get a specific credential field input
*/
getFieldInput(key: string): Locator {
return this.root.getByTestId(`parameter-input-${key}`).locator('input, textarea');
}
/**
* Get the users select dropdown in the Sharing tab
*/
getUsersSelect(): Locator {
return this.root.getByTestId('project-sharing-select').filter({ visible: true });
}
/**
* Get the visible dropdown popper (for sharing dropdown interactions)
*/
getVisibleDropdown(): Locator {
return this.root.page().locator('.el-popper[aria-hidden="false"]');
}
/**
* Add a user to credential sharing
* @param emailOrName - User email or name to share with
*/
async addUserToSharing(emailOrName: string): Promise<void> {
await this.getUsersSelect().click();
const dropdown = this.getVisibleDropdown();
// Wait for dropdown content to load
await dropdown.locator('.el-select-dropdown__item').first().waitFor({ state: 'visible' });
// Try to find by email or name (personal projects now show "Personal space" instead of email)
const byEmail = dropdown.getByText(emailOrName.toLowerCase(), { exact: false });
if ((await byEmail.count()) > 0) {
await byEmail.click();
} else {
// For personal projects, try matching by name part of email
const namePart = emailOrName.split('@')[0].replace(/[.-]/g, ' ');
await dropdown.getByText(new RegExp(namePart, 'i')).first().click();
}
}
/**
* Save credential sharing (different from regular save - hits /share endpoint)
*/
async saveSharing(): Promise<void> {
const saveBtn = this.getSaveButton();
await saveBtn.click();
// Wait for share API call to complete
await this.root
.page()
.waitForResponse(
(response) =>
response.url().includes('/rest/credentials/') &&
response.url().includes('/share') &&
response.request().method() === 'PUT',
);
await this.waitForSaveComplete();
}
}