diff --git a/packages/testing/playwright/pages/WorkflowReviewsPage.ts b/packages/testing/playwright/pages/WorkflowReviewsPage.ts new file mode 100644 index 00000000000..2af36560f48 --- /dev/null +++ b/packages/testing/playwright/pages/WorkflowReviewsPage.ts @@ -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 { + 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 { + await this.getRequestRow(title).click(); + await this.getActivityFeed().waitFor({ state: 'visible' }); + } + + async postComment(body: string): Promise { + const composer = this.getCommentComposerInput(); + await composer.fill(body); + await Promise.all([this.waitForRestResponse('/comments', 'POST'), composer.press('Enter')]); + } + + async requestChanges(note: string): Promise { + await this.decide(note, 'workflow-review-decision-request-changes-button'); + } + + /** Approving also publishes the version the review pinned. */ + async approve(note: string): Promise { + 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 { + 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(), + ]); + } +} diff --git a/packages/testing/playwright/pages/components/WorkflowReviewControls.ts b/packages/testing/playwright/pages/components/WorkflowReviewControls.ts new file mode 100644 index 00000000000..6385421d2b9 --- /dev/null +++ b/packages/testing/playwright/pages/components/WorkflowReviewControls.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index b2a36140f76..69e0e07e4a5 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -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); diff --git a/packages/testing/playwright/services/api-helper.ts b/packages/testing/playwright/services/api-helper.ts index e30148ab772..ff825aa496d 100644 --- a/packages/testing/playwright/services/api-helper.ts +++ b/packages/testing/playwright/services/api-helper.ts @@ -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 { + 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 { diff --git a/packages/testing/playwright/services/security-settings-api-helper.ts b/packages/testing/playwright/services/security-settings-api-helper.ts index a7d7ffd3b3a..888baa08b6c 100644 --- a/packages/testing/playwright/services/security-settings-api-helper.ts +++ b/packages/testing/playwright/services/security-settings-api-helper.ts @@ -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 { + 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 { return await this.api.request.post('/rest/settings/security', { data: { redactionEnforcement: { floor } }, diff --git a/packages/testing/playwright/tests/e2e/workflow-reviews/review-round-trip.spec.ts b/packages/testing/playwright/tests/e2e/workflow-reviews/review-round-trip.spec.ts new file mode 100644 index 00000000000..03e2f9709ef --- /dev/null +++ b/packages/testing/playwright/tests/e2e/workflow-reviews/review-round-trip.spec.ts @@ -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 { + 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(); + }); + }, +);