test(editor): Add an E2E test for the workflow reviews round trip (#36906)

This commit is contained in:
Kai
2026-08-28 09:03:23 +00:00
committed by GitHub
parent b54500d438
commit 846bb4bab8
6 changed files with 308 additions and 0 deletions
@@ -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();
});
},
);