Files
buzz/desktop/tests/e2e/workflow-title-stability.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

276 lines
9.8 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
const GENERATED_NAME = /^[a-z]+-[a-z]+-[a-z]+$/;
async function navigateToWorkflows(page: Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page.getByTestId("workflows-view")).toBeVisible();
}
function readWorkflowName(dialog: Locator) {
return dialog
.getByRole("button", { name: "Edit workflow name" })
.evaluate((button) =>
(button.previousElementSibling?.textContent ?? "").trim(),
);
}
async function selectWorkflowChannel(page: Page, dialog: 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 openTriggerPane(dialog: Locator) {
await dialog.getByRole("button", { name: /^Trigger: / }).click();
await expect(
dialog.getByRole("button", { name: "Trigger event" }),
).toBeVisible();
}
async function openStepPane(dialog: Locator, index: number) {
await dialog
.getByRole("button", { name: new RegExp(`^Step ${index}: `) })
.click();
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
`Step ${index}`,
);
}
/**
* Inserts a step right after the trigger. The insertion control only becomes
* interactive on hover once the trigger is no longer the terminal node.
*/
async function addStepAfterTrigger(page: Page, dialog: Locator) {
const ingress = dialog.getByTestId("workflow-node-ingress").first();
await ingress.hover();
await ingress.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
}
async function createNamedWorkflow(page: Page, name: 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 dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await dialog.getByRole("button", { name: "Add first step" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog.getByRole("button", { name: "Create workflow" }).click();
await expect(page.getByTestId("workflows-view")).toContainText(name);
}
function workflowCard(page: Page, name: string) {
return page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
}
test("keeps the generated name while moving between trigger and step panes", 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 expect.poll(() => readWorkflowName(dialog)).toMatch(GENERATED_NAME);
const generatedName = await readWorkflowName(dialog);
// Adding the first step lands on the step pane while the step is still
// incomplete — the header must keep showing the workflow name.
await dialog.getByRole("button", { name: "Add first step" }).click();
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(generatedName);
await expect(
dialog.getByRole("button", { name: "Edit workflow name" }),
).toBeEnabled();
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(generatedName);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(generatedName);
// A second, still-empty step must not clear the name either.
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(generatedName);
await openStepPane(dialog, 2);
expect(await readWorkflowName(dialog)).toBe(generatedName);
});
test("renames stay visible across pane navigation before the step is filled in", async ({
page,
}) => {
const name = `renamed_workflow_${Date.now()}`;
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: "Add first step" }).click();
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
expect(await readWorkflowName(dialog)).toBe(name);
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(name);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(name);
// Completing the step re-serializes the definition from the builder's form
// state, which must have picked the rename up rather than writing back the
// generated name it was holding when the rename happened.
await dialog.getByLabel("Message text").fill("Workflow notification");
expect(await readWorkflowName(dialog)).toBe(name);
await dialog.getByRole("button", { name: "Create workflow" }).click();
await expect(page.getByTestId("workflows-view")).toContainText(name);
});
test("keeps a header disable across a later form edit", async ({ page }) => {
const name = `disabled_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await createNamedWorkflow(page, name);
await workflowCard(page, name)
.getByRole("button", { name: "Workflow actions" })
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
// The open menu aria-hides the dialog behind it, so it has to be dismissed
// before the dialog can be addressed again.
const closeActionsMenu = async () => {
await page.keyboard.press("Escape");
await expect(enableItem).not.toBeVisible();
};
// Add an empty step so the definition stops validating, then disable from the
// header. Completing the step re-serializes the definition from the builder's
// form state, which must carry the disable rather than the enabled state it
// held before the header wrote it.
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
await dialog.getByRole("button", { name: "Workflow actions" }).click();
await expect(enableItem).toHaveAttribute("aria-checked", "true");
await enableItem.click();
await expect(enableItem).toHaveAttribute("aria-checked", "false");
await closeActionsMenu();
await dialog.getByLabel("Message text").fill("Updated notification");
await dialog.getByRole("button", { name: "Workflow actions" }).click();
await expect(enableItem).toHaveAttribute("aria-checked", "false");
await closeActionsMenu();
await dialog.getByRole("button", { name: "Save changes" }).click();
await expect(workflowCard(page, name)).toContainText("disabled");
});
test("keeps the saved name while editing an existing workflow", async ({
page,
}) => {
const name = `edit_title_${Date.now()}`;
await navigateToWorkflows(page);
await createNamedWorkflow(page, name);
await workflowCard(page, name)
.getByRole("button", { name: "Workflow actions" })
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
expect(await readWorkflowName(dialog)).toBe(name);
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(name);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(name);
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(name);
await expect(
dialog.getByRole("button", { name: "Edit workflow name" }),
).toBeEnabled();
await openStepPane(dialog, 2);
expect(await readWorkflowName(dialog)).toBe(name);
});
test("keeps the copy name while duplicating an existing workflow", async ({
page,
}) => {
const name = `dup_title_${Date.now()}`;
await navigateToWorkflows(page);
await createNamedWorkflow(page, name);
await workflowCard(page, name)
.getByRole("button", { name: "Workflow actions" })
.click();
await page.getByRole("menuitem", { name: "Duplicate" }).click();
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
await expect(dialog).toBeVisible();
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
await openStepPane(dialog, 1);
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
await addStepAfterTrigger(page, dialog);
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
"Step 1",
);
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
await openTriggerPane(dialog);
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
});