mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(workflows): add workflow editor (#6248)
**Category:** new-feature **User Impact:** Users can create, edit, duplicate, and deep-link to workflows in a responsive visual editor without losing unsupported YAML or unsaved work. **Problem:** Workflow editing was split across disconnected surfaces and lacked reliable URL state, lifecycle protection, and parity between Form and YAML modes. **Solution:** This adds a route-addressable editor foundation with stable pane identity, guarded dirty exits, lossless Form/YAML transitions, responsive workflow and channel controls, and matching reaction-filter execution support. <details> <summary>File changes</summary> **crates/buzz-workflow/src/lib.rs** Apply reaction trigger filters during workflow execution and cover target-message gating. **crates/buzz-workflow/src/schema.rs** Extend the reaction trigger schema with the editor-owned filter field. **desktop/src/app/navigation/useAppNavigation.ts** Add navigation helpers for explicit workflow create, edit, and duplicate editor modes. **desktop/src/app/routes/WorkflowsRouteScreen.tsx** Coordinate route state with the shared workflow library and editor dialog. **desktop/src/app/routes/lazyWorkflowsRouteScreen.ts** Share one lazy route component across workflow route entry points to avoid loading flashes. **desktop/src/app/routes/workflows.$workflowId.tsx** Parse workflow editor modes and pane deep links for workflow-specific URLs. **desktop/src/app/routes/workflows.tsx** Parse library-level create state and render the shared workflow route screen. **desktop/src/app/AppWorkflowEditorOverlayProvider.tsx** Host the shared workflow editor at the app-shell level so channel-originated workflow dialogs stay above the active channel instead of replacing it. **desktop/src/shared/context/WorkflowEditorOverlayContext.tsx** Expose route-independent open-existing and create-new workflow actions to channel settings. **desktop/src/features/workflows/ui/WorkflowEditorHost.tsx** Share editor loading, unavailable, and dialog lifecycle wiring between canonical workflow routes and the channel overlay. **desktop/src/features/channels/ui/ChannelManagementSheet.tsx** Add an experiment-gated, Canvas-style Workflows ingress below Canvas, including channel-scoped loading, error, empty, and list states plus open/create actions; disabled users issue no workflow query. **desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx** Render the channel workflow list and New workflow action without pushing the existing settings sheet past its file-size ceiling. **desktop/src/features/workflows/ui/ChannelCombobox.tsx** Adopt the final channel presentation, portalled scrolling, and one-shot create-flow opening behavior. **desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx** Remove the superseded create-only dialog in favor of the unified workflow editor. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Open workflow cards in the detail and run-history modal while preserving explicit edit and duplicate actions. **desktop/src/features/workflows/ui/WorkflowDetailDialog.tsx** Present workflow Trigger/Steps in the shared modal chrome, with top-chrome ingress to a responsive right-side run-history inspector and an explicit edit action. **desktop/src/features/workflows/ui/WorkflowDialog.tsx** Unify create, edit, and duplicate lifecycle handling with URL panes, generated-name synchronization, dirty-exit guards, stale-write preservation, and protected webhook-secret handoff. **desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx** Build the responsive Form/YAML editor shell, stable step selection, insertion and removal behavior, and lossless canonical-YAML synchronization. **desktop/src/features/workflows/ui/WorkflowStepCard.tsx** Align workflow step controls and presentation with the final editor interaction model. **desktop/src/features/workflows/ui/WorkflowUnavailableDialog.tsx** Show a non-disclosing loading or unavailable state for missing and inaccessible workflow links, with retry and close actions. **desktop/src/features/workflows/ui/WorkflowWebhookSecretDialog.tsx** Obscure one-time webhook secrets by default and require explicit confirmation before any close or navigation discards them. **desktop/src/features/workflows/ui/WorkflowsScreen.tsx** Connect library state and workflow actions to the route-addressable editor. **desktop/src/features/workflows/ui/WorkflowsView.tsx** Restore the responsive workflow library, create tile, cards, loading states, and shared action menu. **desktop/src/features/workflows/ui/workflowEditorPane.test.mjs** Cover pane parsing, serialization, and stable step-ID reconciliation. **desktop/src/features/workflows/ui/workflowEditorPane.ts** Define explicit trigger and stable step pane URL state. **desktop/src/features/workflows/ui/workflowYamlDocument.ts** Read and update header fields independently of full form validation so incomplete steps cannot clear or disable the workflow title. **desktop/src/features/workflows/ui/workflowYamlDocument.test.mjs** Cover document-level workflow header reads and writes for incomplete definitions. **desktop/tests/e2e/workflow-title-stability.spec.ts** Verify generated, renamed, saved, and duplicated titles remain stable while moving between trigger and step panes. **desktop/src/features/workflows/ui/workflowFormTypes.test.mjs** Cover lossless Form/YAML round trips and actionable fallback for unsupported fields. **desktop/src/features/workflows/ui/workflowFormTypes.ts** Own canonical workflow YAML conversion while preserving supported trigger and step fields. **desktop/src/shared/ui/PortalledScrollArea.tsx** Provide bounded scrolling for popovers rendered outside their dialog container. **desktop/src/shared/ui/popover.tsx** Allow workflow popovers to use the shared portalled scroll container. **desktop/tests/e2e/channels.spec.ts** Cover disabled and enabled Workflows experiment states, including suppressed queries while disabled, placement beneath Canvas, channel workflow listing and opening, channel-preselected workflow creation, and direct return to the channel Workflows panel after close, discard, or cancel. **desktop/tests/e2e/workflows.spec.ts** Exercise library actions, deep links, create/edit/duplicate lifecycle, dirty exits, responsive editor behavior, YAML safety, stale updates, and one-shot channel selection. </details> ## Reproduction steps 1. Open **Workflows** and confirm the responsive card library, create tile, card action menu, and card-to-detail/run-history modal navigation. 2. Open `?view=create`; confirm the channel chooser opens once, the trigger inspector stays hidden until a channel is selected, and closing the chooser does not make it reopen after unrelated edits. 3. Create a workflow, switch between Form and YAML, add and remove steps, refresh a pane deep link, and confirm the selected trigger or stable step remains addressable. 4. Edit or duplicate a workflow, make an unsaved change, and confirm close, Escape, browser navigation, and route target changes require discard confirmation while pane-only navigation does not. 5. Enter unsupported YAML and confirm Form mode gives an actionable fallback without rewriting the definition; verify reaction triggers preserve and execute their filter. 6. Open a channel’s settings, select **Workflows** below **Canvas**, and open or create a workflow; confirm the shared modal stays over the channel, the channel URL does not change, New workflow preselects that channel, and closing or discarding returns directly to the channel’s **Workflows** panel. 7. In create, edit, and duplicate modes, move between the trigger and incomplete step panes and confirm the generated or edited title remains visible and editable. ## Screenshots Fresh captures from product head `c5c3abc91a71fe511d43e6cc9168b1626d0c217c`; the later review-guidance fix does not alter these pictured states. ### Workflow library and actions  ### Workflow editor — wide  ### Workflow editor — narrow inspector overlay  ### Workflow editor — active channel overlay  ## Review feedback addressed - Corrected Diff Posted condition guidance to use the executor-supported `str_contains(trigger_text, "deploy")` syntax and added a visible Playwright regression assertion. - Scoped direct workflow-detail navigation coverage to the stable `Edit workflow` dialog name, asserted the workflow title separately, and retained the trigger-node assertion. - Routed dirty channel-overlay Duplicate/Edit transitions through the existing discard confirmation, preserving the original YAML draft when the user keeps editing. - Made workflow deletion await relay success, remain non-dismissible while pending, and retain the confirmation/editor/draft with an actionable inline error on rejection. - Gated the channel-settings Workflows ingress and channel workflow query behind the `workflows` experiment, with defensive rendering if the flag changes while that view is active. - Matched the Reaction Added trigger to the prototype reaction picker, preserving native/custom/legacy values in canonical YAML and providing an explicit clear action. - Kept a channel’s Workflows panel mounted beneath channel-origin editors so clean close, dirty discard, and create cancel return directly to that panel without changing the channel URL. ## Verification Verified at exact pushed head `76ebba7b7ace1e13445744200da70ca9da231b7e`: - Push hooks passed: destination-org policy, branch-skew, differential file-size, Desktop checks, TypeScript typecheck, and **5,115/5,115** Desktop unit tests - Focused channel lifecycle E2E passed: dirty edit discard returns directly to the channel Workflows panel; create cancel does the same while preserving the channel URL and preselected channel - E2E production build, standalone TypeScript typecheck, and Biome checks on both touched files passed - Reaction-picker regression spec remains recorded at `dc28ffa98ec34b4e0656757fb2dd93e60a84674d`: **5/5 passed** (picker interaction, canonical YAML persistence/clear, legacy-value preservation, save/reopen round trip, narrow viewport containment) - Blox existing workflow E2E regression set at `dc28ffa98ec34b4e0656757fb2dd93e60a84674d`: **36/36 passed** - Earlier blocker and review regressions remain recorded at `3760c3d657a525f5af98e8d0f98bdd03999d8e61`: **5,114/5,114** Desktop unit tests and the dirty-overlay/deletion focused checks - Working tree clean; local branch, remote branch, and PR head all match the exact SHA above ### Related issue None found. Closest prior work: #231. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
@@ -13,6 +14,36 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectWorkflowChannel(
|
||||
page: import("@playwright/test").Page,
|
||||
dialog: import("@playwright/test").Locator,
|
||||
) {
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
if (!(await channelList.isVisible())) {
|
||||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||||
}
|
||||
await expect(channelList).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function editWorkflowName(
|
||||
dialog: import("@playwright/test").Locator,
|
||||
name: string,
|
||||
) {
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
const input = dialog.getByRole("textbox", { name: "Workflow name" });
|
||||
await input.fill(name);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
await expect(input).not.toBeVisible();
|
||||
await expect(dialog).toContainText(name);
|
||||
await expect(
|
||||
dialog.page().getByTestId("channel-combobox-list"),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function createWorkflow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
@@ -20,38 +51,67 @@ async function createWorkflow(
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
trigger?: string;
|
||||
stepCondition?: string;
|
||||
stepConditionText?: string;
|
||||
stepName?: string;
|
||||
stepTimeoutSecs?: string;
|
||||
},
|
||||
) {
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByLabel("Workflow name").fill(name);
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, name);
|
||||
if (options?.description) {
|
||||
await dialog.getByLabel("Description (optional)").fill(options.description);
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
const yaml = await yamlEditor.inputValue();
|
||||
await yamlEditor.fill(
|
||||
yaml.replace(
|
||||
`name: ${name}`,
|
||||
`name: ${name}\ndescription: ${JSON.stringify(options.description)}`,
|
||||
),
|
||||
);
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
}
|
||||
if (options?.enabled === false) {
|
||||
await dialog.getByLabel("Workflow is enabled").click();
|
||||
await dialog.getByRole("switch", { name: "Enable" }).click();
|
||||
}
|
||||
if (options?.trigger) {
|
||||
await dialog.getByLabel("Trigger").selectOption(options.trigger);
|
||||
await dialog
|
||||
.getByRole("button", { name: "Trigger: Message Posted" })
|
||||
.click();
|
||||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||||
await page
|
||||
.getByRole("menuitem", {
|
||||
name:
|
||||
options.trigger === "diff_posted"
|
||||
? "Diff Posted"
|
||||
: options.trigger === "reaction_added"
|
||||
? "Reaction Added"
|
||||
: options.trigger === "webhook"
|
||||
? "Webhook"
|
||||
: "Message Posted",
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Workflow notification");
|
||||
if (options?.stepName) {
|
||||
await dialog.getByLabel("Step name (optional)").fill(options.stepName);
|
||||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||||
await dialog.getByLabel("Name (optional)").fill(options.stepName);
|
||||
}
|
||||
if (options?.stepCondition) {
|
||||
await dialog
|
||||
.getByLabel("Run condition (optional)")
|
||||
.fill(options.stepCondition);
|
||||
if (options?.stepConditionText || options?.stepTimeoutSecs) {
|
||||
await dialog.getByRole("button", { name: "Run controls" }).click();
|
||||
}
|
||||
if (options?.stepConditionText) {
|
||||
await dialog.getByLabel("Text to match").fill(options.stepConditionText);
|
||||
}
|
||||
if (options?.stepTimeoutSecs) {
|
||||
await dialog
|
||||
.getByLabel("Timeout seconds (optional)")
|
||||
.getByRole("textbox", { name: "Timeout", exact: true })
|
||||
.fill(options.stepTimeoutSecs);
|
||||
}
|
||||
|
||||
@@ -71,6 +131,59 @@ test("navigates to workflows view and shows the empty create tile", async ({
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("creation reveals the trigger pane only after a one-shot channel pick", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
await expect(channelList).toBeVisible();
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(channelList).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() => dialog.getByText("Untitled workflow", { exact: true }).count())
|
||||
.toBe(0);
|
||||
await expect(channelList).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Trigger event" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Escape closes the selected inspector before the workflow modal", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const width of [760, 1280]) {
|
||||
await page.setViewportSize({ width, height: 820 });
|
||||
await page.goto(
|
||||
"/#/workflows?view=create&channel=94a444a4-c0a3-5966-ab05-530c6ddc2301&pane=trigger",
|
||||
);
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(inspector).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(inspector).not.toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).toHaveURL(/view=create/);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("creates a workflow via the form builder", async ({ page }) => {
|
||||
const workflowName = `test_workflow_${Date.now()}`;
|
||||
|
||||
@@ -81,21 +194,64 @@ test("creates a workflow via the form builder", async ({ page }) => {
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
});
|
||||
|
||||
test("accepts the generated name through the first form mutation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
const generatedName = (
|
||||
await dialog
|
||||
.getByRole("button", { name: "Edit workflow name" })
|
||||
.evaluate((button) => button.previousElementSibling?.textContent ?? "")
|
||||
).trim();
|
||||
expect(generatedName).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Add first step" }).click();
|
||||
await dialog.getByRole("button", { name: "Create workflow" }).click();
|
||||
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(generatedName);
|
||||
});
|
||||
|
||||
test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
|
||||
await expect(dialog.getByLabel("Workflow name")).toHaveAttribute(
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
await expect(
|
||||
dialog.getByRole("textbox", { name: "Workflow name" }),
|
||||
).toHaveAttribute("autocapitalize", "off");
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||||
await expect(dialog.getByLabel("Name (optional)")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
);
|
||||
});
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await expect(dialog.getByLabel("Step name (optional)")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
test("shows executable guidance for diff trigger conditions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: "Diff Posted", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(dialog.getByLabel("Condition (optional)")).toHaveAttribute(
|
||||
"placeholder",
|
||||
'e.g. str_contains(trigger_text, "deploy")',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,7 +266,7 @@ test("captures workflow library across responsive viewports", async ({
|
||||
});
|
||||
await createWorkflow(page, "Post the daily standup reminder to the team", {
|
||||
description: "Keeps the team aligned every morning",
|
||||
trigger: "schedule",
|
||||
trigger: "message_posted",
|
||||
});
|
||||
await createWorkflow(
|
||||
page,
|
||||
@@ -152,8 +308,8 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
enabled: false,
|
||||
trigger: "diff_posted",
|
||||
stepName: "Notify reviewers",
|
||||
stepCondition: 'str_contains(trigger_text, "src/")',
|
||||
stepTimeoutSecs: "45",
|
||||
stepConditionText: "src/",
|
||||
stepTimeoutSecs: "45s",
|
||||
});
|
||||
|
||||
const card = page
|
||||
@@ -182,11 +338,6 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
|
||||
|
||||
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
|
||||
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
const detailPanel = page.getByTestId("workflow-detail-panel");
|
||||
await expect(detailPanel).toBeVisible();
|
||||
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
|
||||
|
||||
await workflowActions().click();
|
||||
await expect(enableItem).toHaveAttribute("aria-checked", "true");
|
||||
await expect(enableItem.locator("button")).toHaveCount(0);
|
||||
@@ -197,15 +348,11 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
|
||||
await expect(
|
||||
workflowCard().getByText("disabled", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText("disabled", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await enableItem.click();
|
||||
await expect(
|
||||
workflowCard().getByText("active", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects a stale card toggle without overwriting a newer edit", async ({
|
||||
@@ -309,12 +456,22 @@ test("shows the webhook secret dialog after saving a webhook workflow", async ({
|
||||
trigger: "webhook",
|
||||
});
|
||||
|
||||
await expect(page.getByText("Webhook Ready")).toBeVisible();
|
||||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy URL" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy Secret" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.getByText("Webhook Ready")).not.toBeVisible();
|
||||
await expect(page.getByText(/•{24}/)).toBeVisible();
|
||||
await page.goBack();
|
||||
const confirmation = page.getByRole("alertdialog", {
|
||||
name: "Continue without this secret?",
|
||||
});
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Go back" }).click();
|
||||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await confirmation.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByText("Webhook ready")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("edits an existing workflow", async ({ page }) => {
|
||||
@@ -332,13 +489,18 @@ test("edits an existing workflow", async ({ page }) => {
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
// Dialog should open in edit mode
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Edit Workflow")).toBeVisible();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("Edit workflow")).toBeVisible();
|
||||
|
||||
// Change the name
|
||||
const nameInput = page.getByLabel("Workflow name");
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
const nameInput = dialog
|
||||
.getByRole("textbox", { name: "Workflow name" })
|
||||
.first();
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(updatedName);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
|
||||
// Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
@@ -362,8 +524,10 @@ test("duplicates a workflow", async ({ page }) => {
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
|
||||
|
||||
// Submit the duplicate
|
||||
await page.getByRole("button", { name: "Create Copy" }).click();
|
||||
// Submit the duplicate after deliberately choosing its destination channel.
|
||||
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Create copy" }).click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
|
||||
// Both the original and copy should exist
|
||||
@@ -396,35 +560,451 @@ test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("triggers a workflow from the detail panel", async ({ page }) => {
|
||||
const workflowName = `trigger_test_${Date.now()}`;
|
||||
test("deleting the open workflow closes its editor", async ({ page }) => {
|
||||
const workflowName = `delete_open_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(detailDialog).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/#\/workflows(?:\?|$)/);
|
||||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("rejected deletion keeps the confirmation, editor, and draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `delete_rejected_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
const editor = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await editor.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = editor.getByRole("textbox", { name: "Workflow YAML" });
|
||||
await yamlEditor.fill(
|
||||
(await yamlEditor.inputValue()).replace(
|
||||
workflowName,
|
||||
`${workflowName}_draft`,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__ ??= {};
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.workflowDeleteError = "relay refused deletion";
|
||||
});
|
||||
|
||||
await editor.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
const confirmation = page.getByRole("alertdialog", {
|
||||
name: "Delete workflow?",
|
||||
});
|
||||
await confirmation.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(confirmation).toBeVisible();
|
||||
await expect(confirmation.getByRole("alert")).toContainText(
|
||||
"relay refused deletion",
|
||||
);
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(yamlEditor).toContainText(`${workflowName}_draft`);
|
||||
});
|
||||
|
||||
test("captures the built editor at desktop and narrow widths", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "editor_screenshot");
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Notify the workflow channel");
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
|
||||
for (const viewport of [
|
||||
{ height: 900, name: "wide", width: 1440 },
|
||||
{ height: 820, name: "narrow", width: 760 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await inspector.getByRole("button", { name: "Run controls" }).click();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `test-results/workflow-editor-${viewport.name}-run-controls.png`,
|
||||
});
|
||||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `test-results/workflow-editor-${viewport.name}-step-details.png`,
|
||||
});
|
||||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves final sequence affordances and responsive inspector behavior", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "sequence_parity_test");
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
const runControls = inspector.getByRole("button", { name: "Run controls" });
|
||||
const stepDetails = inspector.getByRole("button", { name: "Step details" });
|
||||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(stepDetails).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||||
await expect(inspector.getByLabel("Name (optional)")).toHaveCount(0);
|
||||
|
||||
await runControls.click();
|
||||
await expect(runControls).toHaveAttribute("aria-expanded", "true");
|
||||
await inspector.getByLabel("Text to match").fill("deploy");
|
||||
const timeoutField = inspector.getByRole("textbox", {
|
||||
name: "Timeout",
|
||||
exact: true,
|
||||
});
|
||||
const timeoutSlider = inspector.getByRole("slider", {
|
||||
name: "Timeout slider",
|
||||
});
|
||||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "5m");
|
||||
await timeoutField.fill("1h 2s");
|
||||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "1h");
|
||||
await timeoutField.fill("45s");
|
||||
await expect(runControls).toContainText("Conditional · 45s");
|
||||
|
||||
await stepDetails.click();
|
||||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(stepDetails).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||||
await inspector.getByLabel("Name (optional)").fill("Notify deployers");
|
||||
await expect(stepDetails).toContainText("Notify deployers");
|
||||
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
|
||||
|
||||
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
|
||||
const ingresses = dialog.getByTestId("workflow-node-ingress");
|
||||
await expect(ingresses).toHaveCount(2);
|
||||
await expect(
|
||||
ingresses.first().locator("svg.lucide-arrow-down"),
|
||||
).toBeVisible();
|
||||
await expect(ingresses.last().locator("svg.lucide-arrow-down")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
ingresses.last().getByRole("button", { name: "Add after Step 1" }),
|
||||
).toBeVisible();
|
||||
|
||||
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
|
||||
const removeStep = dialog.getByRole("button", { name: "Remove Step 1" });
|
||||
await expect(removeStep).toHaveClass(/-right-8/);
|
||||
await stepNode.hover();
|
||||
await expect(removeStep).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 760, height: 820 });
|
||||
await expect(
|
||||
dialog.getByTestId("workflow-node-inspector-backdrop"),
|
||||
).toBeVisible();
|
||||
await dialog.getByTestId("workflow-node-inspector-backdrop").click({
|
||||
position: { x: 20, y: 20 },
|
||||
});
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("pane routes use stable IDs and Form/YAML changes stay synchronized", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "pane_sync_test");
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("first message");
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Add after Step 1" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delay" }).click();
|
||||
await dialog.getByLabel("Duration").fill("5m");
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||||
await dialog
|
||||
.getByTestId("workflow-node-inspector")
|
||||
.getByRole("button", { name: "Remove step", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
await expect(yamlEditor).toContainText("id: step_2");
|
||||
await expect(yamlEditor).not.toContainText("id: step_1");
|
||||
const yaml = await yamlEditor.inputValue();
|
||||
await yamlEditor.fill(yaml.replace("duration: 5m", "duration: 10m"));
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await dialog.getByRole("button", { name: "Step 1: Delay" }).click();
|
||||
await expect(dialog.getByLabel("Duration")).toHaveValue("10m");
|
||||
});
|
||||
|
||||
test("clean editor close respects in-app versus direct-entry provenance", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
await expect(page).toHaveURL(/view=create/);
|
||||
await page
|
||||
.getByRole("dialog", { name: "Create workflow" })
|
||||
.getByRole("button", {
|
||||
name: "Close",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
|
||||
await page.goto("/#/workflows?view=create");
|
||||
await page
|
||||
.getByRole("dialog", { name: "Create workflow" })
|
||||
.getByRole("button", {
|
||||
name: "Close",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
});
|
||||
|
||||
test("direct workflow routes survive refresh and invalid view opens detail", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `route_test_${Date.now()}`;
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?.*view=edit/);
|
||||
const workflowId = new URL(page.url()).hash.match(/workflows\/([^?]+)/)?.[1];
|
||||
expect(workflowId).toBeTruthy();
|
||||
await page.goto(`/#/workflows/${workflowId}?view=invalid`);
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await expect(detailDialog).toContainText(workflowName);
|
||||
await expect(
|
||||
detailDialog.getByRole("button", { name: "Run history" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto("/#/workflows?view=create");
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Create workflow" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("dirty create close is blocked and keep editing preserves the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "preserved_dirty_draft");
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
const confirmation = page.getByRole("alertdialog", {
|
||||
name: "Discard changes?",
|
||||
});
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Keep editing" }).click();
|
||||
await expect(dialog).toContainText("preserved_dirty_draft");
|
||||
});
|
||||
|
||||
test("stale editor save preserves the local draft and reports the conflict", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `stale_editor_${Date.now()}`;
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
const nameInput = dialog
|
||||
.getByRole("textbox", { name: "Workflow name" })
|
||||
.first();
|
||||
await nameInput.fill(`${workflowName}_local`);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const workflowId = new URL(window.location.href).hash.match(
|
||||
/workflows\/([^?]+)/,
|
||||
)?.[1];
|
||||
if (!workflowId) throw new Error("workflow id unavailable");
|
||||
const workflow = (await invoke("get_workflow", { workflowId })) as {
|
||||
revision: string;
|
||||
};
|
||||
await invoke("update_workflow", {
|
||||
expectedRevision: workflow.revision,
|
||||
workflowId,
|
||||
yamlDefinition:
|
||||
"name: authoritative_remote\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: send_message\n text: remote\n",
|
||||
});
|
||||
});
|
||||
|
||||
await dialog.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(dialog).toContainText("workflow changed since it was loaded");
|
||||
await expect(dialog).toContainText(`${workflowName}_local`);
|
||||
await expect(dialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("unsupported workflow opens canonical YAML without a fabricated sequence", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `unsupported_detail_${Date.now()}`;
|
||||
await navigateToWorkflows(page);
|
||||
const workflowId = await page.evaluate(async (name) => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const workflow = (await invoke("create_workflow", {
|
||||
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
yamlDefinition: `name: ${name}\ntrigger:\n on: message_posted\n legacy_filter:\n author: alice\nsteps:\n - id: legacy_step\n action: send_message\n text: preserve me\n`,
|
||||
})) as { id: string };
|
||||
return workflow.id;
|
||||
}, workflowName);
|
||||
|
||||
await page.goto(`/#/workflows/${workflowId}`);
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await expect(detailDialog).toContainText(workflowName);
|
||||
await expect(detailDialog.getByRole("tab", { name: "YAML" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const yamlEditor = detailDialog.getByRole("textbox", {
|
||||
name: "Workflow YAML",
|
||||
});
|
||||
await expect(yamlEditor).toContainText("legacy_filter:");
|
||||
await expect(yamlEditor).toContainText("author: alice");
|
||||
await expect(yamlEditor).toContainText("id: legacy_step");
|
||||
await expect(yamlEditor).toContainText("text: preserve me");
|
||||
await expect(
|
||||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
detailDialog.getByRole("button", { name: "Step 1: Send Message" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("card click opens the animated trigger inspector, triggers a run, and hides unsupported run history", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `card_editor_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Click on the workflow card to open the detail panel
|
||||
const inspectorEntryAnimation = page.evaluate(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(false);
|
||||
}, 1_000);
|
||||
const observer = new MutationObserver(() => {
|
||||
const inspector = document.querySelector<HTMLElement>(
|
||||
'[data-testid="workflow-node-inspector"]',
|
||||
);
|
||||
if (!inspector) return;
|
||||
|
||||
observer.disconnect();
|
||||
window.clearTimeout(timeoutId);
|
||||
const initialRect = inspector.getBoundingClientRect();
|
||||
const initialStyles = getComputedStyle(inspector);
|
||||
const initial = {
|
||||
opacity: initialStyles.opacity,
|
||||
transform: initialStyles.transform,
|
||||
width: initialRect.width,
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const nextRect = inspector.getBoundingClientRect();
|
||||
const nextStyles = getComputedStyle(inspector);
|
||||
resolve(
|
||||
nextRect.width !== initial.width ||
|
||||
nextStyles.opacity !== initial.opacity ||
|
||||
nextStyles.transform !== initial.transform,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}),
|
||||
);
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
|
||||
|
||||
// Click the Trigger button
|
||||
await page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByRole("button", { name: "Trigger" })
|
||||
.click();
|
||||
|
||||
// Wait for the trigger to complete (button text changes back from "Triggering...")
|
||||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?pane=trigger$/);
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await expect(detailDialog).toContainText(workflowName);
|
||||
await expect(inspectorEntryAnimation).resolves.toBe(true);
|
||||
await expect(page.getByTestId("workflow-node-inspector")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByRole("button", { name: "Trigger" }),
|
||||
).toBeVisible();
|
||||
|
||||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
const triggerCallsBefore = await page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||||
(call) => call.command === "trigger_workflow",
|
||||
).length ?? 0,
|
||||
);
|
||||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Trigger" }).click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||||
(call) => call.command === "trigger_workflow",
|
||||
).length ?? 0,
|
||||
),
|
||||
)
|
||||
.toBe(triggerCallsBefore + 1);
|
||||
await expect(page.getByTestId("workflow-detail-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByTestId("workflow-selected-run"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("workflow-detail-panel").getByTestId("workflow-run-trace"),
|
||||
).toContainText("step_1");
|
||||
detailDialog.getByRole("button", { name: "Run history" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("missing workflow routes show an unavailable modal with close and retry", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.goto("/#/workflows/missing-workflow?view=edit");
|
||||
|
||||
const unavailable = page.getByRole("dialog", {
|
||||
name: "Workflow unavailable",
|
||||
});
|
||||
await expect(unavailable).toBeVisible();
|
||||
await expect(
|
||||
unavailable.getByRole("button", { name: "Retry" }),
|
||||
).toBeVisible();
|
||||
await unavailable.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user