diff --git a/e2e/pages/project.ts b/e2e/pages/project.ts index c11e6d9287..413075482c 100644 --- a/e2e/pages/project.ts +++ b/e2e/pages/project.ts @@ -47,4 +47,14 @@ export class ProjectPage { const deploymentTableRow = this.page.getByRole("cell", { name: "inactive" }); await expect(deploymentTableRow).toHaveCount(1); } + + async acknowledgeDeploymentWarning() { + const warningText = this.page.getByText("Changes might affect the currently running deployments."); + try { + await expect(warningText).toBeVisible({ timeout: 2000 }); + await this.page.getByRole("button", { name: "Ok" }).click(); + } catch { + // Warning not present - no active deployment, continue without acknowledging + } + } } diff --git a/e2e/project/triggers/triggerBase.spec.ts b/e2e/project/triggers/triggerBase.spec.ts index 34ce947ad4..dc88d0be8a 100644 --- a/e2e/project/triggers/triggerBase.spec.ts +++ b/e2e/project/triggers/triggerBase.spec.ts @@ -64,7 +64,8 @@ async function modifyTrigger( name: string, newCronExpression: string, newFunctionName: string, - withActiveDeployment: boolean + withActiveDeployment: boolean, + projectPage: { acknowledgeDeploymentWarning: () => Promise } ) { if (withActiveDeployment) { const deployButton = page.getByRole("button", { name: "Deploy project" }); @@ -76,8 +77,7 @@ async function modifyTrigger( await page.getByRole("button", { name: `Modify ${name} trigger` }).click(); if (withActiveDeployment) { - await expect(page.getByText("Changes might affect the currently running deployments.")).toBeVisible(); - await page.getByRole("button", { name: "Ok" }).click(); + await projectPage.acknowledgeDeploymentWarning(); } const cronInput = page.getByRole("textbox", { name: "Cron expression" }); @@ -127,7 +127,7 @@ test.describe("Project Triggers Suite", () => { test.describe("Modify trigger with cron expression", () => { testModifyCases.forEach(({ description, expectedFileFunction, modifyParams }) => { - test(`Modify trigger ${description}`, async ({ page }) => { + test(`Modify trigger ${description}`, async ({ page, projectPage }) => { await createTriggerScheduler(page, triggerName, "5 4 * * *", "program.py", "on_trigger"); await page.getByRole("button", { name: "Return back" }).click(); @@ -136,7 +136,8 @@ test.describe("Project Triggers Suite", () => { triggerName, modifyParams.cron, modifyParams.on_trigger, - modifyParams.withActiveDeployment + modifyParams.withActiveDeployment, + projectPage ); await verifyFormValues(page, modifyParams.cron, modifyParams.on_trigger); diff --git a/e2e/project/triggers/triggerSyncResponse.spec.ts b/e2e/project/triggers/triggerSyncResponse.spec.ts new file mode 100644 index 0000000000..e065c2763f --- /dev/null +++ b/e2e/project/triggers/triggerSyncResponse.spec.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Page } from "@playwright/test"; + +import { expect, test } from "e2e/fixtures"; +import { waitForToast } from "e2e/utils"; + +const triggerName = "syncTriggerTest"; + +async function createTriggerWithSync( + page: Page, + name: string, + cronExpression: string, + fileName: string, + on_trigger: string, + isSync: boolean +) { + await page.getByRole("button", { name: "Add new" }).click(); + + const nameInput = page.getByRole("textbox", { name: "Name", exact: true }); + await nameInput.click(); + await nameInput.fill(name); + + await page.getByTestId("select-trigger-type").click(); + await page.getByRole("option", { name: "Scheduler" }).click(); + + const cronInput = page.getByRole("textbox", { name: "Cron expression" }); + await cronInput.click(); + await cronInput.fill(cronExpression); + + await page.getByTestId("select-file").click(); + await page.getByRole("option", { name: fileName }).click(); + + const functionNameInput = page.getByRole("textbox", { name: "Function name" }); + await functionNameInput.click(); + await functionNameInput.fill(on_trigger); + + if (isSync) { + const syncToggle = page.locator('label:has-text("Synchronous Response")'); + await syncToggle.click(); + } + + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await expect(nameInput).toBeDisabled(); + await expect(nameInput).toHaveValue(name); +} + +async function verifySyncToggleState(page: Page, expectedState: boolean) { + // Wait for the sync label to exist first - use a more flexible selector + const syncLabel = page.locator('label:has-text("Synchronous Response")').first(); + await syncLabel.waitFor({ state: "attached", timeout: 10000 }); + + // The checkbox is inside the label + const syncCheckbox = syncLabel.locator('input[type="checkbox"]'); + + if (expectedState) { + await expect(syncCheckbox).toBeChecked({ timeout: 10000 }); + } else { + await expect(syncCheckbox).not.toBeChecked({ timeout: 10000 }); + } +} + +async function toggleSyncResponse(page: Page, enable: boolean) { + const syncCheckbox = page.locator('label:has-text("Synchronous Response") input[type="checkbox"]'); + const currentState = await syncCheckbox.isChecked(); + + if (currentState !== enable) { + const syncLabel = page.locator('label:has-text("Synchronous Response")'); + await syncLabel.click(); + } +} + +test.describe("Trigger Synchronous Response Suite", () => { + test.beforeEach(async ({ dashboardPage, page }) => { + await dashboardPage.createProjectFromMenu(); + await page.getByRole("tab", { name: "Triggers" }).click(); + }); + + test("Create trigger with Synchronous Response enabled", async ({ page }) => { + await createTriggerWithSync(page, triggerName, "5 4 * * *", "program.py", "on_trigger", true); + + await verifySyncToggleState(page, true); + + await page.getByRole("button", { name: "Return back" }).click(); + + const newRowInTable = page.getByRole("row", { name: triggerName }); + await expect(newRowInTable).toHaveCount(1); + }); + + test("Create trigger with Synchronous Response disabled", async ({ page }) => { + await createTriggerWithSync(page, triggerName, "5 4 * * *", "program.py", "on_trigger", false); + + await verifySyncToggleState(page, false); + + await page.getByRole("button", { name: "Return back" }).click(); + + const newRowInTable = page.getByRole("row", { name: triggerName }); + await expect(newRowInTable).toHaveCount(1); + }); + + test("Modify trigger to enable Synchronous Response", async ({ page, projectPage }) => { + await createTriggerWithSync(page, triggerName, "5 4 * * *", "program.py", "on_trigger", false); + + await verifySyncToggleState(page, false); + + await page.getByRole("button", { name: "Return back" }).click(); + + await page.getByRole("button", { name: `Modify ${triggerName} trigger` }).click(); + + await projectPage.acknowledgeDeploymentWarning(); + + await toggleSyncResponse(page, true); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await page.getByRole("button", { name: "Return back" }).click(); + + await page.getByRole("button", { name: `Modify ${triggerName} trigger` }).click(); + + await verifySyncToggleState(page, true); + }); + + test("Modify trigger to disable Synchronous Response", async ({ page }) => { + await createTriggerWithSync(page, triggerName, "5 4 * * *", "program.py", "on_trigger", true); + + await verifySyncToggleState(page, true); + + await toggleSyncResponse(page, false); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await page.getByRole("button", { name: "Return back" }).click(); + + await page.getByRole("button", { name: `Modify ${triggerName} trigger` }).click(); + + await verifySyncToggleState(page, false); + }); + + test("Synchronous Response state persists after navigation", async ({ page }) => { + await createTriggerWithSync(page, triggerName, "5 4 * * *", "program.py", "on_trigger", true); + + await verifySyncToggleState(page, true); + + await page.getByRole("button", { name: "Return back" }).click(); + + await page.getByRole("tab", { name: "Connections" }).click(); + + await page.getByRole("tab", { name: "Triggers" }).click(); + + await page.getByRole("button", { name: `Modify ${triggerName} trigger` }).click(); + + await verifySyncToggleState(page, true); + }); + + test("Synchronous Response works with Durability toggle", async ({ page }) => { + await page.getByRole("button", { name: "Add new" }).click(); + + const nameInput = page.getByRole("textbox", { name: "Name", exact: true }); + await nameInput.click(); + await nameInput.fill(triggerName); + + await page.getByTestId("select-trigger-type").click(); + await page.getByRole("option", { name: "Scheduler" }).click(); + + const cronInput = page.getByRole("textbox", { name: "Cron expression" }); + await cronInput.click(); + await cronInput.fill("5 4 * * *"); + + await page.getByTestId("select-file").click(); + await page.getByRole("option", { name: "program.py" }).click(); + + const functionNameInput = page.getByRole("textbox", { name: "Function name" }); + await functionNameInput.click(); + await functionNameInput.fill("on_trigger"); + + const durabilityLabel = page.locator('label:has-text("Durability - for long-running reliable workflows")'); + await durabilityLabel.click(); + + const syncLabel = page.locator('label:has-text("Synchronous Response")'); + await syncLabel.click(); + + const durabilityCheckbox = page.locator( + 'label:has-text("Durability - for long-running reliable workflows") input[type="checkbox"]' + ); + const syncCheckbox = page.locator('label:has-text("Synchronous Response") input[type="checkbox"]'); + + await expect(durabilityCheckbox).toBeChecked(); + await expect(syncCheckbox).toBeChecked(); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await expect(nameInput).toBeDisabled(); + await expect(durabilityCheckbox).toBeChecked(); + await expect(syncCheckbox).toBeChecked(); + }); + + test("Synchronous Response toggle visible in edit mode", async ({ page }) => { + await createTriggerWithSync(page, triggerName, "5 4 * * *", "program.py", "on_trigger", false); + + await page.getByRole("button", { name: "Return back" }).click(); + + await page.getByRole("button", { name: `Modify ${triggerName} trigger` }).click(); + + const syncLabel = page.locator('label:has-text("Synchronous Response")'); + await expect(syncLabel).toBeVisible(); + }); + + test("Modify trigger with active deployment preserves Synchronous Response state", async ({ + page, + projectPage, + }) => { + await createTriggerWithSync(page, triggerName, "5 4 * * *", "program.py", "on_trigger", true); + + await verifySyncToggleState(page, true); + + await page.getByRole("button", { name: "Return back" }).click(); + + const deployButton = page.getByRole("button", { name: "Deploy project" }); + await deployButton.click(); + const toast = await waitForToast(page, "Project deployment completed successfully"); + await expect(toast).toBeVisible(); + + // Wait for toast to disappear + await expect(toast).not.toBeVisible({ timeout: 10000 }); + + await page.getByRole("button", { name: `Modify ${triggerName} trigger` }).click(); + + await projectPage.acknowledgeDeploymentWarning(); + + // Wait for form to be fully loaded, including the sync toggle section + const nameInput = page.getByRole("textbox", { name: "Name", exact: true }); + await nameInput.waitFor({ state: "visible" }); + + // Wait for the sync toggle label to appear (it's outside the form element) + const syncLabel = page.locator('label:has-text("Synchronous Response")'); + await syncLabel.waitFor({ state: "attached", timeout: 10000 }); + + await verifySyncToggleState(page, true); + + const cronInput = page.getByRole("textbox", { name: "Cron expression" }); + await cronInput.click(); + await cronInput.fill("6 5 * * *"); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await page.getByRole("button", { name: "Return back" }).click(); + + await page.getByRole("button", { name: `Modify ${triggerName} trigger` }).click(); + + await verifySyncToggleState(page, true); + }); + + test("Synchronous Response description is visible", async ({ page, projectPage }) => { + await page.getByRole("button", { name: "Add new" }).click(); + + await projectPage.acknowledgeDeploymentWarning(); + + // Wait for form to load + await page.getByRole("textbox", { name: "Name", exact: true }).waitFor({ state: "visible" }); + + const syncLabel = page.locator('label:has-text("Synchronous Response")'); + await syncLabel.scrollIntoViewIfNeeded(); + await expect(syncLabel).toBeVisible(); + + // The description is in an InfoPopover that appears on hover + // Find the info icon next to the Synchronous Response label and hover over it + const syncToggleWrapper = page.locator('div:has(label:has-text("Synchronous Response"))'); + const infoIcon = syncToggleWrapper.locator("svg, img").last(); + await infoIcon.hover(); + + // Now the popover should be visible with the description + const syncDescription = page.getByText("Allow scripts to send custom HTTP responses back to webhook callers"); + await expect(syncDescription).toBeVisible(); + }); +}); diff --git a/e2e/project/variable.spec.ts b/e2e/project/variable.spec.ts index 7914f77673..103ec846cc 100644 --- a/e2e/project/variable.spec.ts +++ b/e2e/project/variable.spec.ts @@ -40,14 +40,14 @@ test.describe("Project Variables Suite", () => { await expect(newVariableInTable).toBeVisible(); }); - test("Modify variable with active deployment", async ({ page }) => { + test("Modify variable with active deployment", async ({ page, projectPage }) => { const deployButton = page.getByRole("button", { name: "Deploy project" }); await deployButton.click(); const toast = await waitForToast(page, "Project successfully deployed with 1 warning"); await expect(toast).toBeVisible(); await page.getByRole("button", { name: "Modify nameVariable variable" }).click(); - await page.getByRole("button", { name: "Ok" }).click(); + await projectPage.acknowledgeDeploymentWarning(); await page.getByLabel("Value", { exact: true }).click(); await page.getByLabel("Value").fill("newValueVariable"); await page.getByRole("button", { name: "Save", exact: true }).click(); diff --git a/e2e/project/webhookSessionTriggered.spec.ts b/e2e/project/webhookSessionTriggered.spec.ts index ab3a56a836..48b142ae98 100644 --- a/e2e/project/webhookSessionTriggered.spec.ts +++ b/e2e/project/webhookSessionTriggered.spec.ts @@ -64,6 +64,7 @@ test.describe("Session triggered with webhook", () => { }); async function setupProjectAndTriggerSession({ dashboardPage, page, request }: SetupParams) { + const projectPage = new ProjectPage(page); await page.goto("/"); await page.getByRole("heading", { name: /^Welcome to .+$/, level: 1 }).isVisible(); @@ -118,8 +119,7 @@ async function setupProjectAndTriggerSession({ dashboardPage, page, request }: S await page.getByRole("tab", { name: "Triggers" }).click(); await page.getByRole("button", { name: "Modify receive_http_get_or_head trigger" }).click(); - await expect(page.getByText("Changes might affect the currently running deployments.")).toBeVisible(); - await page.getByRole("button", { name: "Ok" }).click(); + await projectPage.acknowledgeDeploymentWarning(); await page.waitForSelector('[data-testid="webhook-url"]'); diff --git a/src/components/atoms/toggle.tsx b/src/components/atoms/toggle.tsx index 97c44f8578..601d51e9f1 100644 --- a/src/components/atoms/toggle.tsx +++ b/src/components/atoms/toggle.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useId } from "react"; import { ToggleProps } from "@interfaces/components"; import { cn } from "@utilities"; @@ -14,12 +14,15 @@ export const Toggle = ({ checked, label, onChange, title, description, className "after:size-4 after:transition-all" ); + const toggleId = useId(); + return (
-