mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
test(editor): Add an E2E test for the workflow reviews round trip (#36906)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
/** The review inbox at `/reviews`: request list, activity feed and decision popover. */
|
||||
export class WorkflowReviewsPage extends BasePage {
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/reviews');
|
||||
await this.page.getByTestId('workflow-review-requests-view').waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
getRequestRow(title: string): Locator {
|
||||
return this.page.getByTestId('workflow-review-request-row').filter({ hasText: title });
|
||||
}
|
||||
|
||||
getSelectedRequestTitle(): Locator {
|
||||
return this.page.getByTestId('workflow-review-request-title');
|
||||
}
|
||||
|
||||
getActivityFeed(): Locator {
|
||||
return this.page.getByTestId('workflow-review-activity-feed');
|
||||
}
|
||||
|
||||
getActivityEntries(): Locator {
|
||||
return this.page.getByTestId('workflow-review-activity-entry');
|
||||
}
|
||||
|
||||
/** Status dot in the detail header. Its `aria-label` reads e.g. "Closed • Approved". */
|
||||
getSelectedRequestStatus(): Locator {
|
||||
return this.page
|
||||
.getByTestId('workflow-review-request-title-row')
|
||||
.getByTestId('workflow-review-request-status-dot');
|
||||
}
|
||||
|
||||
/** Only shown once the review is approved and the version it pinned is published. */
|
||||
getClosedCallout(): Locator {
|
||||
return this.page.getByTestId('workflow-review-closed-callout');
|
||||
}
|
||||
|
||||
getDecisionTrigger(): Locator {
|
||||
return this.page.getByTestId('workflow-review-decision-trigger');
|
||||
}
|
||||
|
||||
async openRequest(title: string): Promise<void> {
|
||||
await this.getRequestRow(title).click();
|
||||
await this.getActivityFeed().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
async postComment(body: string): Promise<void> {
|
||||
const composer = this.getCommentComposerInput();
|
||||
await composer.fill(body);
|
||||
await Promise.all([this.waitForRestResponse('/comments', 'POST'), composer.press('Enter')]);
|
||||
}
|
||||
|
||||
async requestChanges(note: string): Promise<void> {
|
||||
await this.decide(note, 'workflow-review-decision-request-changes-button');
|
||||
}
|
||||
|
||||
/** Approving also publishes the version the review pinned. */
|
||||
async approve(note: string): Promise<void> {
|
||||
await this.decide(note, 'workflow-review-decision-approve-button');
|
||||
}
|
||||
|
||||
private getCommentComposerInput(): Locator {
|
||||
return this.page.getByTestId('workflow-review-comment-composer').locator('textarea');
|
||||
}
|
||||
|
||||
/** The popover requires a note before it will accept either decision. */
|
||||
private async decide(note: string, buttonTestId: string): Promise<void> {
|
||||
await this.getDecisionTrigger().click();
|
||||
const popover = this.page.getByTestId('workflow-review-decision-popover');
|
||||
await popover.waitFor({ state: 'visible' });
|
||||
await popover.getByTestId('workflow-review-decision-note').fill(note);
|
||||
await Promise.all([
|
||||
this.waitForRestResponse('/decision', 'POST'),
|
||||
popover.getByTestId(buttonTestId).click(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
|
||||
import { BasePage } from '../BasePage';
|
||||
|
||||
/** The review surfaces on the workflow editor header, as an author sees them. */
|
||||
export class WorkflowReviewControls extends BasePage {
|
||||
/** Only present while a review is open on this workflow. */
|
||||
getStatusPill(): Locator {
|
||||
return this.page.getByTestId('workflow-review-status-pill');
|
||||
}
|
||||
|
||||
getPublishChoiceDialog(): Locator {
|
||||
return this.page.getByTestId('workflow-publish-choice-dialog');
|
||||
}
|
||||
|
||||
async chooseSubmitForReview(): Promise<void> {
|
||||
await this.clickByTestId('workflow-submit-for-review-choice-button');
|
||||
}
|
||||
|
||||
/** Ends by dismissing the confirmation dialog, so the canvas is usable again. */
|
||||
async submitForReview(options: {
|
||||
versionName: string;
|
||||
title: string;
|
||||
reviewerEmail: string;
|
||||
}): Promise<void> {
|
||||
await this.page.getByTestId('workflow-submit-for-review-dialog').waitFor({ state: 'visible' });
|
||||
await this.fillByTestId('workflow-review-version-name-input', options.versionName);
|
||||
await this.clickByTestId('workflow-review-next-button');
|
||||
|
||||
await this.fillByTestId('workflow-review-title-input', options.title);
|
||||
await this.selectReviewer(options.reviewerEmail);
|
||||
|
||||
await Promise.all([
|
||||
this.waitForRestResponse('/rest/workflow-review-requests', 'POST'),
|
||||
this.clickByTestId('workflow-review-submit-button'),
|
||||
]);
|
||||
|
||||
await this.clickByTestId('workflow-review-submitted-got-it-button');
|
||||
}
|
||||
|
||||
/** Adds the currently saved version to the open review. */
|
||||
async submitChangesToReview(versionName: string): Promise<void> {
|
||||
await this.getStatusPill().click();
|
||||
await this.clickByTestId('workflow-review-submit-changes-button');
|
||||
|
||||
await this.page.getByTestId('workflow-update-review-dialog').waitFor({ state: 'visible' });
|
||||
await this.fillByTestId('workflow-update-review-version-name-input', versionName);
|
||||
await this.clickByTestId('workflow-update-review-next-button');
|
||||
|
||||
await Promise.all([
|
||||
this.waitForRestResponse('/update-version', 'POST'),
|
||||
this.clickByTestId('workflow-update-review-submit-button'),
|
||||
]);
|
||||
}
|
||||
|
||||
private async selectReviewer(email: string): Promise<void> {
|
||||
await this.clickByTestId('workflow-review-reviewer-select');
|
||||
// The list filters as you type, so the full email narrows it to one option
|
||||
await this.page.getByTestId('workflow-review-reviewer-select').locator('input').fill(email);
|
||||
await this.getVisiblePopoverOption().filter({ hasText: email }).click();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { ProjectTabsComponent } from './components/ProjectTabsComponent';
|
||||
import { ResourceMoveModal } from './components/ResourceMoveModal';
|
||||
import { SecretsProviderConnectionModal } from './components/SecretsProviderConnectionModal';
|
||||
import { WorkflowMenu } from './components/WorkflowMenu';
|
||||
import { WorkflowReviewControls } from './components/WorkflowReviewControls';
|
||||
import { CredentialsPage } from './CredentialsPage';
|
||||
import { DataTableDetails } from './DataTableDetails';
|
||||
import { DataTableView } from './DataTableView';
|
||||
@@ -51,6 +52,7 @@ import { VersionsPage } from './VersionsPage';
|
||||
import { WorkerViewPage } from './WorkerViewPage';
|
||||
import { WorkflowActivationModal } from './WorkflowActivationModal';
|
||||
import { WorkflowCredentialSetupModal } from './WorkflowCredentialSetupModal';
|
||||
import { WorkflowReviewsPage } from './WorkflowReviewsPage';
|
||||
import { WorkflowSettingsModal } from './WorkflowSettingsModal';
|
||||
import { WorkflowSharingModal } from './WorkflowSharingModal';
|
||||
import { WorkflowsPage } from './WorkflowsPage';
|
||||
@@ -102,6 +104,7 @@ export class n8nPage {
|
||||
readonly variables: VariablesPage;
|
||||
readonly versions: VersionsPage;
|
||||
readonly workerView: WorkerViewPage;
|
||||
readonly workflowReviews: WorkflowReviewsPage;
|
||||
readonly workflows: WorkflowsPage;
|
||||
readonly notifications: NotificationsPage;
|
||||
readonly credentials: CredentialsPage;
|
||||
@@ -119,6 +122,7 @@ export class n8nPage {
|
||||
readonly projectTabs: ProjectTabsComponent;
|
||||
readonly commandBar: CommandBar;
|
||||
readonly workflowMenu: WorkflowMenu;
|
||||
readonly workflowReviewControls: WorkflowReviewControls;
|
||||
|
||||
readonly settingsEnvironment: SettingsEnvironmentPage;
|
||||
readonly secretsProviderSettings: SecretsProviderSettingsPage;
|
||||
@@ -187,6 +191,7 @@ export class n8nPage {
|
||||
this.variables = new VariablesPage(page);
|
||||
this.versions = new VersionsPage(page);
|
||||
this.workerView = new WorkerViewPage(page);
|
||||
this.workflowReviews = new WorkflowReviewsPage(page);
|
||||
this.workflows = new WorkflowsPage(page);
|
||||
this.notifications = new NotificationsPage(page);
|
||||
this.credentials = new CredentialsPage(page);
|
||||
@@ -208,6 +213,7 @@ export class n8nPage {
|
||||
this.projectTabs = new ProjectTabsComponent(page);
|
||||
this.commandBar = new CommandBar(page);
|
||||
this.workflowMenu = new WorkflowMenu(page);
|
||||
this.workflowReviewControls = new WorkflowReviewControls(page);
|
||||
|
||||
// Modals
|
||||
this.workflowActivationModal = new WorkflowActivationModal(page);
|
||||
|
||||
@@ -343,6 +343,23 @@ export class ApiHelpers {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend modules this instance started with. A module the license did not
|
||||
* cover at boot is missing here, and `enableFeature` cannot add it later.
|
||||
*/
|
||||
async getActiveModules(): Promise<string[]> {
|
||||
const response = await this.request.get('/rest/settings');
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(
|
||||
`GET /rest/settings failed (${response.status()}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = await response.json();
|
||||
return data.activeModules ?? [];
|
||||
}
|
||||
|
||||
// ===== CONVENIENCE METHODS =====
|
||||
|
||||
async enableFeature(feature: string): Promise<void> {
|
||||
|
||||
@@ -40,6 +40,22 @@ export class SecuritySettingsApiHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the instance-wide workflow reviews policy on or off. Needs
|
||||
* `feat:workflowReviews` as well as the license noted above.
|
||||
*/
|
||||
async setWorkflowReviewsEnabled(enabled: boolean): Promise<void> {
|
||||
const response = await this.api.request.post('/rest/settings/security', {
|
||||
data: { workflowReviews: { enabled } },
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(
|
||||
`POST /rest/settings/security failed (${response.status()}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setRedactionFloorRaw(floor: RedactionFloor): Promise<APIResponse> {
|
||||
return await this.api.request.post('/rest/settings/security', {
|
||||
data: { redactionEnforcement: { floor } },
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { EDIT_FIELDS_SET_NODE_NAME } from '../../../config/constants';
|
||||
import { expect, test } from '../../../fixtures/base';
|
||||
import type { ApiHelpers } from '../../../services/api-helper';
|
||||
|
||||
// Turning reviews on changes what the Publish button does for every workflow on
|
||||
// the instance, so this spec needs an instance of its own.
|
||||
test.use({ capability: { env: { TEST_ISOLATION: 'workflow-reviews' } } });
|
||||
|
||||
/** Approval publishes the workflow, so it needs a trigger that can be activated. */
|
||||
function scheduleTriggerNode() {
|
||||
return {
|
||||
id: nanoid(),
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { rule: { interval: [{ field: 'days' }] } },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The reviews module is only started at boot, and only if the license allows it,
|
||||
* so `enableFeature` alone cannot switch it on. Check it up front to fail with a
|
||||
* clear reason instead of a 404 halfway through the flow.
|
||||
*/
|
||||
async function assertReviewsModuleActive(api: ApiHelpers): Promise<void> {
|
||||
expect(
|
||||
await api.getActiveModules(),
|
||||
'the workflow-reviews module is not active: the instance needs a license granting feat:workflowReviews at startup',
|
||||
).toContain('workflow-reviews');
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Workflow reviews @licensed',
|
||||
{ annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }] },
|
||||
() => {
|
||||
test('author and reviewer complete a review round trip', async ({ n8n, api }) => {
|
||||
await api.enableFeature('workflowReviews');
|
||||
await api.enableFeature('personalSpacePolicy');
|
||||
await api.securitySettings.setWorkflowReviewsEnabled(true);
|
||||
await assertReviewsModuleActive(api);
|
||||
|
||||
// Emails are lowercased when saved, and the reviewer picker filters
|
||||
// case-sensitively, so a mixed-case email would match nothing.
|
||||
const author = await api.publicApi.createUser({
|
||||
email: `author-${nanoid().toLowerCase()}@test.com`,
|
||||
});
|
||||
const reviewer = await api.publicApi.createUser({
|
||||
email: `reviewer-${nanoid().toLowerCase()}@test.com`,
|
||||
});
|
||||
// The author needs workflow:publish to submit, the reviewer workflow:read to decide
|
||||
const project = await api.projects.createProject(`Reviews ${nanoid(8)}`);
|
||||
await api.projects.addUserToProject(project.id, author.id, 'project:admin');
|
||||
await api.projects.addUserToProject(project.id, reviewer.id, 'project:editor');
|
||||
|
||||
const authorApi = await api.createApiForUser(author);
|
||||
const workflowName = `Review Workflow ${nanoid(8)}`;
|
||||
const workflow = await authorApi.workflows.createWorkflow(
|
||||
{ name: workflowName, nodes: [scheduleTriggerNode()], connections: {}, settings: {} },
|
||||
project.id,
|
||||
);
|
||||
|
||||
const reviewTitle = `Review ${nanoid(8)}`;
|
||||
const authorN8n = await n8n.start.withUser(author);
|
||||
const reviewerN8n = await n8n.start.withUser(reviewer);
|
||||
|
||||
// --- The author submits the saved version for review ---
|
||||
await authorN8n.start.fromExistingWorkflow(workflow.id);
|
||||
await authorN8n.canvas.getOpenPublishModalButton().click();
|
||||
await expect(authorN8n.workflowReviewControls.getPublishChoiceDialog()).toBeVisible();
|
||||
await authorN8n.workflowReviewControls.chooseSubmitForReview();
|
||||
await authorN8n.workflowReviewControls.submitForReview({
|
||||
versionName: 'Release candidate',
|
||||
title: reviewTitle,
|
||||
reviewerEmail: reviewer.email,
|
||||
});
|
||||
|
||||
await expect(authorN8n.workflowReviewControls.getStatusPill()).toHaveText(
|
||||
'Waiting for review',
|
||||
);
|
||||
|
||||
// --- The reviewer finds it in the inbox, comments, and requests changes ---
|
||||
await reviewerN8n.workflowReviews.goto();
|
||||
await reviewerN8n.workflowReviews.openRequest(reviewTitle);
|
||||
await expect(reviewerN8n.workflowReviews.getSelectedRequestTitle()).toHaveText(reviewTitle);
|
||||
await expect(reviewerN8n.workflowReviews.getRequestRow(reviewTitle)).toContainText(
|
||||
workflowName,
|
||||
);
|
||||
|
||||
const comment = `Please rename the trigger ${nanoid(6)}`;
|
||||
await reviewerN8n.workflowReviews.postComment(comment);
|
||||
await expect(
|
||||
reviewerN8n.workflowReviews.getActivityEntries().filter({ hasText: comment }),
|
||||
).toBeVisible();
|
||||
|
||||
await reviewerN8n.workflowReviews.requestChanges('Renaming needed before this goes live');
|
||||
|
||||
// --- The author sees the decision, pushed to the open editor ---
|
||||
await expect(authorN8n.workflowReviewControls.getStatusPill()).toHaveText(
|
||||
'Changes requested',
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// --- The author saves a new version and pushes it into the review ---
|
||||
await authorN8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
|
||||
await authorN8n.canvas.waitForSaveWorkflowCompleted();
|
||||
await expect(authorN8n.workflowReviewControls.getStatusPill()).toHaveText('Update review');
|
||||
await authorN8n.workflowReviewControls.submitChangesToReview('Release candidate 2');
|
||||
|
||||
// --- The reviewer approves, which publishes the reviewed version ---
|
||||
await reviewerN8n.workflowReviews.approve('Looks good now');
|
||||
await expect(reviewerN8n.workflowReviews.getSelectedRequestStatus()).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Closed • Approved',
|
||||
);
|
||||
// Deciding refetches the review, so this summary has to appear on its own
|
||||
await expect(reviewerN8n.workflowReviews.getClosedCallout()).toBeVisible();
|
||||
|
||||
// Approval publishes the workflow on the author's behalf. Reloading because an
|
||||
// editor that was already open does not pick this up by itself.
|
||||
await authorN8n.page.reload();
|
||||
await authorN8n.canvas.waitForCanvasReady();
|
||||
await expect(authorN8n.canvas.getPublishedIndicator()).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user