mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
test: Migrate UI tests from Cypress -> Playwright (no-changelog) (#18201)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,3 +36,4 @@ trivy_report*
|
||||
compiled
|
||||
packages/cli/src/modules/my-feature
|
||||
.secrets
|
||||
packages/testing/**/.cursor/rules/
|
||||
|
||||
@@ -3,7 +3,9 @@ import { WorkflowPage, NDV } from '../pages';
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
describe('Schedule Trigger node', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('Schedule Trigger node', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { NDV, WorkflowPage } from '../pages';
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
describe('ADO-2270 Save button resets on webhook node open', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('ADO-2270 Save button resets on webhook node open', () => {
|
||||
it('should not reset the save button if webhook node is opened and closed', () => {
|
||||
workflowPage.actions.visit();
|
||||
workflowPage.actions.addInitialNodeToCanvas(WEBHOOK_NODE_NAME);
|
||||
|
||||
@@ -10,7 +10,9 @@ const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
|
||||
describe('Debug', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('Debug', () => {
|
||||
beforeEach(() => {
|
||||
cy.enableFeature('debugInEditor');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const url = '/settings';
|
||||
|
||||
describe('Admin user', { disableAutoLogin: true }, () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('Admin user', { disableAutoLogin: true }, () => {
|
||||
it('should see same Settings sub menu items as instance owner', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(url);
|
||||
|
||||
@@ -7,7 +7,9 @@ import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
|
||||
|
||||
const WorkflowsPage = new WorkflowsPageClass();
|
||||
|
||||
describe('Become creator CTA', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('Become creator CTA', () => {
|
||||
it('should not show the CTA if user is not eligible', () => {
|
||||
interceptCtaRequestWithResponse(false).as('cta');
|
||||
cy.visit(WorkflowsPage.url);
|
||||
|
||||
@@ -34,7 +34,9 @@ const credentialsModal = new CredentialsModal();
|
||||
const ndv = new NDV();
|
||||
const mainSidebar = new MainSidebar();
|
||||
|
||||
describe('Projects', { disableAutoLogin: true }, () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('Projects', { disableAutoLogin: true }, () => {
|
||||
describe('when starting from scratch', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable n8n-local-rules/no-skipped-tests */
|
||||
import { type ICredentialType } from 'n8n-workflow';
|
||||
|
||||
import { clickCreateNewCredential, openCredentialSelect } from '../composables/ndv';
|
||||
@@ -13,7 +14,8 @@ const credentialsPage = new CredentialsPage();
|
||||
const credentialsModal = new CredentialsModal();
|
||||
const nodeCreatorFeature = new NodeCreator();
|
||||
|
||||
describe('AI Assistant::disabled', () => {
|
||||
// Migrated to Playwright
|
||||
describe.skip('AI Assistant::disabled', () => {
|
||||
beforeEach(() => {
|
||||
aiAssistant.actions.disableAssistant();
|
||||
wf.actions.visit();
|
||||
@@ -34,7 +36,7 @@ describe('AI Assistant::enabled', () => {
|
||||
aiAssistant.actions.disableAssistant();
|
||||
});
|
||||
|
||||
it('renders placeholder UI', () => {
|
||||
it.skip('renders placeholder UI', () => {
|
||||
aiAssistant.getters.askAssistantCanvasActionButton().should('be.visible');
|
||||
aiAssistant.getters.askAssistantCanvasActionButton().click();
|
||||
aiAssistant.getters.askAssistantChat().should('be.visible');
|
||||
@@ -80,7 +82,7 @@ describe('AI Assistant::enabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should start chat session from node error view', () => {
|
||||
it.skip('should start chat session from node error view', () => {
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/simple_message_response.json',
|
||||
@@ -98,7 +100,7 @@ describe('AI Assistant::enabled', () => {
|
||||
aiAssistant.getters.nodeErrorViewAssistantButton().should('be.disabled');
|
||||
});
|
||||
|
||||
it('should render chat input correctly', () => {
|
||||
it.skip('should render chat input correctly', () => {
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/simple_message_response.json',
|
||||
@@ -131,7 +133,7 @@ describe('AI Assistant::enabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should render and handle quick replies', () => {
|
||||
it.skip('should render and handle quick replies', () => {
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/quick_reply_message_response.json',
|
||||
@@ -148,7 +150,7 @@ describe('AI Assistant::enabled', () => {
|
||||
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
|
||||
});
|
||||
|
||||
it('should warn before starting a new session', () => {
|
||||
it.skip('should warn before starting a new session', () => {
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/simple_message_response.json',
|
||||
@@ -273,7 +275,7 @@ describe('AI Assistant::enabled', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should end chat session when `end_session` event is received', () => {
|
||||
it.skip('should end chat session when `end_session` event is received', () => {
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/end_session_response.json',
|
||||
|
||||
@@ -2,7 +2,9 @@ import { WorkflowsPage } from '../pages';
|
||||
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
|
||||
describe('n8n.io iframe', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('n8n.io iframe', () => {
|
||||
describe('when telemetry is disabled', () => {
|
||||
it('should not load the iframe when visiting /home/workflows', () => {
|
||||
cy.overrideSettings({ telemetry: { enabled: false } });
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
} from '../composables/workflow';
|
||||
import { AGENT_NODE_NAME, AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME } from '../constants';
|
||||
|
||||
describe('AI-716 Correctly set up agent model shows error', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('AI-716 Correctly set up agent model shows error', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(getCredentialsPageUrl());
|
||||
createNewCredential('OpenAi', 'OpenAI Account', 'API Key', 'sk-123', true);
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
} from '../composables/workflow';
|
||||
import Workflow from '../fixtures/Test_9999-SUG-38.json';
|
||||
|
||||
describe('SUG-38 Inline expression previews are not displayed in NDV', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('SUG-38 Inline expression previews are not displayed in NDV', () => {
|
||||
it("should show resolved inline expression preview in NDV if the node's input data is populated", () => {
|
||||
navigateToNewWorkflowPage();
|
||||
pasteWorkflow(Workflow);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
describe('Environment Feature Flags', () => {
|
||||
// Migrated to Playwright
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('Environment Feature Flags', () => {
|
||||
it('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', () => {
|
||||
cy.setEnvFeatureFlags({
|
||||
N8N_ENV_FEAT_TEST: true,
|
||||
|
||||
@@ -1,6 +1,152 @@
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
|
||||
export class TestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TestError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test requirements for Playwright tests.
|
||||
*
|
||||
* This interface allows you to declaratively specify all test setup requirements
|
||||
* in one place, making tests more readable and maintainable.
|
||||
* If a workflow is specified, the starting point for the test is now the canvas after the workflow is imported.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const requirements: TestRequirements = {
|
||||
* config: {
|
||||
* features: {
|
||||
* aiAssistant: true,
|
||||
* debugInEditor: true,
|
||||
* sharing: true
|
||||
* },
|
||||
* settings: { telemetry: { enabled: false } }
|
||||
* },
|
||||
* workflow: {
|
||||
* 'ai_assistant_test_workflow.json': 'AI Assistant Test Workflow'
|
||||
* },
|
||||
* intercepts: {
|
||||
* 'ai-chat': {
|
||||
* url: '*\/rest/ai/chat',
|
||||
* response: { sessionId: '1', messages: [] }
|
||||
* }
|
||||
* },
|
||||
* storage: {
|
||||
* 'n8n-telemetry': '{"enabled": true}'
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export interface TestRequirements {
|
||||
/**
|
||||
* Configuration settings for the test environment
|
||||
*/
|
||||
config?: {
|
||||
/** Frontend settings to override (merged with default settings) */
|
||||
settings?: Partial<FrontendSettings>;
|
||||
|
||||
/** Feature flags to enable/disable for the test */
|
||||
features?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* API route intercepts and their mock responses
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* intercepts: {
|
||||
* 'ai-chat': {
|
||||
* url: '*\/rest/ai/chat',
|
||||
* response: {
|
||||
* sessionId: '1',
|
||||
* messages: [{ role: 'assistant', type: 'message', text: 'Hello!' }]
|
||||
* }
|
||||
* },
|
||||
* 'become-creator': {
|
||||
* url: '*\/rest/cta/become-creator',
|
||||
* response: true
|
||||
* },
|
||||
* 'credentials-test': {
|
||||
* url: '*\/rest/credentials/test',
|
||||
* response: { data: { status: 'success', message: 'Tested successfully' } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
intercepts?: Record<string, InterceptConfig>;
|
||||
|
||||
/**
|
||||
* Single workflow to import for the test
|
||||
*
|
||||
* Key: Import file location (relative to workflows folder)
|
||||
* Value: Name to give the workflow when imported
|
||||
*
|
||||
* Note: Only one workflow is supported. Multiple workflows will throw an error.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* workflow: {
|
||||
* 'ai_assistant_test_workflow.json': 'AI Assistant Test Workflow'
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
workflow?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Browser storage values to set before the test
|
||||
*
|
||||
* Supports localStorage, sessionStorage, and other browser storage APIs
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* storage: {
|
||||
* 'n8n-telemetry': '{"enabled": true}',
|
||||
* 'n8n-instance-id': 'test-instance-id'
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
storage?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for API route interception in Playwright
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* url: '*\/rest/ai/chat',
|
||||
* response: { sessionId: '1', messages: [] },
|
||||
* status: 200
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example Network error simulation
|
||||
* ```typescript
|
||||
* {
|
||||
* url: '*\/rest/credentials/test',
|
||||
* forceNetworkError: true
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface InterceptConfig {
|
||||
/** URL pattern to intercept (supports wildcards) */
|
||||
url: string;
|
||||
|
||||
/** Mock response data */
|
||||
response?: unknown;
|
||||
|
||||
/** HTTP status code to return (default: 200) */
|
||||
status?: number;
|
||||
|
||||
/** HTTP headers to return */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** Content type for the response (default: 'application/json') */
|
||||
contentType?: string;
|
||||
|
||||
/** Force network error instead of mock response */
|
||||
forceNetworkError?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const getSuggestedActionsButton = (page: Page) => page.getByTestId('suggested-action-count');
|
||||
export const getSuggestedActionItem = (page: Page, text?: string) => {
|
||||
const items = page.getByTestId('suggested-action-item');
|
||||
if (text) {
|
||||
return items.getByText(text);
|
||||
}
|
||||
return items;
|
||||
};
|
||||
export const getSuggestedActionsPopover = (page: Page) =>
|
||||
page.locator('[data-reka-popper-content-wrapper=""]').filter({ hasText: /./ });
|
||||
|
||||
export const getErrorActionItem = (page: Page) =>
|
||||
getSuggestedActionItem(page, 'Set up error notifications');
|
||||
|
||||
export const getTimeSavedActionItem = (page: Page) =>
|
||||
getSuggestedActionItem(page, 'Track time saved');
|
||||
|
||||
export const getEvaluationsActionItem = (page: Page) =>
|
||||
getSuggestedActionItem(page, 'Test reliability of AI steps');
|
||||
|
||||
export const getIgnoreAllButton = (page: Page) => page.getByTestId('suggested-action-ignore-all');
|
||||
@@ -1,14 +0,0 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const getActivationModal = (page: Page) => page.getByTestId('activation-modal');
|
||||
|
||||
export const closeActivationModal = async (page: Page) => {
|
||||
await expect(getActivationModal(page)).toBeVisible();
|
||||
|
||||
// click checkbox so it does not show again
|
||||
await getActivationModal(page).getByText("Don't show again").click();
|
||||
|
||||
// confirm modal
|
||||
await getActivationModal(page).getByRole('button', { name: 'Got it' }).click();
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { n8nPage } from '../pages/n8nPage';
|
||||
|
||||
/**
|
||||
@@ -47,7 +49,7 @@ export class WorkflowComposer {
|
||||
fileName: string,
|
||||
name?: string,
|
||||
): Promise<{ workflowName: string }> {
|
||||
const workflowName = name ?? `Imported Workflow ${Date.now()}`;
|
||||
const workflowName = name ?? `Imported Workflow ${nanoid(8)}`;
|
||||
await this.n8n.goHome();
|
||||
await this.n8n.workflows.clickAddWorkflowButton();
|
||||
await this.n8n.canvas.importWorkflow(fileName, workflowName);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Helper to open workflow settings modal
|
||||
export const openWorkflowSettings = async (page: Page) => {
|
||||
await page.getByTestId('workflow-menu').click();
|
||||
await page.getByTestId('workflow-menu-item-settings').click();
|
||||
await expect(page.getByTestId('workflow-settings-dialog')).toBeVisible();
|
||||
};
|
||||
@@ -1,18 +1,36 @@
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
import type { BrowserContext, Route } from '@playwright/test';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import merge from 'lodash/merge';
|
||||
|
||||
export let settings: Partial<FrontendSettings>;
|
||||
const contextSettings = new Map<BrowserContext, Partial<Record<string, unknown>>>();
|
||||
|
||||
export function setContextSettings(
|
||||
context: BrowserContext,
|
||||
settings: Partial<Record<string, unknown>>,
|
||||
) {
|
||||
contextSettings.set(context, settings);
|
||||
}
|
||||
|
||||
export function getContextSettings(context: BrowserContext) {
|
||||
return contextSettings.get(context);
|
||||
}
|
||||
|
||||
export async function setupDefaultInterceptors(target: BrowserContext) {
|
||||
// Global /rest/settings intercept - always active like Cypress
|
||||
await target.route('**/rest/settings', async (route: Route) => {
|
||||
try {
|
||||
const originalResponse = await route.fetch();
|
||||
const originalJson = await originalResponse.json();
|
||||
|
||||
// Get settings stored for this specific context
|
||||
const testSettings = getContextSettings(target);
|
||||
|
||||
// Deep merge test settings with backend settings (like Cypress)
|
||||
const modifiedData = {
|
||||
data: merge(cloneDeep(originalJson.data), settings),
|
||||
data:
|
||||
testSettings && Object.keys(testSettings).length > 0
|
||||
? merge(cloneDeep(originalJson.data), testSettings)
|
||||
: originalJson.data,
|
||||
};
|
||||
|
||||
await route.fulfill({
|
||||
|
||||
@@ -5,7 +5,7 @@ export default [
|
||||
...baseConfig,
|
||||
playwrightPlugin.configs['flat/recommended'],
|
||||
{
|
||||
ignores: ['playwright-report/**/*'],
|
||||
ignores: ['playwright-report/**/*', 'ms-playwright-cache/**/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
@@ -22,6 +22,43 @@ export default [
|
||||
'playwright/max-nested-describe': 'warn',
|
||||
'playwright/no-conditional-in-test': 'error',
|
||||
'playwright/no-skipped-test': 'warn',
|
||||
// Allow any naming convention for TestRequirements object properties
|
||||
// This is specifically for workflow filenames and intercept keys that may not follow camelCase
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'default',
|
||||
format: ['camelCase'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['camelCase', 'UPPER_CASE'],
|
||||
},
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
{
|
||||
selector: 'property',
|
||||
format: ['camelCase', 'snake_case', 'UPPER_CASE'],
|
||||
filter: {
|
||||
// Allow any format for properties in TestRequirements objects (workflow files, intercept keys, etc.)
|
||||
regex: '^(workflow|intercepts|storage|config)$',
|
||||
match: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'objectLiteralProperty',
|
||||
format: null, // Allow any format for object literal properties in TestRequirements
|
||||
filter: {
|
||||
// This allows workflow filenames and intercept keys to use any naming convention
|
||||
regex: '\\.(json|spec\\.ts)$|[a-zA-Z0-9_-]+',
|
||||
match: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'import-x/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test as base, expect, type TestInfo } from '@playwright/test';
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import type { N8NStack } from 'n8n-containers/n8n-test-container-creation';
|
||||
import { createN8NStack } from 'n8n-containers/n8n-test-container-creation';
|
||||
import { ContainerTestHelpers } from 'n8n-containers/n8n-test-container-helpers';
|
||||
@@ -7,12 +7,14 @@ import { setTimeout as wait } from 'node:timers/promises';
|
||||
import { setupDefaultInterceptors } from '../config/intercepts';
|
||||
import { n8nPage } from '../pages/n8nPage';
|
||||
import { ApiHelpers } from '../services/api-helper';
|
||||
import { TestError } from '../Types';
|
||||
import { TestError, type TestRequirements } from '../Types';
|
||||
import { setupTestRequirements } from '../utils/requirements';
|
||||
|
||||
type TestFixtures = {
|
||||
n8n: n8nPage;
|
||||
api: ApiHelpers;
|
||||
baseURL: string;
|
||||
setupRequirements: (requirements: TestRequirements) => Promise<void>;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
@@ -40,8 +42,10 @@ interface ContainerConfig {
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
// Container configuration from the project use options
|
||||
containerConfig: [
|
||||
async ({}, use, testInfo: TestInfo) => {
|
||||
const config = (testInfo.project.use?.containerConfig as ContainerConfig) || {};
|
||||
async ({}, use, workerInfo) => {
|
||||
const config =
|
||||
(workerInfo.project.use as unknown as { containerConfig?: ContainerConfig })
|
||||
?.containerConfig ?? {};
|
||||
config.env = {
|
||||
...config.env,
|
||||
E2E_TESTS: 'true',
|
||||
@@ -122,6 +126,11 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
// Browser, baseURL, and dbSetup are required here to ensure they run first.
|
||||
// This is how Playwright does dependency graphs
|
||||
context: async ({ context, browser, baseURL, dbSetup }, use) => {
|
||||
// Dependencies: browser, baseURL, dbSetup (ensure they run first)
|
||||
void browser;
|
||||
void baseURL;
|
||||
void dbSetup;
|
||||
|
||||
await setupDefaultInterceptors(context);
|
||||
await use(context);
|
||||
},
|
||||
@@ -145,6 +154,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
const api = new ApiHelpers(context.request);
|
||||
await use(api);
|
||||
},
|
||||
|
||||
setupRequirements: async ({ page, context }, use) => {
|
||||
const setupFunction = async (requirements: TestRequirements): Promise<void> => {
|
||||
await setupTestRequirements(page, context, requirements);
|
||||
};
|
||||
|
||||
await use(setupFunction);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"eslint-plugin-playwright": "2.2.2",
|
||||
"generate-schema": "2.6.0",
|
||||
"n8n-containers": "workspace:*",
|
||||
"tsx": "catalog:"
|
||||
"nanoid": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"@n8n/api-types": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
66
packages/testing/playwright/pages/AIAssistantPage.ts
Normal file
66
packages/testing/playwright/pages/AIAssistantPage.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class AIAssistantPage extends BasePage {
|
||||
getAskAssistantFloatingButton() {
|
||||
return this.page.getByTestId('ask-assistant-floating-button');
|
||||
}
|
||||
|
||||
getAskAssistantCanvasActionButton() {
|
||||
return this.page.getByTestId('ask-assistant-canvas-action-button');
|
||||
}
|
||||
|
||||
getAskAssistantChat() {
|
||||
return this.page.getByTestId('ask-assistant-chat');
|
||||
}
|
||||
|
||||
getPlaceholderMessage() {
|
||||
return this.page.getByTestId('placeholder-message');
|
||||
}
|
||||
|
||||
getChatInput() {
|
||||
return this.page.getByTestId('chat-input');
|
||||
}
|
||||
|
||||
getSendMessageButton() {
|
||||
return this.page.getByTestId('send-message-button');
|
||||
}
|
||||
|
||||
getCloseChatButton() {
|
||||
return this.page.getByTestId('close-chat-button');
|
||||
}
|
||||
|
||||
getAskAssistantSidebarResizer() {
|
||||
return this.page
|
||||
.getByTestId('ask-assistant-sidebar')
|
||||
.locator('[class*="_resizer"][data-dir="left"]')
|
||||
.first();
|
||||
}
|
||||
|
||||
getNodeErrorViewAssistantButton() {
|
||||
return this.page.getByTestId('node-error-view-ask-assistant-button').locator('button').first();
|
||||
}
|
||||
|
||||
getChatMessagesAll() {
|
||||
return this.page.locator('[data-test-id^="chat-message"]');
|
||||
}
|
||||
|
||||
getChatMessagesAssistant() {
|
||||
return this.page.getByTestId('chat-message-assistant');
|
||||
}
|
||||
|
||||
getChatMessagesUser() {
|
||||
return this.page.getByTestId('chat-message-user');
|
||||
}
|
||||
|
||||
getChatMessagesSystem() {
|
||||
return this.page.getByTestId('chat-message-system');
|
||||
}
|
||||
|
||||
getQuickReplyButtons() {
|
||||
return this.page.getByTestId('quick-replies').locator('button');
|
||||
}
|
||||
|
||||
getNewAssistantSessionModal() {
|
||||
return this.page.getByTestId('new-assistant-session-modal');
|
||||
}
|
||||
}
|
||||
15
packages/testing/playwright/pages/BecomeCreatorCTAPage.ts
Normal file
15
packages/testing/playwright/pages/BecomeCreatorCTAPage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class BecomeCreatorCTAPage extends BasePage {
|
||||
getBecomeTemplateCreatorCta() {
|
||||
return this.page.getByTestId('become-template-creator-cta');
|
||||
}
|
||||
|
||||
getCloseBecomeTemplateCreatorCtaButton() {
|
||||
return this.page.getByTestId('close-become-template-creator-cta');
|
||||
}
|
||||
|
||||
async closeBecomeTemplateCreatorCta() {
|
||||
await this.getCloseBecomeTemplateCreatorCtaButton().click();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { BasePage } from './BasePage';
|
||||
import { resolveFromRoot } from '../utils/path-helper';
|
||||
@@ -8,6 +9,10 @@ export class CanvasPage extends BasePage {
|
||||
return this.page.getByRole('button', { name: 'Save' });
|
||||
}
|
||||
|
||||
workflowSaveButton(): Locator {
|
||||
return this.page.getByTestId('workflow-save-button');
|
||||
}
|
||||
|
||||
canvasAddButton(): Locator {
|
||||
return this.page.getByTestId('canvas-add-button');
|
||||
}
|
||||
@@ -132,7 +137,6 @@ export class CanvasPage extends BasePage {
|
||||
async clickExecutionsTab(): Promise<void> {
|
||||
await this.page.getByRole('radio', { name: 'Executions' }).click();
|
||||
}
|
||||
|
||||
async setWorkflowName(name: string): Promise<void> {
|
||||
await this.clickByTestId('inline-edit-preview');
|
||||
await this.fillByTestId('inline-edit-input', name);
|
||||
@@ -161,7 +165,6 @@ export class CanvasPage extends BasePage {
|
||||
getWorkflowTags() {
|
||||
return this.page.getByTestId('workflow-tags').locator('.el-tag');
|
||||
}
|
||||
|
||||
async activateWorkflow() {
|
||||
const responsePromise = this.page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -170,4 +173,90 @@ export class CanvasPage extends BasePage {
|
||||
await this.page.getByTestId('workflow-activate-switch').click();
|
||||
await responsePromise;
|
||||
}
|
||||
|
||||
async clickZoomToFitButton(): Promise<void> {
|
||||
await this.clickByTestId('zoom-to-fit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node issues for a specific node
|
||||
*/
|
||||
getNodeIssuesByName(nodeName: string) {
|
||||
return this.nodeByName(nodeName).getByTestId('node-issues');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags to the workflow
|
||||
* @param count - The number of tags to add
|
||||
* @returns An array of tag names
|
||||
*/
|
||||
async addTags(count: number = 1): Promise<string[]> {
|
||||
const tags: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tag = `tag-${nanoid(8)}-${i}`;
|
||||
tags.push(tag);
|
||||
|
||||
if (i === 0) {
|
||||
await this.clickByText('Add tag');
|
||||
} else {
|
||||
await this.page
|
||||
.getByTestId('tags-dropdown')
|
||||
.getByText(tags[i - 1])
|
||||
.click();
|
||||
}
|
||||
|
||||
await this.page.getByRole('combobox').first().fill(tag);
|
||||
await this.page.getByRole('combobox').first().press('Enter');
|
||||
}
|
||||
|
||||
await this.page.click('body');
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
// Production Checklist methods
|
||||
getProductionChecklistButton(): Locator {
|
||||
return this.page.getByTestId('suggested-action-count');
|
||||
}
|
||||
|
||||
getProductionChecklistPopover(): Locator {
|
||||
return this.page.locator('[data-reka-popper-content-wrapper=""]').filter({ hasText: /./ });
|
||||
}
|
||||
|
||||
getProductionChecklistActionItem(text?: string): Locator {
|
||||
const items = this.page.getByTestId('suggested-action-item');
|
||||
if (text) {
|
||||
return items.getByText(text);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
getProductionChecklistIgnoreAllButton(): Locator {
|
||||
return this.page.getByTestId('suggested-action-ignore-all');
|
||||
}
|
||||
|
||||
getErrorActionItem(): Locator {
|
||||
return this.getProductionChecklistActionItem('Set up error notifications');
|
||||
}
|
||||
|
||||
getTimeSavedActionItem(): Locator {
|
||||
return this.getProductionChecklistActionItem('Track time saved');
|
||||
}
|
||||
|
||||
getEvaluationsActionItem(): Locator {
|
||||
return this.getProductionChecklistActionItem('Test reliability of AI steps');
|
||||
}
|
||||
|
||||
async clickProductionChecklistButton(): Promise<void> {
|
||||
await this.getProductionChecklistButton().click();
|
||||
}
|
||||
|
||||
async clickProductionChecklistIgnoreAll(): Promise<void> {
|
||||
await this.getProductionChecklistIgnoreAllButton().click();
|
||||
}
|
||||
|
||||
async clickProductionChecklistAction(actionText: string): Promise<void> {
|
||||
await this.getProductionChecklistActionItem(actionText).click();
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/testing/playwright/pages/IframePage.ts
Normal file
15
packages/testing/playwright/pages/IframePage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class IframePage extends BasePage {
|
||||
getIframe() {
|
||||
return this.page.locator('iframe');
|
||||
}
|
||||
|
||||
getIframeBySrc(src: string) {
|
||||
return this.page.locator(`iframe[src="${src}"]`);
|
||||
}
|
||||
|
||||
async waitForIframeRequest(url: string) {
|
||||
await this.page.waitForResponse(url);
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,16 @@ export class NodeDisplayViewPage extends BasePage {
|
||||
async close() {
|
||||
await this.clickBackToCanvasButton();
|
||||
}
|
||||
|
||||
async execute() {
|
||||
await this.clickByTestId('node-execute-button');
|
||||
}
|
||||
|
||||
getOutputPanel() {
|
||||
return this.page.getByTestId('output-panel');
|
||||
}
|
||||
|
||||
getParameterExpressionPreviewValue() {
|
||||
return this.page.getByTestId('parameter-expression-preview-value');
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/testing/playwright/pages/SettingsPage.ts
Normal file
15
packages/testing/playwright/pages/SettingsPage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class SettingsPage extends BasePage {
|
||||
getMenuItems() {
|
||||
return this.page.getByTestId('menu-item');
|
||||
}
|
||||
|
||||
getMenuItem(id: string) {
|
||||
return this.page.getByTestId('menu-item').getByTestId(id);
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.page.goto('/settings');
|
||||
}
|
||||
}
|
||||
35
packages/testing/playwright/pages/VersionsPage.ts
Normal file
35
packages/testing/playwright/pages/VersionsPage.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class VersionsPage extends BasePage {
|
||||
getVersionUpdatesPanelOpenButton() {
|
||||
return this.page.getByTestId('version-update-next-versions-link');
|
||||
}
|
||||
|
||||
getVersionUpdatesPanel() {
|
||||
return this.page.getByTestId('version-updates-panel');
|
||||
}
|
||||
|
||||
getVersionUpdatesPanelCloseButton() {
|
||||
return this.getVersionUpdatesPanel().getByRole('button', { name: 'Close' });
|
||||
}
|
||||
|
||||
getVersionCard() {
|
||||
return this.page.getByTestId('version-card');
|
||||
}
|
||||
|
||||
getWhatsNewMenuItem() {
|
||||
return this.page.getByTestId('menu-item').getByTestId('whats-new');
|
||||
}
|
||||
|
||||
async openWhatsNewMenu() {
|
||||
await this.getWhatsNewMenuItem().click();
|
||||
}
|
||||
|
||||
async openVersionUpdatesPanel() {
|
||||
await this.getVersionUpdatesPanelOpenButton().click();
|
||||
}
|
||||
|
||||
async closeVersionUpdatesPanel() {
|
||||
await this.getVersionUpdatesPanelCloseButton().click();
|
||||
}
|
||||
}
|
||||
31
packages/testing/playwright/pages/WorkflowActivationModal.ts
Normal file
31
packages/testing/playwright/pages/WorkflowActivationModal.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class WorkflowActivationModal extends BasePage {
|
||||
getModal(): Locator {
|
||||
return this.page.getByTestId('activation-modal');
|
||||
}
|
||||
|
||||
getDontShowAgainCheckbox(): Locator {
|
||||
return this.getModal().getByText("Don't show again");
|
||||
}
|
||||
|
||||
getGotItButton(): Locator {
|
||||
return this.getModal().getByRole('button', { name: 'Got it' });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.getDontShowAgainCheckbox().click();
|
||||
|
||||
await this.getGotItButton().click();
|
||||
}
|
||||
|
||||
async clickDontShowAgain(): Promise<void> {
|
||||
await this.getDontShowAgainCheckbox().click();
|
||||
}
|
||||
|
||||
async clickGotIt(): Promise<void> {
|
||||
await this.getGotItButton().click();
|
||||
}
|
||||
}
|
||||
39
packages/testing/playwright/pages/WorkflowSettingsModal.ts
Normal file
39
packages/testing/playwright/pages/WorkflowSettingsModal.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class WorkflowSettingsModal extends BasePage {
|
||||
getModal(): Locator {
|
||||
return this.page.getByTestId('workflow-settings-dialog');
|
||||
}
|
||||
|
||||
getWorkflowMenu(): Locator {
|
||||
return this.page.getByTestId('workflow-menu');
|
||||
}
|
||||
|
||||
getSettingsMenuItem(): Locator {
|
||||
return this.page.getByTestId('workflow-menu-item-settings');
|
||||
}
|
||||
|
||||
getErrorWorkflowField(): Locator {
|
||||
return this.page.getByTestId('workflow-settings-error-workflow');
|
||||
}
|
||||
|
||||
getSaveButton(): Locator {
|
||||
return this.page.getByRole('button', { name: 'Save' });
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.getWorkflowMenu().click();
|
||||
await this.getSettingsMenuItem().click();
|
||||
}
|
||||
|
||||
async clickSave(): Promise<void> {
|
||||
await this.getSaveButton().click();
|
||||
}
|
||||
|
||||
async selectErrorWorkflow(workflowName: string): Promise<void> {
|
||||
await this.getErrorWorkflowField().click();
|
||||
await this.page.getByRole('option', { name: workflowName }).first().click();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { AIAssistantPage } from './AIAssistantPage';
|
||||
import { BecomeCreatorCTAPage } from './BecomeCreatorCTAPage';
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
import { CredentialsPage } from './CredentialsPage';
|
||||
import { ExecutionsPage } from './ExecutionsPage';
|
||||
import { IframePage } from './IframePage';
|
||||
import { NodeDisplayViewPage } from './NodeDisplayViewPage';
|
||||
import { NotificationsPage } from './NotificationsPage';
|
||||
import { ProjectSettingsPage } from './ProjectSettingsPage';
|
||||
import { SettingsPage } from './SettingsPage';
|
||||
import { SidebarPage } from './SidebarPage';
|
||||
import { VersionsPage } from './VersionsPage';
|
||||
import { WorkflowActivationModal } from './WorkflowActivationModal';
|
||||
import { WorkflowSettingsModal } from './WorkflowSettingsModal';
|
||||
import { WorkflowSharingModal } from './WorkflowSharingModal';
|
||||
import { WorkflowsPage } from './WorkflowsPage';
|
||||
import { CanvasComposer } from '../composables/CanvasComposer';
|
||||
@@ -18,18 +25,28 @@ export class n8nPage {
|
||||
readonly page: Page;
|
||||
|
||||
// Pages
|
||||
readonly aiAssistant: AIAssistantPage;
|
||||
readonly becomeCreatorCTA: BecomeCreatorCTAPage;
|
||||
readonly canvas: CanvasPage;
|
||||
|
||||
readonly iframe: IframePage;
|
||||
readonly ndv: NodeDisplayViewPage;
|
||||
readonly projectSettings: ProjectSettingsPage;
|
||||
readonly settings: SettingsPage;
|
||||
readonly versions: VersionsPage;
|
||||
readonly workflows: WorkflowsPage;
|
||||
readonly notifications: NotificationsPage;
|
||||
readonly credentials: CredentialsPage;
|
||||
readonly executions: ExecutionsPage;
|
||||
readonly sideBar: SidebarPage;
|
||||
|
||||
// Modals
|
||||
readonly workflowActivationModal: WorkflowActivationModal;
|
||||
readonly workflowSettingsModal: WorkflowSettingsModal;
|
||||
readonly workflowSharingModal: WorkflowSharingModal;
|
||||
|
||||
// Composables
|
||||
readonly workflowComposer: WorkflowComposer;
|
||||
readonly workflowSharingModal: WorkflowSharingModal;
|
||||
readonly projectComposer: ProjectComposer;
|
||||
readonly canvasComposer: CanvasComposer;
|
||||
|
||||
@@ -37,9 +54,15 @@ export class n8nPage {
|
||||
this.page = page;
|
||||
|
||||
// Pages
|
||||
this.aiAssistant = new AIAssistantPage(page);
|
||||
this.becomeCreatorCTA = new BecomeCreatorCTAPage(page);
|
||||
this.canvas = new CanvasPage(page);
|
||||
|
||||
this.iframe = new IframePage(page);
|
||||
this.ndv = new NodeDisplayViewPage(page);
|
||||
this.projectSettings = new ProjectSettingsPage(page);
|
||||
this.settings = new SettingsPage(page);
|
||||
this.versions = new VersionsPage(page);
|
||||
this.workflows = new WorkflowsPage(page);
|
||||
this.notifications = new NotificationsPage(page);
|
||||
this.credentials = new CredentialsPage(page);
|
||||
@@ -47,6 +70,10 @@ export class n8nPage {
|
||||
this.sideBar = new SidebarPage(page);
|
||||
this.workflowSharingModal = new WorkflowSharingModal(page);
|
||||
|
||||
// Modals
|
||||
this.workflowActivationModal = new WorkflowActivationModal(page);
|
||||
this.workflowSettingsModal = new WorkflowSettingsModal(page);
|
||||
|
||||
// Composables
|
||||
this.workflowComposer = new WorkflowComposer(this);
|
||||
this.projectComposer = new ProjectComposer(this);
|
||||
|
||||
@@ -144,6 +144,41 @@ export class ApiHelpers {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== FEATURE FLAG METHODS =====
|
||||
|
||||
async setEnvFeatureFlags(flags: Record<string, string>): Promise<{
|
||||
data: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
flags: Record<string, string>;
|
||||
};
|
||||
}> {
|
||||
const response = await this.request.patch('/rest/e2e/env-feature-flags', {
|
||||
data: { flags },
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async clearEnvFeatureFlags(): Promise<{
|
||||
data: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
flags: Record<string, string>;
|
||||
};
|
||||
}> {
|
||||
const response = await this.request.patch('/rest/e2e/env-feature-flags', {
|
||||
data: { flags: {} },
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getEnvFeatureFlags(): Promise<{
|
||||
data: Record<string, string>;
|
||||
}> {
|
||||
const response = await this.request.get('/rest/e2e/env-feature-flags');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== CONVENIENCE METHODS =====
|
||||
|
||||
async enableFeature(feature: string): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
// @ts-expect-error - 'generate-schema' is not typed, so we ignore the TS error.
|
||||
import GenerateSchema from 'generate-schema';
|
||||
import generateSchema from 'generate-schema';
|
||||
import * as path from 'path';
|
||||
|
||||
import { findPackagesRoot } from '../../utils/path-helper';
|
||||
@@ -136,7 +136,7 @@ test.describe('Workflow Tests', () => {
|
||||
|
||||
// Optionally, validate the output against a JSON schema snapshot if enabled.
|
||||
if (SCHEMA_MODE && result.data && workflow.enableSchemaValidation) {
|
||||
const schema = GenerateSchema.json(result.data);
|
||||
const schema = generateSchema.json(result.data);
|
||||
expect(JSON.stringify(schema, null, 2)).toMatchSnapshot(
|
||||
`workflow-${workflow.id}-schema.snap`,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
const NOTIFICATIONS = {
|
||||
@@ -18,7 +20,9 @@ test.describe('Workflows', () => {
|
||||
await expect(n8n.canvas.getWorkflowTags()).toHaveText(['some-tag-1', 'some-tag-2']);
|
||||
});
|
||||
|
||||
test('should create a new workflow using add workflow button', async ({ n8n }) => {
|
||||
test('should create a new workflow using add workflow button and save successfully', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
await n8n.workflows.clickAddWorkflowButton();
|
||||
|
||||
const workflowName = `Test Workflow ${Date.now()}`;
|
||||
@@ -31,9 +35,9 @@ test.describe('Workflows', () => {
|
||||
});
|
||||
|
||||
test('should search for workflows', async ({ n8n }) => {
|
||||
const date = Date.now();
|
||||
const specificName = `Specific Test ${date}`;
|
||||
const genericName = `Generic Test ${date}`;
|
||||
const uniqueId = nanoid(8);
|
||||
const specificName = `Specific Test ${uniqueId}`;
|
||||
const genericName = `Generic Test ${uniqueId}`;
|
||||
|
||||
await n8n.workflowComposer.createWorkflow(specificName);
|
||||
await n8n.goHome();
|
||||
@@ -47,7 +51,7 @@ test.describe('Workflows', () => {
|
||||
|
||||
// Search with partial term
|
||||
await n8n.workflows.clearSearch();
|
||||
await n8n.workflows.searchWorkflows(date.toString());
|
||||
await n8n.workflows.searchWorkflows(uniqueId);
|
||||
await expect(n8n.workflows.getWorkflowItems()).toHaveCount(2);
|
||||
|
||||
// Search for non-existent
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test.describe('Schedule Trigger node', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
});
|
||||
|
||||
test('should execute schedule trigger node and return timestamp in output', async ({ n8n }) => {
|
||||
await n8n.workflows.clickAddWorkflowButton();
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
||||
await expect(n8n.ndv.getOutputPanel()).toContainText('timestamp');
|
||||
|
||||
await n8n.ndv.clickBackToCanvasButton();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test.describe('ADO-2270 Save button resets on webhook node open', () => {
|
||||
test('should not reset the save button if webhook node is opened and closed', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
|
||||
await n8n.workflows.clickAddWorkflowButton();
|
||||
await n8n.canvas.addNode('Webhook');
|
||||
|
||||
await n8n.page.keyboard.press('Escape');
|
||||
|
||||
await n8n.canvas.clickSaveWorkflowButton();
|
||||
|
||||
await n8n.canvas.openNode('Webhook');
|
||||
|
||||
await n8n.ndv.clickBackToCanvasButton();
|
||||
|
||||
await expect(n8n.canvas.workflowSaveButton()).toContainText('Saved');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test.describe('Admin user', () => {
|
||||
test('should see same Settings sub menu items as instance owner', async ({ n8n, api }) => {
|
||||
await api.setupTest('signin-only', 'owner');
|
||||
await n8n.settings.goToSettings();
|
||||
|
||||
const ownerMenuItems = await n8n.settings.getMenuItems().count();
|
||||
|
||||
await api.setupTest('signin-only', 'admin');
|
||||
await n8n.settings.goToSettings();
|
||||
|
||||
await expect(n8n.settings.getMenuItems()).toHaveCount(ownerMenuItems);
|
||||
});
|
||||
});
|
||||
276
packages/testing/playwright/tests/ui/45-ai-assistant.spec.ts
Normal file
276
packages/testing/playwright/tests/ui/45-ai-assistant.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import type { TestRequirements } from '../../Types';
|
||||
|
||||
const aiDisabledRequirements: TestRequirements = {
|
||||
config: {
|
||||
features: { aiAssistant: false },
|
||||
},
|
||||
};
|
||||
|
||||
const aiEnabledRequirements: TestRequirements = {
|
||||
config: {
|
||||
features: { aiAssistant: true },
|
||||
},
|
||||
};
|
||||
|
||||
const aiEnabledWithWorkflowRequirements: TestRequirements = {
|
||||
config: {
|
||||
features: { aiAssistant: true },
|
||||
},
|
||||
workflow: {
|
||||
'ai_assistant_test_workflow.json': 'AI_Assistant_Test_Workflow',
|
||||
},
|
||||
intercepts: {
|
||||
aiChat: {
|
||||
url: '**/rest/ai/chat',
|
||||
response: {
|
||||
sessionId: '1',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'Hey, this is an assistant message',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const aiEnabledWithQuickRepliesRequirements: TestRequirements = {
|
||||
config: {
|
||||
features: { aiAssistant: true },
|
||||
},
|
||||
workflow: {
|
||||
'ai_assistant_test_workflow.json': 'AI_Assistant_Test_Workflow',
|
||||
},
|
||||
intercepts: {
|
||||
aiChat: {
|
||||
url: '**/rest/ai/chat',
|
||||
response: {
|
||||
sessionId: '1',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'Hey, this is an assistant message',
|
||||
quickReplies: [
|
||||
{
|
||||
text: "Sure, let's do it",
|
||||
type: 'yes',
|
||||
},
|
||||
{
|
||||
text: "Nah, doesn't sound good",
|
||||
type: 'no',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const aiEnabledWithEndSessionRequirements: TestRequirements = {
|
||||
config: {
|
||||
features: { aiAssistant: true },
|
||||
},
|
||||
workflow: {
|
||||
'ai_assistant_test_workflow.json': 'AI_Assistant_Test_Workflow',
|
||||
},
|
||||
intercepts: {
|
||||
aiChat: {
|
||||
url: '**/rest/ai/chat',
|
||||
response: {
|
||||
sessionId: '1',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
title: 'Glad to Help',
|
||||
text: "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'event',
|
||||
eventName: 'end-session',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('AI Assistant::disabled', () => {
|
||||
test('does not show assistant button if feature is disabled', async ({
|
||||
n8n,
|
||||
setupRequirements,
|
||||
}) => {
|
||||
await setupRequirements(aiDisabledRequirements);
|
||||
await expect(n8n.aiAssistant.getAskAssistantFloatingButton()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('AI Assistant::enabled', () => {
|
||||
test('renders placeholder UI', async ({ n8n, setupRequirements }) => {
|
||||
await setupRequirements(aiEnabledRequirements);
|
||||
await n8n.page.goto('/workflow/new');
|
||||
|
||||
await expect(n8n.aiAssistant.getAskAssistantCanvasActionButton()).toBeVisible();
|
||||
|
||||
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
|
||||
|
||||
await expect(n8n.aiAssistant.getPlaceholderMessage()).toBeVisible();
|
||||
|
||||
await expect(n8n.aiAssistant.getChatInput()).toBeVisible();
|
||||
|
||||
await expect(n8n.aiAssistant.getSendMessageButton()).toBeDisabled();
|
||||
|
||||
await expect(n8n.aiAssistant.getCloseChatButton()).toBeVisible();
|
||||
|
||||
await n8n.aiAssistant.getCloseChatButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeHidden();
|
||||
});
|
||||
|
||||
test('should show resizer when chat is open', async ({ n8n, setupRequirements }) => {
|
||||
await setupRequirements(aiEnabledRequirements);
|
||||
await n8n.page.goto('/workflow/new');
|
||||
|
||||
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getAskAssistantSidebarResizer()).toBeVisible();
|
||||
|
||||
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
|
||||
|
||||
await n8n.aiAssistant.getAskAssistantSidebarResizer().hover();
|
||||
|
||||
await n8n.aiAssistant.getCloseChatButton().click();
|
||||
});
|
||||
|
||||
test('should start chat session from node error view', async ({ n8n, setupRequirements }) => {
|
||||
await setupRequirements(aiEnabledWithWorkflowRequirements);
|
||||
|
||||
await n8n.canvas.openNode('Stop and Error');
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
||||
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeVisible();
|
||||
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeEnabled();
|
||||
|
||||
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesAll().first()).toContainText(
|
||||
'Hey, this is an assistant message',
|
||||
);
|
||||
|
||||
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should render chat input correctly', async ({ n8n, setupRequirements }) => {
|
||||
await setupRequirements(aiEnabledWithWorkflowRequirements);
|
||||
|
||||
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
|
||||
await expect(n8n.aiAssistant.getChatInput()).toBeVisible();
|
||||
|
||||
await expect(n8n.aiAssistant.getSendMessageButton()).toBeDisabled();
|
||||
|
||||
await n8n.aiAssistant.getChatInput().fill('Test message');
|
||||
|
||||
await expect(n8n.aiAssistant.getChatInput()).toHaveValue('Test message');
|
||||
|
||||
await expect(n8n.aiAssistant.getSendMessageButton()).toBeEnabled();
|
||||
|
||||
await n8n.aiAssistant.getSendMessageButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatInput()).toHaveValue('');
|
||||
});
|
||||
|
||||
test('should render and handle quick replies', async ({ n8n, setupRequirements }) => {
|
||||
await setupRequirements(aiEnabledWithQuickRepliesRequirements);
|
||||
|
||||
await n8n.canvas.openNode('Stop and Error');
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
||||
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getQuickReplyButtons()).toHaveCount(2);
|
||||
|
||||
await expect(n8n.aiAssistant.getQuickReplyButtons()).toHaveCount(2);
|
||||
|
||||
await n8n.aiAssistant.getQuickReplyButtons().first().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toContainText("Sure, let's do it");
|
||||
});
|
||||
|
||||
test('should warn before starting a new session', async ({ n8n, setupRequirements }) => {
|
||||
await setupRequirements(aiEnabledWithWorkflowRequirements);
|
||||
|
||||
await n8n.canvas.openNode('Edit Fields');
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
||||
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
|
||||
|
||||
await n8n.aiAssistant.getCloseChatButton().click();
|
||||
|
||||
await n8n.ndv.clickBackToCanvasButton();
|
||||
|
||||
await n8n.canvas.openNode('Stop and Error');
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
||||
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getNewAssistantSessionModal()).toBeVisible();
|
||||
|
||||
await n8n.aiAssistant
|
||||
.getNewAssistantSessionModal()
|
||||
.getByRole('button', { name: 'Start new session' })
|
||||
.click();
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('should end chat session when `end_session` event is received', async ({
|
||||
n8n,
|
||||
setupRequirements,
|
||||
}) => {
|
||||
await setupRequirements(aiEnabledWithEndSessionRequirements);
|
||||
|
||||
await n8n.canvas.openNode('Stop and Error');
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
||||
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesSystem()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesSystem()).toHaveCount(1);
|
||||
|
||||
await expect(n8n.aiAssistant.getChatMessagesSystem().first()).toContainText(
|
||||
'session has ended',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import type { TestRequirements } from '../../Types';
|
||||
|
||||
const telemetryDisabledRequirements: TestRequirements = {
|
||||
config: {
|
||||
settings: {
|
||||
telemetry: { enabled: false },
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
'n8n-telemetry': JSON.stringify({ enabled: false }),
|
||||
},
|
||||
};
|
||||
|
||||
const telemetryEnabledRequirements: TestRequirements = {
|
||||
config: {
|
||||
settings: {
|
||||
telemetry: { enabled: true },
|
||||
instanceId: 'test-instance-id',
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
'n8n-telemetry': JSON.stringify({ enabled: true }),
|
||||
'n8n-instance-id': 'test-instance-id',
|
||||
},
|
||||
intercepts: {
|
||||
iframeRequest: {
|
||||
url: 'https://n8n.io/self-install*',
|
||||
response: '<html><body>Test iframe content</body></html>',
|
||||
contentType: 'text/html',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('n8n.io iframe', () => {
|
||||
test.describe('when telemetry is disabled', () => {
|
||||
test('should not load the iframe when visiting /home/workflows', async ({
|
||||
n8n,
|
||||
setupRequirements,
|
||||
}) => {
|
||||
await setupRequirements(telemetryDisabledRequirements);
|
||||
|
||||
await n8n.page.goto('/');
|
||||
await n8n.page.waitForLoadState();
|
||||
await expect(n8n.iframe.getIframe()).not.toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('when telemetry is enabled', () => {
|
||||
test('should load the iframe when visiting /home/workflows @auth:owner', async ({
|
||||
n8n,
|
||||
setupRequirements,
|
||||
api,
|
||||
}) => {
|
||||
await setupRequirements(telemetryEnabledRequirements);
|
||||
|
||||
// Get current user ID from the API
|
||||
const currentUser = await api.get('/rest/login');
|
||||
const testInstanceId = 'test-instance-id';
|
||||
const testUserId = currentUser.id;
|
||||
const iframeUrl = `https://n8n.io/self-install?instanceId=${testInstanceId}&userId=${testUserId}`;
|
||||
|
||||
await n8n.page.goto('/');
|
||||
await n8n.page.waitForLoadState();
|
||||
|
||||
const iframeElement = n8n.iframe.getIframeBySrc(iframeUrl);
|
||||
await expect(iframeElement).toBeAttached();
|
||||
|
||||
await expect(iframeElement).toHaveAttribute('src', iframeUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,3 @@
|
||||
import {
|
||||
getErrorActionItem,
|
||||
getEvaluationsActionItem,
|
||||
getIgnoreAllButton,
|
||||
getSuggestedActionItem,
|
||||
getSuggestedActionsButton,
|
||||
getSuggestedActionsPopover,
|
||||
getTimeSavedActionItem,
|
||||
} from '../../composables/ProductionChecklist';
|
||||
import { closeActivationModal } from '../../composables/WorkflowActivationModal';
|
||||
import { openWorkflowSettings } from '../../composables/WorkflowSettingsModal';
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
|
||||
@@ -22,35 +11,29 @@ test.describe('Workflow Production Checklist', () => {
|
||||
test('should show suggested actions automatically when workflow is first activated', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
// Add a schedule trigger node (activatable)
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
|
||||
// Verify suggested actions button is not visible
|
||||
await expect(getSuggestedActionsButton(n8n.page)).toBeHidden();
|
||||
await expect(n8n.canvas.getProductionChecklistButton()).toBeHidden();
|
||||
|
||||
// Activate the workflow
|
||||
await n8n.canvas.activateWorkflow();
|
||||
|
||||
// Activation Modal should be visible since it's first activation
|
||||
await closeActivationModal(n8n.page);
|
||||
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
|
||||
await n8n.workflowActivationModal.close();
|
||||
|
||||
// Verify suggested actions button and popover is visible
|
||||
await expect(getSuggestedActionsButton(n8n.page)).toBeVisible();
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(getSuggestedActionItem(n8n.page)).toHaveCount(2);
|
||||
await expect(getErrorActionItem(n8n.page)).toBeVisible();
|
||||
await expect(getTimeSavedActionItem(n8n.page)).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistButton()).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistActionItem()).toHaveCount(2);
|
||||
await expect(n8n.canvas.getErrorActionItem()).toBeVisible();
|
||||
await expect(n8n.canvas.getTimeSavedActionItem()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display evaluations action when AI node exists and feature is enabled', async ({
|
||||
n8n,
|
||||
api,
|
||||
}) => {
|
||||
// Enable evaluations feature
|
||||
await api.enableFeature('evaluation');
|
||||
|
||||
// Add schedule trigger and AI node
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNodeAndCloseNDV('OpenAI', 'Create an assistant');
|
||||
|
||||
@@ -58,56 +41,50 @@ test.describe('Workflow Production Checklist', () => {
|
||||
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
|
||||
await n8n.workflowActivationModal.close();
|
||||
|
||||
// Suggested actions should be open
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(getSuggestedActionItem(n8n.page)).toHaveCount(3);
|
||||
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistActionItem()).toHaveCount(3);
|
||||
|
||||
// Verify evaluations action is present
|
||||
await expect(getEvaluationsActionItem(n8n.page)).toBeVisible();
|
||||
await getEvaluationsActionItem(n8n.page).click();
|
||||
await expect(n8n.canvas.getEvaluationsActionItem()).toBeVisible();
|
||||
await n8n.canvas.getEvaluationsActionItem().click();
|
||||
|
||||
// Verify navigation to evaluations page
|
||||
await expect(n8n.page).toHaveURL(/\/evaluation/);
|
||||
});
|
||||
|
||||
test('should open workflow settings modal when error workflow action is clicked', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
// Add schedule trigger
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
|
||||
await n8n.workflowActivationModal.close();
|
||||
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
|
||||
|
||||
// Click error workflow action
|
||||
const errorAction = getErrorActionItem(n8n.page);
|
||||
const errorAction = n8n.canvas.getErrorActionItem();
|
||||
await expect(errorAction).toBeVisible();
|
||||
await errorAction.click();
|
||||
|
||||
// Verify workflow settings modal opens
|
||||
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
|
||||
await expect(n8n.page.getByTestId('workflow-settings-error-workflow')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open workflow settings modal when time saved action is clicked', async ({ n8n }) => {
|
||||
// Add schedule trigger
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
|
||||
await n8n.workflowActivationModal.close();
|
||||
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
|
||||
|
||||
// Click time saved action
|
||||
const timeAction = getTimeSavedActionItem(n8n.page);
|
||||
const timeAction = n8n.canvas.getTimeSavedActionItem();
|
||||
await expect(timeAction).toBeVisible();
|
||||
await timeAction.click();
|
||||
|
||||
// Verify workflow settings modal opens
|
||||
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -115,73 +92,64 @@ test.describe('Workflow Production Checklist', () => {
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
|
||||
await n8n.workflowActivationModal.close();
|
||||
|
||||
// Suggested actions popover should be open
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
|
||||
|
||||
// Verify error workflow action is visible
|
||||
await expect(getSuggestedActionItem(n8n.page).first()).toContainText('error');
|
||||
await getSuggestedActionItem(n8n.page).first().getByTitle('Ignore').click();
|
||||
await n8n.page.waitForTimeout(500); // items disappear after timeout, not arbitrary
|
||||
await expect(getErrorActionItem(n8n.page)).toBeHidden();
|
||||
await expect(n8n.canvas.getProductionChecklistActionItem().first()).toContainText('error');
|
||||
await n8n.canvas.getProductionChecklistActionItem().first().getByTitle('Ignore').click();
|
||||
await expect(n8n.canvas.getErrorActionItem()).toBeHidden();
|
||||
|
||||
// Close and reopen popover
|
||||
await n8n.page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await getSuggestedActionsButton(n8n.page).click();
|
||||
await n8n.canvas.clickProductionChecklistButton();
|
||||
|
||||
// Verify error workflow action is still no longer visible
|
||||
await expect(getErrorActionItem(n8n.page)).toBeHidden();
|
||||
await expect(getTimeSavedActionItem(n8n.page)).toBeVisible();
|
||||
await expect(n8n.canvas.getErrorActionItem()).toBeHidden();
|
||||
await expect(n8n.canvas.getTimeSavedActionItem()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show completed state for configured actions', async ({ n8n }) => {
|
||||
// Add schedule trigger and activate workflow
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
|
||||
await n8n.workflowActivationModal.close();
|
||||
|
||||
// Open workflow settings and set error workflow
|
||||
await openWorkflowSettings(n8n.page);
|
||||
await n8n.workflowSettingsModal.open();
|
||||
await expect(n8n.workflowSettingsModal.getModal()).toBeVisible();
|
||||
|
||||
// Set an error workflow (we'll use a dummy value)
|
||||
await n8n.page.getByTestId('workflow-settings-error-workflow').click();
|
||||
await n8n.page.getByRole('option', { name: 'My workflow' }).first().click();
|
||||
await n8n.page.getByRole('button', { name: 'Save' }).click();
|
||||
await n8n.workflowSettingsModal.selectErrorWorkflow('My workflow');
|
||||
await n8n.workflowSettingsModal.clickSave();
|
||||
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeHidden();
|
||||
|
||||
// Open suggested actions
|
||||
await getSuggestedActionsButton(n8n.page).click();
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await n8n.canvas.clickProductionChecklistButton();
|
||||
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
|
||||
|
||||
// Verify error workflow action shows as completed
|
||||
await expect(
|
||||
getSuggestedActionItem(n8n.page).first().locator('svg[data-icon="circle-check"]'),
|
||||
n8n.canvas
|
||||
.getProductionChecklistActionItem()
|
||||
.first()
|
||||
.locator('svg[data-icon="circle-check"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow ignoring all actions with confirmation', async ({ n8n }) => {
|
||||
// Add schedule trigger
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
|
||||
await n8n.workflowActivationModal.close();
|
||||
|
||||
// Suggested actions should be open
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
|
||||
|
||||
// Click ignore all button
|
||||
await getIgnoreAllButton(n8n.page).click();
|
||||
await n8n.canvas.clickProductionChecklistIgnoreAll();
|
||||
|
||||
// Confirm in the dialog
|
||||
await expect(n8n.page.locator('.el-message-box')).toBeVisible();
|
||||
await n8n.page
|
||||
.locator('.el-message-box__btns button')
|
||||
.filter({ hasText: /ignore for all workflows/i })
|
||||
.click();
|
||||
|
||||
// Verify suggested actions button is no longer visible
|
||||
await expect(getSuggestedActionsButton(n8n.page)).toBeHidden();
|
||||
await expect(n8n.canvas.getProductionChecklistButton()).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test.describe('AI-716 Correctly set up agent model shows error', () => {
|
||||
test('should not show error when adding a sub-node with credential set-up', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.clickAddWorkflowButton();
|
||||
|
||||
await n8n.canvas.addNode('AI Agent');
|
||||
|
||||
await n8n.page.keyboard.press('Escape');
|
||||
|
||||
await n8n.canvas.addNode('OpenAI Chat Model');
|
||||
|
||||
await n8n.credentials.createAndSaveNewCredential('apiKey', 'sk-123');
|
||||
|
||||
await n8n.page.keyboard.press('Escape');
|
||||
|
||||
await expect(n8n.canvas.getNodeIssuesByName('OpenAI Chat Model')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import type { TestRequirements } from '../../Types';
|
||||
|
||||
const requirements: TestRequirements = {
|
||||
workflow: {
|
||||
'Test_9999_SUG_38.json': 'SUG_38_Test_Workflow',
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('SUG-38 Inline expression previews are not displayed in NDV', () => {
|
||||
test("should show resolved inline expression preview in NDV if the node's input data is populated", async ({
|
||||
n8n,
|
||||
setupRequirements,
|
||||
}) => {
|
||||
await setupRequirements(requirements);
|
||||
|
||||
await n8n.canvas.clickZoomToFitButton();
|
||||
|
||||
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
|
||||
'Workflow executed successfully',
|
||||
);
|
||||
|
||||
await n8n.canvas.openNode('Repro1');
|
||||
|
||||
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toBeVisible();
|
||||
|
||||
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toHaveText('hello there');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,18 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test('default signin is as owner', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await expect(n8n.page).toHaveURL(/\/workflow/);
|
||||
});
|
||||
test.describe('Authentication', () => {
|
||||
const testCases = [
|
||||
{ role: 'default', expectedUrl: /\/workflow/, auth: '' },
|
||||
{ role: 'owner', expectedUrl: /\/workflow/, auth: '@auth:owner' },
|
||||
{ role: 'admin', expectedUrl: /\/workflow/, auth: '@auth:admin' },
|
||||
{ role: 'member', expectedUrl: /\/workflow/, auth: '@auth:member' },
|
||||
{ role: 'none', expectedUrl: /\/signin/, auth: '@auth:none' },
|
||||
];
|
||||
|
||||
test('owner can access dashboard @auth:owner', async ({ n8n }) => {
|
||||
for (const { role, expectedUrl, auth } of testCases) {
|
||||
test(`${role} authentication ${auth}`, async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await expect(n8n.page).toHaveURL(/\/workflow/);
|
||||
});
|
||||
|
||||
test('admin can access dashboard @auth:admin', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await expect(n8n.page).toHaveURL(/\/workflow/);
|
||||
});
|
||||
|
||||
test('member can access dashboard @auth:member', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await expect(n8n.page).toHaveURL(/\/workflow/);
|
||||
});
|
||||
|
||||
test('no auth can not access dashboard @auth:none', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await expect(n8n.page).toHaveURL(/\/signin/);
|
||||
await expect(n8n.page).toHaveURL(expectedUrl);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test.describe
|
||||
.serial('Environment Feature Flags', () => {
|
||||
test('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', async ({
|
||||
api,
|
||||
}) => {
|
||||
const setResponse = await api.setEnvFeatureFlags({
|
||||
N8N_ENV_FEAT_TEST: 'true',
|
||||
});
|
||||
|
||||
expect(setResponse.data.success).toBe(true);
|
||||
expect(setResponse.data.message).toBe('Environment feature flags updated');
|
||||
|
||||
expect(setResponse.data.flags).toBeInstanceOf(Object);
|
||||
expect(setResponse.data.flags['N8N_ENV_FEAT_TEST']).toBe('true');
|
||||
|
||||
const currentFlags = await api.getEnvFeatureFlags();
|
||||
|
||||
expect(currentFlags).toBeInstanceOf(Object);
|
||||
expect(currentFlags.data['N8N_ENV_FEAT_TEST']).toBe('true');
|
||||
});
|
||||
|
||||
test('should reset feature flags at runtime', async ({ api }) => {
|
||||
const setResponse1 = await api.setEnvFeatureFlags({
|
||||
N8N_ENV_FEAT_TEST: 'true',
|
||||
});
|
||||
|
||||
expect(setResponse1.data.success).toBe(true);
|
||||
expect(setResponse1.data.flags['N8N_ENV_FEAT_TEST']).toBe('true');
|
||||
|
||||
const clearResponse = await api.clearEnvFeatureFlags();
|
||||
|
||||
expect(clearResponse.data.success).toBe(true);
|
||||
|
||||
expect(clearResponse.data.flags).toBeInstanceOf(Object);
|
||||
expect(clearResponse.data.flags['N8N_ENV_FEAT_TEST']).toBeUndefined();
|
||||
|
||||
const currentFlags = await api.getEnvFeatureFlags();
|
||||
|
||||
expect(currentFlags).toBeInstanceOf(Object);
|
||||
expect(currentFlags.data['N8N_ENV_FEAT_TEST']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { expect, test } from '../../fixtures/base';
|
||||
|
||||
// Example of importing a workflow from a file
|
||||
test.describe('PDF Test', () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip('Can read and write PDF files and extract text', async ({ n8n }) => {
|
||||
test('Can read and write PDF files and extract text', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.clickAddWorkflowButton();
|
||||
await n8n.canvas.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');
|
||||
|
||||
@@ -44,5 +44,6 @@ const playwrightRoot = findProjectRoot('playwright.config.ts');
|
||||
* @returns An absolute path to the file or directory.
|
||||
*/
|
||||
export function resolveFromRoot(...pathSegments: string[]): string {
|
||||
// eslint-disable-next-line n8n-local-rules/no-argument-spread
|
||||
return path.join(playwrightRoot, ...pathSegments);
|
||||
}
|
||||
|
||||
70
packages/testing/playwright/utils/requirements.ts
Normal file
70
packages/testing/playwright/utils/requirements.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Page, BrowserContext } from '@playwright/test';
|
||||
|
||||
import { setContextSettings } from '../config/intercepts';
|
||||
import { n8nPage } from '../pages/n8nPage';
|
||||
import { ApiHelpers } from '../services/api-helper';
|
||||
import { TestError, type TestRequirements } from '../Types';
|
||||
|
||||
export async function setupTestRequirements(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
requirements: TestRequirements,
|
||||
): Promise<void> {
|
||||
const n8n = new n8nPage(page);
|
||||
const api = new ApiHelpers(context.request);
|
||||
|
||||
// 1. Setup frontend settings override
|
||||
if (requirements.config?.settings) {
|
||||
// Store settings for this context
|
||||
setContextSettings(context, requirements.config.settings);
|
||||
}
|
||||
|
||||
// 2. Setup feature flags
|
||||
if (requirements.config?.features) {
|
||||
for (const [feature, enabled] of Object.entries(requirements.config.features)) {
|
||||
if (enabled) {
|
||||
await api.enableFeature(feature);
|
||||
} else {
|
||||
await api.disableFeature(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Setup API intercepts
|
||||
if (requirements.intercepts) {
|
||||
for (const [name, config] of Object.entries(requirements.intercepts)) {
|
||||
await page.route(config.url, async (route) => {
|
||||
await route.fulfill({
|
||||
status: config.status ?? 200,
|
||||
contentType: config.contentType ?? 'application/json',
|
||||
body:
|
||||
typeof config.response === 'string' ? config.response : JSON.stringify(config.response),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Setup workflows
|
||||
if (requirements.workflow) {
|
||||
for (const [name, workflowData] of Object.entries(requirements.workflow)) {
|
||||
try {
|
||||
// Import workflow using the n8n page object
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.clickAddWorkflowButton();
|
||||
await n8n.canvas.importWorkflow(name, workflowData);
|
||||
} catch (error) {
|
||||
throw new TestError(`Failed to create workflow ${name}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Setup browser storage
|
||||
if (requirements.storage) {
|
||||
await context.addInitScript((storage) => {
|
||||
// Set localStorage items
|
||||
for (const [key, value] of Object.entries(storage)) {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
}, requirements.storage);
|
||||
}
|
||||
}
|
||||
72
packages/testing/playwright/workflows/Manual_wait_set.json
Normal file
72
packages/testing/playwright/workflows/Manual_wait_set.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "Manual wait set",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"amount": 2,
|
||||
"unit": "seconds"
|
||||
},
|
||||
"id": "ed6e0168-1145-43d0-9082-970b8a8f3cb5",
|
||||
"name": "Wait",
|
||||
"type": "n8n-nodes-base.wait",
|
||||
"typeVersion": 1,
|
||||
"position": [900, 580],
|
||||
"webhookId": "0f6f94a4-c28d-46f9-8468-6ab315a9fec9"
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "59467b99-4e7c-4f19-8fc2-4329788f0951",
|
||||
"name": "Manual",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [680, 580]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "6ddf089f-4d01-4691-928f-6de168e3b089",
|
||||
"name": "Set",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 1,
|
||||
"position": [1120, 580]
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"Wait": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Set",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Manual": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Wait",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"saveExecutionProgress": true,
|
||||
"saveManualExecutions": true,
|
||||
"callerPolicy": "workflowsFromSameOwner"
|
||||
},
|
||||
"versionId": "f11ff1bf-4273-46cb-bbec-65c7b2fa13cb",
|
||||
"id": "1037",
|
||||
"meta": {
|
||||
"instanceId": "8a47b83b4479b11330fdf21ccc96d4a8117035a968612e452b4c87bfd09c16c7"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
80
packages/testing/playwright/workflows/Test_9999_SUG_38.json
Normal file
80
packages/testing/playwright/workflows/Test_9999_SUG_38.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [-240, 180],
|
||||
"id": "cd9b8124-567e-43d9-b4d1-638b111cd049",
|
||||
"name": "When clicking ‘Execute workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "3a40d9f2-0eed-4a92-9287-9d6ec9ce90e8",
|
||||
"name": "message",
|
||||
"value": "hello there",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [-20, 180],
|
||||
"id": "6e58ae14-4851-4e9d-9465-4155b6e2f278",
|
||||
"name": "Edit Fields1"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "9e957377-c5f2-4254-89d8-334d32a8cfb6",
|
||||
"name": "test",
|
||||
"value": "={{ $json.message }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [200, 180],
|
||||
"id": "c4e9d792-51e9-4296-ba66-afac3cf378dd",
|
||||
"name": "Repro1"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Edit Fields1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Repro1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"instanceId": "cdc3bfdf3e6244f221ab6e71b2115a631406ae45a034bfca5e9731cf64f4eb64"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "bd816131-d8ad-4b4c-90d6-59fdab2e6307",
|
||||
"name": "Set",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 1,
|
||||
"position": [720, 460]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "23fc3930-b8f9-41d9-89db-b647291a2201",
|
||||
"options": {}
|
||||
},
|
||||
"id": "82fe0f6c-854a-4eb9-b311-d7b43025c047",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 1,
|
||||
"position": [460, 460],
|
||||
"webhookId": "23fc3930-b8f9-41d9-89db-b647291a2201"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Set",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"Webhook": [
|
||||
{
|
||||
"headers": {
|
||||
"host": "localhost:5678",
|
||||
"content-length": "37",
|
||||
"accept": "*/*",
|
||||
"content-type": "application/json",
|
||||
"accept-encoding": "gzip"
|
||||
},
|
||||
"params": {},
|
||||
"query": {},
|
||||
"body": {
|
||||
"here": "be",
|
||||
"dragons": true
|
||||
},
|
||||
"webhookUrl": "http://localhost:5678/webhook-test/23fc3930-b8f9-41d9-89db-b647291a2201",
|
||||
"executionMode": "test"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "ebfced75-2ce1-4c41-a971-6c3b83522c4d",
|
||||
"name": "When clicking 'Execute workflow'",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [360, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"errorMessage": "This is an error message"
|
||||
},
|
||||
"id": "f2e60459-401a-49d5-acfc-7b2b31cfdcf7",
|
||||
"name": "Stop and Error",
|
||||
"type": "n8n-nodes-base.stopAndError",
|
||||
"typeVersion": 1,
|
||||
"position": [1020, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
|
||||
},
|
||||
"id": "b54d4db9-b257-41a8-862f-26d293115bad",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [840, 320]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "053ada73-f7db-4e6a-8cc8-85756cc6ca4e",
|
||||
"name": "age",
|
||||
"value": "={{ 32asd }}",
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "5fd89612-a871-4679-b7b0-d659e09c6a0e",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [600, 100]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking 'Execute workflow'": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Stop and Error",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {}
|
||||
}
|
||||
743
pnpm-lock.yaml
generated
743
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user