mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
test: Add core entry points to allow easier test setup (#18597)
This commit is contained in:
54
packages/testing/playwright/composables/TestEntryComposer.ts
Normal file
54
packages/testing/playwright/composables/TestEntryComposer.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { n8nPage } from '../pages/n8nPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composer for UI test entry points. All methods in this class navigate to or verify UI state.
|
||||||
|
* For API-only testing, use the standalone `api` fixture directly instead.
|
||||||
|
*/
|
||||||
|
export class TestEntryComposer {
|
||||||
|
constructor(private readonly n8n: n8nPage) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start UI test from the home page and navigate to canvas
|
||||||
|
*/
|
||||||
|
async fromHome() {
|
||||||
|
await this.n8n.goHome();
|
||||||
|
await this.n8n.page.waitForURL('/home/workflows');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start UI test from a blank canvas (assumes already on canvas)
|
||||||
|
*/
|
||||||
|
async fromBlankCanvas() {
|
||||||
|
await this.n8n.goHome();
|
||||||
|
await this.n8n.workflows.clickAddWorkflowButton();
|
||||||
|
// Verify we're on canvas
|
||||||
|
await this.n8n.canvas.canvasPane().isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start UI test from a workflow in a new project
|
||||||
|
*/
|
||||||
|
async fromNewProject() {
|
||||||
|
// Enable features to allow us to create a new project
|
||||||
|
await this.n8n.api.enableFeature('projectRole:admin');
|
||||||
|
await this.n8n.api.enableFeature('projectRole:editor');
|
||||||
|
await this.n8n.api.setMaxTeamProjectsQuota(-1);
|
||||||
|
|
||||||
|
// Create a project using the API
|
||||||
|
const response = await this.n8n.api.projectApi.createProject();
|
||||||
|
|
||||||
|
const projectId = response.id;
|
||||||
|
await this.n8n.page.goto(`workflow/new?projectId=${projectId}`);
|
||||||
|
await this.n8n.canvas.canvasPane().isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start UI test from the canvas of an imported workflow
|
||||||
|
* Returns the workflow import result for use in the test
|
||||||
|
*/
|
||||||
|
async fromImportedWorkflow(workflowFile: string) {
|
||||||
|
const workflowImportResult = await this.n8n.api.workflowApi.importWorkflow(workflowFile);
|
||||||
|
await this.n8n.page.goto(`workflow/${workflowImportResult.workflowId}`);
|
||||||
|
return workflowImportResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
const envBaseURL = process.env.N8N_BASE_URL;
|
const envBaseURL = process.env.N8N_BASE_URL;
|
||||||
|
|
||||||
if (envBaseURL) {
|
if (envBaseURL) {
|
||||||
console.log(`Using external N8N_BASE_URL: ${envBaseURL}`);
|
|
||||||
await use(null as unknown as N8NStack);
|
await use(null as unknown as N8NStack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,8 +140,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
await page.close();
|
await page.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
n8n: async ({ page }, use) => {
|
n8n: async ({ page, api }, use) => {
|
||||||
const n8nInstance = new n8nPage(page);
|
const n8nInstance = new n8nPage(page, api);
|
||||||
await use(n8nInstance);
|
await use(n8nInstance);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@currents/playwright": "^1.15.3",
|
"@currents/playwright": "^1.15.3",
|
||||||
|
"@n8n/api-types": "workspace:^",
|
||||||
"@playwright/test": "1.54.2",
|
"@playwright/test": "1.54.2",
|
||||||
"@types/lodash": "catalog:",
|
"@types/lodash": "catalog:",
|
||||||
"eslint-plugin-playwright": "2.2.2",
|
"eslint-plugin-playwright": "2.2.2",
|
||||||
"generate-schema": "2.6.0",
|
"generate-schema": "2.6.0",
|
||||||
|
"n8n": "workspace:*",
|
||||||
"n8n-containers": "workspace:*",
|
"n8n-containers": "workspace:*",
|
||||||
|
"n8n-core": "workspace:*",
|
||||||
|
"n8n-workflow": "workspace:*",
|
||||||
"nanoid": "catalog:",
|
"nanoid": "catalog:",
|
||||||
"tsx": "catalog:",
|
"tsx": "catalog:"
|
||||||
"@n8n/api-types": "workspace:^"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import type { Locator } from '@playwright/test';
|
|||||||
import { BasePage } from './BasePage';
|
import { BasePage } from './BasePage';
|
||||||
|
|
||||||
export class WorkflowsPage extends BasePage {
|
export class WorkflowsPage extends BasePage {
|
||||||
async clickNewWorkflowCard() {
|
|
||||||
await this.clickByTestId('new-workflow-card');
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickAddFirstProjectButton() {
|
async clickAddFirstProjectButton() {
|
||||||
await this.clickByTestId('add-first-project-button');
|
await this.clickByTestId('add-first-project-button');
|
||||||
}
|
}
|
||||||
@@ -15,10 +11,20 @@ export class WorkflowsPage extends BasePage {
|
|||||||
await this.clickByTestId('project-plus-button');
|
await this.clickByTestId('project-plus-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the add workflow button on the workflows page, visible when there are already workflows.
|
||||||
|
*/
|
||||||
async clickAddWorkflowButton() {
|
async clickAddWorkflowButton() {
|
||||||
await this.clickByTestId('add-resource-workflow');
|
await this.clickByTestId('add-resource-workflow');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the new workflow button on the workflows page, visible when there are no workflows.
|
||||||
|
*/
|
||||||
|
async clickNewWorkflowCard() {
|
||||||
|
await this.clickByTestId('new-workflow-card');
|
||||||
|
}
|
||||||
|
|
||||||
getNewWorkflowCard() {
|
getNewWorkflowCard() {
|
||||||
return this.page.getByTestId('new-workflow-card');
|
return this.page.getByTestId('new-workflow-card');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ import { WorkflowSharingModal } from './WorkflowSharingModal';
|
|||||||
import { WorkflowsPage } from './WorkflowsPage';
|
import { WorkflowsPage } from './WorkflowsPage';
|
||||||
import { CanvasComposer } from '../composables/CanvasComposer';
|
import { CanvasComposer } from '../composables/CanvasComposer';
|
||||||
import { ProjectComposer } from '../composables/ProjectComposer';
|
import { ProjectComposer } from '../composables/ProjectComposer';
|
||||||
|
import { TestEntryComposer } from '../composables/TestEntryComposer';
|
||||||
import { WorkflowComposer } from '../composables/WorkflowComposer';
|
import { WorkflowComposer } from '../composables/WorkflowComposer';
|
||||||
|
import type { ApiHelpers } from '../services/api-helper';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
export class n8nPage {
|
export class n8nPage {
|
||||||
readonly page: Page;
|
readonly page: Page;
|
||||||
|
readonly api: ApiHelpers;
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
readonly aiAssistant: AIAssistantPage;
|
readonly aiAssistant: AIAssistantPage;
|
||||||
@@ -51,9 +54,11 @@ export class n8nPage {
|
|||||||
readonly workflowComposer: WorkflowComposer;
|
readonly workflowComposer: WorkflowComposer;
|
||||||
readonly projectComposer: ProjectComposer;
|
readonly projectComposer: ProjectComposer;
|
||||||
readonly canvasComposer: CanvasComposer;
|
readonly canvasComposer: CanvasComposer;
|
||||||
|
readonly start: TestEntryComposer;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page, api: ApiHelpers) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
|
this.api = api;
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
this.aiAssistant = new AIAssistantPage(page);
|
this.aiAssistant = new AIAssistantPage(page);
|
||||||
@@ -81,6 +86,7 @@ export class n8nPage {
|
|||||||
this.workflowComposer = new WorkflowComposer(this);
|
this.workflowComposer = new WorkflowComposer(this);
|
||||||
this.projectComposer = new ProjectComposer(this);
|
this.projectComposer = new ProjectComposer(this);
|
||||||
this.canvasComposer = new CanvasComposer(this);
|
this.canvasComposer = new CanvasComposer(this);
|
||||||
|
this.start = new TestEntryComposer(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goHome() {
|
async goHome() {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export default defineConfig({
|
|||||||
retries: IS_CI ? 2 : 0,
|
retries: IS_CI ? 2 : 0,
|
||||||
workers: WORKERS,
|
workers: WORKERS,
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
projects: getProjects(),
|
projects: getProjects(),
|
||||||
|
|
||||||
// We use this if an n8n url is passed in. If the server is already running, we reuse it.
|
// We use this if an n8n url is passed in. If the server is already running, we reuse it.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
INSTANCE_ADMIN_CREDENTIALS,
|
INSTANCE_ADMIN_CREDENTIALS,
|
||||||
} from '../config/test-users';
|
} from '../config/test-users';
|
||||||
import { TestError } from '../Types';
|
import { TestError } from '../Types';
|
||||||
|
import { ProjectApiHelper } from './project-api-helper';
|
||||||
import { WorkflowApiHelper } from './workflow-api-helper';
|
import { WorkflowApiHelper } from './workflow-api-helper';
|
||||||
|
|
||||||
export interface LoginResponseData {
|
export interface LoginResponseData {
|
||||||
@@ -33,10 +34,12 @@ const DB_TAGS = {
|
|||||||
export class ApiHelpers {
|
export class ApiHelpers {
|
||||||
request: APIRequestContext;
|
request: APIRequestContext;
|
||||||
workflowApi: WorkflowApiHelper;
|
workflowApi: WorkflowApiHelper;
|
||||||
|
projectApi: ProjectApiHelper;
|
||||||
|
|
||||||
constructor(requestContext: APIRequestContext) {
|
constructor(requestContext: APIRequestContext) {
|
||||||
this.request = requestContext;
|
this.request = requestContext;
|
||||||
this.workflowApi = new WorkflowApiHelper(this);
|
this.workflowApi = new WorkflowApiHelper(this);
|
||||||
|
this.projectApi = new ProjectApiHelper(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== MAIN SETUP METHODS =====
|
// ===== MAIN SETUP METHODS =====
|
||||||
|
|||||||
30
packages/testing/playwright/services/project-api-helper.ts
Normal file
30
packages/testing/playwright/services/project-api-helper.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import type { ApiHelpers } from './api-helper';
|
||||||
|
import { TestError } from '../Types';
|
||||||
|
|
||||||
|
export class ProjectApiHelper {
|
||||||
|
constructor(private api: ApiHelpers) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new project with a unique name
|
||||||
|
* @param projectName Optional base name for the project. If not provided, generates a default name.
|
||||||
|
* @returns The created project data
|
||||||
|
*/
|
||||||
|
async createProject(projectName?: string) {
|
||||||
|
const uniqueName = projectName ? `${projectName} (${nanoid(8)})` : `Test Project ${nanoid(8)}`;
|
||||||
|
|
||||||
|
const response = await this.api.request.post('/rest/projects', {
|
||||||
|
data: {
|
||||||
|
name: uniqueName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new TestError(`Failed to create project: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.data ?? result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { readFileSync } from 'fs';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { setTimeout } from 'timers/promises';
|
|
||||||
|
|
||||||
import type { ApiHelpers } from './api-helper';
|
|
||||||
import { TestError } from '../Types';
|
|
||||||
import { resolveFromRoot } from '../utils/path-helper';
|
|
||||||
|
|
||||||
type WorkflowDefinition = {
|
|
||||||
name?: string;
|
|
||||||
active?: boolean;
|
|
||||||
nodes: Array<{
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
type: string;
|
|
||||||
typeVersion?: number;
|
|
||||||
position?: [number, number];
|
|
||||||
webhookId?: string;
|
|
||||||
parameters: { [key: string]: unknown } & { path?: string };
|
|
||||||
}>;
|
|
||||||
connections?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate and assign a unique webhook id and path to the first Webhook node in a workflow.
|
|
||||||
*
|
|
||||||
* - Uniqueness: Uses nanoid to ensure both the internal `webhookId` and external `parameters.path`
|
|
||||||
* are unique per call, avoiding collisions across parallel tests and instances.
|
|
||||||
* - Path format: `${prefix}-${nanoid}` (default prefix: `test-webhook`).
|
|
||||||
* - Mutation: Updates the passed-in `workflow` object in-place.
|
|
||||||
*/
|
|
||||||
export function applyUniqueWebhookIds(
|
|
||||||
workflow: WorkflowDefinition,
|
|
||||||
options?: { prefix?: string; idLength?: number },
|
|
||||||
) {
|
|
||||||
const idLength = options?.idLength ?? 12;
|
|
||||||
const prefix = options?.prefix ?? 'test-webhook';
|
|
||||||
|
|
||||||
const generatedId = nanoid(idLength);
|
|
||||||
const generatedPath = `${prefix}-${generatedId}`;
|
|
||||||
|
|
||||||
for (const node of workflow.nodes) {
|
|
||||||
if (node.type === 'n8n-nodes-base.webhook') {
|
|
||||||
node.webhookId = generatedId;
|
|
||||||
node.parameters.path = generatedPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { webhookId: generatedId, webhookPath: generatedPath, workflow };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a webhook workflow from an in-memory definition after assigning unique webhook id/path.
|
|
||||||
*
|
|
||||||
* Returns the externally callable `webhookPath` (what to pass to triggerWebhook)
|
|
||||||
* and the `workflowId` created by the API.
|
|
||||||
*/
|
|
||||||
export async function createWebhookWorkflow(
|
|
||||||
api: ApiHelpers,
|
|
||||||
workflow: WorkflowDefinition,
|
|
||||||
options?: { prefix?: string; idLength?: number },
|
|
||||||
) {
|
|
||||||
const { webhookPath } = applyUniqueWebhookIds(workflow, options);
|
|
||||||
const createdWorkflow = await api.workflowApi.createWorkflow(workflow as object);
|
|
||||||
const workflowId = createdWorkflow.id as string;
|
|
||||||
return { webhookPath, workflowId, createdWorkflow };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import a webhook workflow from `packages/testing/playwright/workflows/{fileName}` and create it
|
|
||||||
* with a unique webhook id/path.
|
|
||||||
*/
|
|
||||||
export async function importWebhookWorkflow(
|
|
||||||
api: ApiHelpers,
|
|
||||||
fileName: string,
|
|
||||||
options?: { prefix?: string; idLength?: number },
|
|
||||||
) {
|
|
||||||
const workflowDefinition = JSON.parse(
|
|
||||||
readFileSync(resolveFromRoot('workflows', fileName), 'utf8'),
|
|
||||||
) as WorkflowDefinition;
|
|
||||||
|
|
||||||
return await createWebhookWorkflow(api, workflowDefinition, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience: import a webhook workflow from file, ensure unique webhook id/path, and activate it.
|
|
||||||
*
|
|
||||||
* Returns the `webhookPath` to call and the `workflowId` for follow-up assertions.
|
|
||||||
*/
|
|
||||||
export async function importAndActivateWebhookWorkflow(
|
|
||||||
api: ApiHelpers,
|
|
||||||
fileName: string,
|
|
||||||
options?: { prefix?: string; idLength?: number },
|
|
||||||
) {
|
|
||||||
const { webhookPath, workflowId, createdWorkflow } = await importWebhookWorkflow(
|
|
||||||
api,
|
|
||||||
fileName,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
await setTimeout(500);
|
|
||||||
await api.workflowApi.setActive(workflowId, true);
|
|
||||||
return { webhookPath, workflowId, createdWorkflow };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience: create a webhook workflow from an in-memory definition and activate it.
|
|
||||||
*/
|
|
||||||
export async function createAndActivateWebhookWorkflow(
|
|
||||||
api: ApiHelpers,
|
|
||||||
workflow: WorkflowDefinition,
|
|
||||||
options?: { prefix?: string; idLength?: number },
|
|
||||||
) {
|
|
||||||
const { webhookPath, workflowId, createdWorkflow } = await createWebhookWorkflow(
|
|
||||||
api,
|
|
||||||
workflow,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
// Timing issue between workflow creation and activation
|
|
||||||
await setTimeout(500);
|
|
||||||
await api.workflowApi.setActive(workflowId, true);
|
|
||||||
return { webhookPath, workflowId, createdWorkflow };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger a webhook endpoint with optional data and parameters.
|
|
||||||
*
|
|
||||||
* @param api - The API helpers instance
|
|
||||||
* @param path - The webhook path (without /webhook/ prefix)
|
|
||||||
* @param options - Configuration for the webhook request
|
|
||||||
*/
|
|
||||||
export async function triggerWebhook(
|
|
||||||
api: ApiHelpers,
|
|
||||||
path: string,
|
|
||||||
options: { method?: 'GET' | 'POST'; data?: object; params?: Record<string, string> } = {},
|
|
||||||
) {
|
|
||||||
const { method = 'POST', data, params } = options;
|
|
||||||
|
|
||||||
let url = `/webhook/${path}`;
|
|
||||||
if (params && Object.keys(params).length > 0) {
|
|
||||||
const searchParams = new URLSearchParams(params);
|
|
||||||
url += `?${searchParams.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestOptions: Record<string, unknown> = {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (data && method === 'POST') {
|
|
||||||
requestOptions.data = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response =
|
|
||||||
method === 'GET' ? await api.request.get(url) : await api.request.post(url, requestOptions);
|
|
||||||
|
|
||||||
if (!response.ok()) {
|
|
||||||
throw new TestError(`Webhook trigger failed: ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,30 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import type { IWorkflowBase, ExecutionSummary } from 'n8n-workflow';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
// Type for execution responses from the n8n API
|
||||||
|
// Couldn't find the exact type so I put these ones together
|
||||||
|
|
||||||
|
interface ExecutionListResponse extends ExecutionSummary {
|
||||||
|
data: string;
|
||||||
|
workflowData: IWorkflowBase;
|
||||||
|
}
|
||||||
|
|
||||||
import type { ApiHelpers } from './api-helper';
|
import type { ApiHelpers } from './api-helper';
|
||||||
import { TestError } from '../Types';
|
import { TestError } from '../Types';
|
||||||
|
import { resolveFromRoot } from '../utils/path-helper';
|
||||||
|
|
||||||
|
type WorkflowImportResult = {
|
||||||
|
workflowId: string;
|
||||||
|
createdWorkflow: IWorkflowBase;
|
||||||
|
webhookPath?: string;
|
||||||
|
webhookId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class WorkflowApiHelper {
|
export class WorkflowApiHelper {
|
||||||
constructor(private api: ApiHelpers) {}
|
constructor(private api: ApiHelpers) {}
|
||||||
|
|
||||||
async createWorkflow(workflow: object) {
|
async createWorkflow(workflow: IWorkflowBase) {
|
||||||
const response = await this.api.request.post('/rest/workflows', { data: workflow });
|
const response = await this.api.request.post('/rest/workflows', { data: workflow });
|
||||||
|
|
||||||
if (!response.ok()) {
|
if (!response.ok()) {
|
||||||
@@ -27,7 +47,83 @@ export class WorkflowApiHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExecutions(workflowId?: string, limit = 20) {
|
/**
|
||||||
|
* Make workflow unique by updating name, IDs, and webhook paths if present.
|
||||||
|
* This ensures no conflicts when importing workflows for testing.
|
||||||
|
*/
|
||||||
|
private makeWorkflowUnique(
|
||||||
|
workflow: IWorkflowBase,
|
||||||
|
options?: { webhookPrefix?: string; idLength?: number },
|
||||||
|
) {
|
||||||
|
const idLength = options?.idLength ?? 12;
|
||||||
|
const webhookPrefix = options?.webhookPrefix ?? 'test-webhook';
|
||||||
|
const uniqueSuffix = nanoid(idLength);
|
||||||
|
|
||||||
|
// Make workflow name unique
|
||||||
|
if (workflow.name) {
|
||||||
|
workflow.name = `${workflow.name} (Test ${uniqueSuffix})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if workflow has webhook nodes and process them
|
||||||
|
let webhookId: string | undefined;
|
||||||
|
let webhookPath: string | undefined;
|
||||||
|
|
||||||
|
for (const node of workflow.nodes) {
|
||||||
|
if (node.type === 'n8n-nodes-base.webhook') {
|
||||||
|
webhookId = nanoid(idLength);
|
||||||
|
webhookPath = `${webhookPrefix}-${webhookId}`;
|
||||||
|
node.webhookId = webhookId;
|
||||||
|
node.parameters.path = webhookPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { webhookId, webhookPath, workflow };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a workflow from an in-memory definition, making it unique for testing.
|
||||||
|
* Returns detailed information about what was created.
|
||||||
|
*/
|
||||||
|
async createWorkflowFromDefinition(
|
||||||
|
workflow: IWorkflowBase,
|
||||||
|
options?: { webhookPrefix?: string; idLength?: number },
|
||||||
|
): Promise<WorkflowImportResult> {
|
||||||
|
const { webhookPath, webhookId } = this.makeWorkflowUnique(workflow, options);
|
||||||
|
const createdWorkflow = await this.createWorkflow(workflow);
|
||||||
|
const workflowId: string = String(createdWorkflow.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflowId,
|
||||||
|
createdWorkflow,
|
||||||
|
webhookPath,
|
||||||
|
webhookId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a workflow from file and make it unique for testing.
|
||||||
|
* The workflow will be created with its original active state from the JSON file.
|
||||||
|
* Returns detailed information about what was imported, including webhook info if present.
|
||||||
|
*/
|
||||||
|
async importWorkflow(
|
||||||
|
fileName: string,
|
||||||
|
options?: { webhookPrefix?: string; idLength?: number },
|
||||||
|
): Promise<WorkflowImportResult> {
|
||||||
|
const workflowDefinition: IWorkflowBase = JSON.parse(
|
||||||
|
readFileSync(resolveFromRoot('workflows', fileName), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await this.createWorkflowFromDefinition(workflowDefinition, options);
|
||||||
|
|
||||||
|
// Ensure the workflow is in the correct active state as specified in the JSON
|
||||||
|
if (workflowDefinition.active) {
|
||||||
|
await this.setActive(result.workflowId, workflowDefinition.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExecutions(workflowId?: string, limit = 20): Promise<ExecutionListResponse[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (workflowId) params.set('workflowId', workflowId);
|
if (workflowId) params.set('workflowId', workflowId);
|
||||||
params.set('limit', limit.toString());
|
params.set('limit', limit.toString());
|
||||||
@@ -47,7 +143,7 @@ export class WorkflowApiHelper {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExecution(executionId: string) {
|
async getExecution(executionId: string): Promise<ExecutionListResponse> {
|
||||||
const response = await this.api.request.get(`/rest/executions/${executionId}`);
|
const response = await this.api.request.get(`/rest/executions/${executionId}`);
|
||||||
|
|
||||||
if (!response.ok()) {
|
if (!response.ok()) {
|
||||||
@@ -58,7 +154,7 @@ export class WorkflowApiHelper {
|
|||||||
return result.data ?? result;
|
return result.data ?? result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForExecution(workflowId: string, timeoutMs = 10000) {
|
async waitForExecution(workflowId: string, timeoutMs = 10000): Promise<ExecutionListResponse> {
|
||||||
const initialExecutions = await this.getExecutions(workflowId, 50);
|
const initialExecutions = await this.getExecutions(workflowId, 50);
|
||||||
const initialCount = initialExecutions.length;
|
const initialCount = initialExecutions.length;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -77,7 +173,9 @@ export class WorkflowApiHelper {
|
|||||||
for (const execution of executions) {
|
for (const execution of executions) {
|
||||||
const isCompleted = execution.status === 'success' || execution.status === 'error';
|
const isCompleted = execution.status === 'success' || execution.status === 'error';
|
||||||
if (isCompleted && execution.mode === 'webhook') {
|
if (isCompleted && execution.mode === 'webhook') {
|
||||||
const executionTime = new Date(execution.startedAt ?? execution.createdAt).getTime();
|
const executionTime = new Date(
|
||||||
|
execution.startedAt ?? execution.createdAt ?? Date.now(),
|
||||||
|
).getTime();
|
||||||
if (executionTime >= startTime - 5000) {
|
if (executionTime >= startTime - 5000) {
|
||||||
return execution;
|
return execution;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from '../../../fixtures/base';
|
||||||
|
|
||||||
|
test.describe('Core UI Patterns - Building Blocks', () => {
|
||||||
|
test.describe('Entry Point: Home Page', () => {
|
||||||
|
test('should navigate from home', async ({ n8n }) => {
|
||||||
|
await n8n.start.fromHome();
|
||||||
|
expect(n8n.page.url()).toContain('/home/workflows');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Entry Point: Blank Canvas', () => {
|
||||||
|
test('should navigate from blank canvas', async ({ n8n }) => {
|
||||||
|
await n8n.start.fromBlankCanvas();
|
||||||
|
await expect(n8n.canvas.canvasPane()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Entry Point: Basic Workflow Creation', () => {
|
||||||
|
test('should create a new project and workflow', async ({ n8n }) => {
|
||||||
|
await n8n.start.fromNewProject();
|
||||||
|
await expect(n8n.canvas.canvasPane()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Entry Point: Imported Workflow', () => {
|
||||||
|
test('should import a webhook workflow', async ({ n8n }) => {
|
||||||
|
const workflowImportResult = await n8n.start.fromImportedWorkflow('simple-webhook-test.json');
|
||||||
|
const { webhookPath } = workflowImportResult;
|
||||||
|
|
||||||
|
const testPayload = { message: 'Hello from Playwright test' };
|
||||||
|
|
||||||
|
await n8n.canvas.clickExecuteWorkflowButton();
|
||||||
|
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
|
||||||
|
|
||||||
|
const webhookResponse = await n8n.page.request.post(`/webhook-test/${webhookPath}`, {
|
||||||
|
data: testPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(webhookResponse.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should import a workflow', async ({ n8n }) => {
|
||||||
|
await n8n.start.fromImportedWorkflow('manual.json');
|
||||||
|
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Success');
|
||||||
|
await expect(n8n.canvas.canvasPane()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import { test, expect } from '../../fixtures/base';
|
import { test, expect } from '../../fixtures/base';
|
||||||
import { importAndActivateWebhookWorkflow, triggerWebhook } from '../../services/webhook-helper';
|
|
||||||
|
|
||||||
test.describe('External Webhook Triggering @auth:owner', () => {
|
test.describe('External Webhook Triggering', () => {
|
||||||
test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({
|
test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({
|
||||||
api,
|
api,
|
||||||
}) => {
|
}) => {
|
||||||
const { webhookPath, workflowId } = await importAndActivateWebhookWorkflow(
|
const { webhookPath, workflowId } = await api.workflowApi.importWorkflow(
|
||||||
api,
|
|
||||||
'simple-webhook-test.json',
|
'simple-webhook-test.json',
|
||||||
);
|
);
|
||||||
|
|
||||||
const testPayload = { message: 'Hello from Playwright test' };
|
const testPayload = { message: 'Hello from Playwright test' };
|
||||||
|
|
||||||
const webhookResponse = await triggerWebhook(api, webhookPath, {
|
const webhookResponse = await api.request.post(`/webhook/${webhookPath}`, {
|
||||||
data: testPayload,
|
data: testPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(webhookResponse.ok()).toBe(true);
|
expect(webhookResponse.ok()).toBe(true);
|
||||||
|
|
||||||
const execution = await api.workflowApi.waitForExecution(workflowId, 10000);
|
const execution = await api.workflowApi.waitForExecution(workflowId, 5000);
|
||||||
expect(execution.status).toBe('success');
|
expect(execution.status).toBe('success');
|
||||||
|
|
||||||
const executionDetails = await api.workflowApi.getExecution(execution.id);
|
const executionDetails = await api.workflowApi.getExecution(execution.id);
|
||||||
|
|||||||
22
packages/testing/playwright/workflows/manual.json
Normal file
22
packages/testing/playwright/workflows/manual.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Manual Trigger Workflow",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"type": "n8n-nodes-base.manualTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [0, 0],
|
||||||
|
"id": "0567e1a2-e1cb-480f-9a17-8f37b88bbe7f",
|
||||||
|
"name": "When clicking ‘Execute workflow’"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"versionId": "2071c8bd-6e92-4eb6-81cd-3d693f683955",
|
||||||
|
"id": "YaCQ6nNLk4xJabWT",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Simple Webhook Test",
|
"name": "Simple Webhook Test",
|
||||||
"active": false,
|
"active": true,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -3129,9 +3129,18 @@ importers:
|
|||||||
generate-schema:
|
generate-schema:
|
||||||
specifier: 2.6.0
|
specifier: 2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
|
n8n:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../cli
|
||||||
n8n-containers:
|
n8n-containers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../containers
|
version: link:../containers
|
||||||
|
n8n-core:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../core
|
||||||
|
n8n-workflow:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../workflow
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.3.8
|
version: 3.3.8
|
||||||
@@ -18795,7 +18804,7 @@ snapshots:
|
|||||||
'@currents/commit-info': 1.0.1-beta.0
|
'@currents/commit-info': 1.0.1-beta.0
|
||||||
async-retry: 1.3.3
|
async-retry: 1.3.3
|
||||||
axios: 1.11.0(debug@4.4.1)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
axios-retry: 4.5.0(axios@1.11.0(debug@4.4.1))
|
axios-retry: 4.5.0(axios@1.11.0)
|
||||||
c12: 1.11.2(magicast@0.3.5)
|
c12: 1.11.2(magicast@0.3.5)
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
@@ -23479,14 +23488,9 @@ snapshots:
|
|||||||
|
|
||||||
axe-core@4.7.2: {}
|
axe-core@4.7.2: {}
|
||||||
|
|
||||||
axios-retry@4.5.0(axios@1.11.0(debug@4.4.1)):
|
|
||||||
dependencies:
|
|
||||||
axios: 1.11.0(debug@4.4.1)
|
|
||||||
is-retry-allowed: 2.2.0
|
|
||||||
|
|
||||||
axios-retry@4.5.0(axios@1.11.0):
|
axios-retry@4.5.0(axios@1.11.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.11.0(debug@4.3.6)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
is-retry-allowed: 2.2.0
|
is-retry-allowed: 2.2.0
|
||||||
|
|
||||||
axios-retry@4.5.0(axios@1.8.3):
|
axios-retry@4.5.0(axios@1.8.3):
|
||||||
@@ -23851,7 +23855,7 @@ snapshots:
|
|||||||
|
|
||||||
bundlemon@3.1.0(typescript@5.9.2):
|
bundlemon@3.1.0(typescript@5.9.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.11.0(debug@4.3.6)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
axios-retry: 4.5.0(axios@1.11.0)
|
axios-retry: 4.5.0(axios@1.11.0)
|
||||||
brotli-size: 4.0.0
|
brotli-size: 4.0.0
|
||||||
bundlemon-utils: 2.0.1
|
bundlemon-utils: 2.0.1
|
||||||
@@ -27000,7 +27004,7 @@ snapshots:
|
|||||||
|
|
||||||
infisical-node@1.3.0:
|
infisical-node@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.11.0(debug@4.3.6)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
dotenv: 16.3.1
|
dotenv: 16.3.1
|
||||||
tweetnacl: 1.0.3
|
tweetnacl: 1.0.3
|
||||||
tweetnacl-util: 0.15.1
|
tweetnacl-util: 0.15.1
|
||||||
@@ -28207,7 +28211,7 @@ snapshots:
|
|||||||
'@langchain/groq': 0.2.3(@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)(zod@3.25.67)))(encoding@0.1.13)
|
'@langchain/groq': 0.2.3(@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)(zod@3.25.67)))(encoding@0.1.13)
|
||||||
'@langchain/mistralai': 0.2.1(@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)(zod@3.25.67)))(zod@3.25.67)
|
'@langchain/mistralai': 0.2.1(@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)(zod@3.25.67)))(zod@3.25.67)
|
||||||
'@langchain/ollama': 0.2.3(@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)(zod@3.25.67)))
|
'@langchain/ollama': 0.2.3(@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)(zod@3.25.67)))
|
||||||
axios: 1.11.0(debug@4.3.6)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
cheerio: 1.0.0
|
cheerio: 1.0.0
|
||||||
handlebars: 4.7.8
|
handlebars: 4.7.8
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -30259,7 +30263,7 @@ snapshots:
|
|||||||
|
|
||||||
posthog-node@3.2.1:
|
posthog-node@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.11.0(debug@4.3.6)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
rusha: 0.8.14
|
rusha: 0.8.14
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
@@ -30938,7 +30942,7 @@ snapshots:
|
|||||||
|
|
||||||
retry-axios@2.6.0(axios@1.11.0):
|
retry-axios@2.6.0(axios@1.11.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.11.0(debug@4.3.6)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
|
|
||||||
retry-request@7.0.2(encoding@0.1.13):
|
retry-request@7.0.2(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -31443,7 +31447,7 @@ snapshots:
|
|||||||
asn1.js: 5.4.1
|
asn1.js: 5.4.1
|
||||||
asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1)
|
asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1)
|
||||||
asn1.js-rfc5280: 3.0.0
|
asn1.js-rfc5280: 3.0.0
|
||||||
axios: 1.11.0(debug@4.3.6)
|
axios: 1.11.0(debug@4.4.1)
|
||||||
big-integer: 1.6.52
|
big-integer: 1.6.52
|
||||||
bignumber.js: 9.1.2
|
bignumber.js: 9.1.2
|
||||||
binascii: 0.0.2
|
binascii: 0.0.2
|
||||||
|
|||||||
Reference in New Issue
Block a user