Files
buzz/desktop/tests/e2e/workflows.spec.ts
Taylor Ho 1934e83bf5 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 library with action
menu](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-library-actions-c5c3abc.png)

### Workflow editor — wide

![Wide workflow editor with step
details](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-editor-wide-c5c3abc.png)

### Workflow editor — narrow inspector overlay

![Narrow workflow editor with inspector
overlay](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-editor-narrow-c5c3abc.png)

### Workflow editor — active channel overlay

![Workflow editor portalled over the active
channel](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6248/workflow-editor-channel-overlay-c5c3abc.png)

## 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>
2026-08-20 12:15:56 -06:00

1011 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function navigateToWorkflows(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page).toHaveURL(/#\/workflows$/);
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,
options?: {
description?: string;
enabled?: boolean;
trigger?: string;
stepConditionText?: string;
stepName?: string;
stepTimeoutSecs?: string;
},
) {
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await expect(dialog).toBeVisible();
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, name);
if (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.getByRole("switch", { name: "Enable" }).click();
}
if (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", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
if (options?.stepName) {
await dialog.getByRole("button", { name: "Step details" }).click();
await dialog.getByLabel("Name (optional)").fill(options.stepName);
}
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
.getByRole("textbox", { name: "Timeout", exact: true })
.fill(options.stepTimeoutSecs);
}
await dialog.getByRole("button", { name: "Create" }).click();
await expect(
page.getByRole("heading", { name: "Create Workflow" }),
).not.toBeVisible();
}
test("navigates to workflows view and shows the empty create tile", async ({
page,
}) => {
await navigateToWorkflows(page);
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
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()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
// Verify workflow appears in the list
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", { name: "Create workflow" });
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",
);
});
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")',
);
});
test("captures workflow library across responsive viewports", async ({
page,
}) => {
await navigateToWorkflows(page);
await createWorkflow(page, "Notify reviewers when source files change", {
description: "Watches diff events for src/ changes",
enabled: false,
trigger: "diff_posted",
});
await createWorkflow(page, "Post the daily standup reminder to the team", {
description: "Keeps the team aligned every morning",
trigger: "message_posted",
});
await createWorkflow(
page,
"Request approval before deploying to production",
{
description: "Requires a final review before release",
trigger: "reaction_added",
},
);
for (const viewport of [
{ width: 800, height: 720, name: "narrow" },
{ width: 1024, height: 720, name: "medium" },
{ width: 1280, height: 720, name: "wide" },
]) {
await page.setViewportSize(viewport);
await page.screenshot({
animations: "disabled",
path: `test-results/workflow-library-${viewport.name}.png`,
});
}
await page.setViewportSize({ width: 1280, height: 720 });
const firstCard = page.locator('[data-testid^="workflow-card-"]').first();
await firstCard.getByRole("button", { name: "Workflow actions" }).click();
await page.screenshot({
animations: "disabled",
path: "test-results/workflow-library-wide-actions.png",
});
});
test("captures disabled diff workflows in the list UI", async ({ page }) => {
const workflowName = `diff_workflow_${Date.now()}`;
const description = "Watches diff events for src/ changes";
await navigateToWorkflows(page);
await createWorkflow(page, workflowName, {
description,
enabled: false,
trigger: "diff_posted",
stepName: "Notify reviewers",
stepConditionText: "src/",
stepTimeoutSecs: "45s",
});
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await expect(card.getByText("Diff Posted", { exact: true })).toBeVisible();
await expect(card.locator("h3")).toHaveText(workflowName);
await expect(card.getByText(description, { exact: true })).toBeVisible();
await expect(card).toContainText("disabled");
});
test("enables and disables a workflow from its card menu", async ({ page }) => {
const workflowName = `toggle_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
const workflowCard = () =>
page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
const workflowActions = () =>
workflowCard().getByRole("button", { name: "Workflow actions" });
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
await workflowActions().click();
await expect(enableItem).toHaveAttribute("aria-checked", "true");
await expect(enableItem.locator("button")).toHaveCount(0);
await expect(
enableItem.getByTestId("workflow-enabled-switch-visual"),
).toHaveAttribute("aria-hidden", "true");
await enableItem.click();
await expect(
workflowCard().getByText("disabled", { exact: true }),
).toBeVisible();
await enableItem.click();
await expect(
workflowCard().getByText("active", { exact: true }),
).toBeVisible();
});
test("rejects a stale card toggle without overwriting a newer edit", async ({
page,
}) => {
const workflowName = `stale_toggle_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
const workflowCard = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await page.evaluate(async (name) => {
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("mock command bridge unavailable");
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((call) => call.command === "create_workflow");
const channelId = (
createCall?.payload as { channelId?: string } | undefined
)?.channelId;
if (!channelId) throw new Error("create workflow channel unavailable");
const workflows = (await invoke("get_channels_workflows", {
channelIds: [channelId],
})) as Array<{
id: string;
revision: string;
definition: Record<string, unknown>;
}>;
const workflow = workflows.find(
(candidate) => candidate.definition.name === name,
);
if (!workflow) throw new Error("created workflow unavailable");
await invoke("update_workflow", {
workflowId: workflow.id,
expectedRevision: workflow.revision,
yamlDefinition: `name: ${name} edited elsewhere\nenabled: true\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: post_message\n`,
});
}, workflowName);
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
await expect(
page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: "workflow changed since it was loaded" }),
).toBeVisible();
const authoritativeName = await page.evaluate(async () => {
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("mock command bridge unavailable");
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((call) => call.command === "create_workflow");
const channelId = (
createCall?.payload as { channelId?: string } | undefined
)?.channelId;
if (!channelId) throw new Error("create workflow channel unavailable");
const workflows = (await invoke("get_channels_workflows", {
channelIds: [channelId],
})) as Array<{ name: string }>;
return workflows[0]?.name;
});
expect(authoritativeName).toBe(`${workflowName} edited elsewhere`);
});
test("reports a rejected workflow status change", async ({ page }) => {
const workflowName = `rejected_toggle_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
await page.evaluate(() => {
window.__BUZZ_E2E__ ??= {};
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.workflowUpdateError = "relay refused the update";
});
const workflowCard = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
const errorToast = page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: "Couldn’t change workflow status" });
await expect(errorToast).toContainText("relay refused the update");
await expect(workflowCard.getByText("active", { exact: true })).toBeVisible();
});
test("shows the webhook secret dialog after saving a webhook workflow", async ({
page,
}) => {
const workflowName = `webhook_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName, {
trigger: "webhook",
});
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 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 }) => {
const originalName = `edit_test_${Date.now()}`;
const updatedName = `${originalName}_updated`;
await navigateToWorkflows(page);
await createWorkflow(page, originalName);
// Verify it exists
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
// Open the dropdown menu and click Edit
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Edit" }).click();
// Dialog should open in edit mode
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Edit workflow")).toBeVisible();
// Change the 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();
await expect(page.getByRole("dialog")).not.toBeVisible();
// Verify the updated name appears
await expect(page.getByTestId("workflows-view")).toContainText(updatedName);
});
test("duplicates a workflow", async ({ page }) => {
const originalName = `dup_test_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, originalName);
// Open the dropdown menu and click Duplicate
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Duplicate" }).click();
// Dialog should open in duplicate mode with "(copy)" suffix
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
// 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
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
});
test("deletes a workflow with confirmation", async ({ page }) => {
const workflowName = `delete_test_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
// Verify it exists
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
// Open the dropdown menu and click Delete
await page.getByRole("button", { name: "Workflow actions" }).first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
// Confirmation dialog should appear with workflow name
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
// Confirm deletion
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.getByRole("alertdialog")).not.toBeVisible();
// Verify workflow is gone — back to the empty create tile.
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
});
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);
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).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(
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(
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$/);
});