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

243 lines
9.3 KiB
TypeScript
Raw Permalink 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 { parse as parseYaml } from "yaml";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function openCreateWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const channelList = page.getByTestId("channel-combobox-list");
if (!(await channelList.isVisible())) {
await dialog.getByRole("combobox", { name: "Channel" }).click();
}
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
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();
return dialog;
}
async function selectTrigger(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
trigger: string,
) {
await dialog.getByRole("button", { name: "Trigger event" }).click();
await page.getByRole("menuitem", { name: trigger, exact: true }).click();
}
async function openTriggerInspector(
dialog: import("@playwright/test").Locator,
trigger: string,
) {
const menu = dialog.getByRole("button", { name: "Trigger event" });
if (!(await menu.isVisible())) {
await dialog.getByRole("button", { name: `Trigger: ${trigger}` }).click();
}
await expect(menu).toBeVisible();
}
async function addMessageStep(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
) {
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");
}
async function reopenWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name });
await card.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
return page.getByRole("dialog", { name: "Edit workflow" });
}
test("round-trips schedule presets and saves a custom UTC cron", async ({
page,
}) => {
const name = `schedule_controls_${Date.now()}`;
const dialog = await openCreateWorkflow(page, name);
await selectTrigger(page, dialog, "Schedule");
await expect(dialog.getByRole("radio", { name: "Daily" })).toBeChecked();
await expect(dialog.getByLabel("Run time (UTC)")).toHaveValue("09:00");
await dialog.getByText("Every 15 minutes", { exact: true }).click();
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
let definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({ on: "schedule", interval: "15m" });
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await expect(
dialog.getByRole("radio", { name: "Every 15 minutes" }),
).toBeChecked();
await dialog.getByText("Monthly", { exact: true }).click();
await dialog.getByLabel("Day of month").selectOption("31");
await expect(
dialog.getByText("This schedule won’t run in some months."),
).toBeVisible();
await dialog.getByText("Custom cron", { exact: true }).click();
await dialog.getByRole("textbox", { name: "Minute", exact: true }).fill("5");
await dialog.getByRole("textbox", { name: "Hour", exact: true }).fill("*/2");
await dialog.getByRole("textbox", { name: "Day", exact: true }).fill("*");
await dialog.getByRole("textbox", { name: "Month", exact: true }).fill("*");
await dialog
.getByRole("textbox", { name: "Weekday", exact: true })
.fill("2-4");
await expect(dialog.getByText(/UTC · Paste all 5 fields/)).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({
on: "schedule",
cron: "5 */2 * * 2-4",
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await expect(
dialog.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
await expect(
dialog.getByRole("textbox", { name: "Weekday", exact: true }),
).toHaveValue("2-4");
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened, "Schedule");
await expect(
reopened.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
await expect(
reopened.getByRole("textbox", { name: "Minute", exact: true }),
).toHaveValue("5");
await expect(
reopened.getByRole("textbox", { name: "Weekday", exact: true }),
).toHaveValue("2-4");
});
test("round-trips and reopens structured message-text conditions", async ({
page,
}) => {
const name = `message_condition_${Date.now()}`;
const text = 'deploy "buzz"\\path';
const expression = 'str_ends_with(trigger_text, "deploy \\"buzz\\"\\\\path")';
const dialog = await openCreateWorkflow(page, name);
await dialog.getByText("ends with", { exact: true }).click();
await dialog.getByLabel("Text to match").fill(text);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger.filter).toBe(expression);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Message Posted");
await expect(dialog.getByText("ends with", { exact: true })).toBeVisible();
await expect(dialog.getByLabel("Text to match")).toHaveValue(text);
await dialog.getByRole("tab", { name: "Advanced" }).click();
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(
expression,
);
await dialog.getByRole("tab", { name: "Basic" }).click();
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened, "Message Posted");
await expect(reopened.getByText("ends with", { exact: true })).toBeVisible();
await expect(reopened.getByLabel("Text to match")).toHaveValue(text);
});
test("hides and clears message-text step conditions for schedule triggers", async ({
page,
}) => {
const dialog = await openCreateWorkflow(
page,
`schedule_step_condition_${Date.now()}`,
);
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Run controls" }).click();
await dialog.getByLabel("Text to match").fill("deploy");
await dialog.getByRole("button", { name: "Trigger: Message Posted" }).click();
await selectTrigger(page, dialog, "Schedule");
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
await dialog.getByRole("button", { name: "Run controls" }).click();
await expect(dialog.getByLabel("Text to match")).toHaveCount(0);
await expect(
dialog.getByRole("textbox", { name: "Timeout", exact: true }),
).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
const definition = parseYaml(
await dialog.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
);
expect(definition.steps[0].if).toBeUndefined();
});
test("keeps advanced and malformed definitions lossless", async ({ page }) => {
const advanced =
'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
const dialog = await openCreateWorkflow(page, `lossless_${Date.now()}`);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const initialYaml = await yamlEditor.inputValue();
await yamlEditor.fill(
initialYaml.replace(
"trigger:\n on: message_posted",
`trigger:\n on: message_posted\n filter: '${advanced}'`,
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Message Posted");
await expect(dialog.getByRole("tab", { name: "Advanced" })).toHaveAttribute(
"data-state",
"active",
);
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(advanced);
await dialog.getByRole("tab", { name: "Basic" }).click();
await expect(
dialog.getByText(/advanced expression is active/i),
).toBeVisible();
await dialog.getByRole("switch", { name: "Enable workflow" }).click();
await dialog.getByRole("tab", { name: "YAML" }).click();
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
advanced,
);
const malformedYaml = (await yamlEditor.inputValue()).replace(
/trigger:\n {2}on: message_posted\n {2}filter:.*\n/,
'trigger:\n on: schedule\n cron: "0 9 * * *"\n interval: 1h\n',
);
await yamlEditor.fill(malformedYaml);
await dialog.getByRole("tab", { name: "Form" }).click();
await expect(
dialog.getByText(/cannot specify both cron and interval/i),
).toBeVisible();
await expect(yamlEditor).toHaveValue(malformedYaml);
});