Files
buzz/desktop/tests/e2e/workflows.spec.ts
Taylor Ho 0e48ff2691 feat(workflows): clarify workflow setup and activation (#6470)
**Category:** new-feature
**User Impact:** Workflow authors can build filtered, runtime-aware
automations, understand them at a glance, and get a clear warning before
turning on workflows likely to run often.
**Problem:** Workflow setup exposed raw configuration without enough
help composing message templates, filtering triggers, or understanding
saved behavior; activation could also make a broadly triggered workflow
live without explaining its likely frequency.
**Solution:** Batch 3 adds local, deterministic template variables,
trigger filters, and semantic summaries, then refines cards and
activation around configured behavior and a risk-aware warning boundary.
Scheduling remains the already-shipped implementation, advanced
expressions remain lossless, and network-backed identity/message
enrichment stays in Batch 4.

| Message inputs | Trigger filters |
| --- | --- |
| Caret-aware, keyboard-accessible suggestions expose trigger-local
values and safe prior-step outputs in `send_message.text`. | Structured
conditions and validated manual IDs block invalid submission while
preserving advanced expressions. |
| ![Message variable
autocomplete](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/message-variables.png)
| ![Structured trigger
filters](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/trigger-filters.png)
|

| Workflow cards | Risk-aware activation |
| --- | --- |
| Semantic labels, channel-first hierarchy, configured reaction/action
visuals, real step stacks, and compact status controls make behavior
scannable. | Broad message and frequent schedule triggers explain the
risk before **Turn on**; narrowly scoped triggers proceed without
unnecessary ceremony. |
| ![Semantic workflow
card](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/workflow-card.png)
| ![Activation
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/activation-choice-v2.png)
|

## Changes

<details>
<summary>File changes</summary>

**desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx**  
Separates direct card status controls from secondary actions while
retaining modal status actions.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**  
Adds semantic behavior, channel-first hierarchy, configured
reaction/action visuals, real subsequent-step stacks, status controls,
and reduced-motion-aware trigger feedback.

**desktop/src/features/workflows/ui/WorkflowDialog.tsx**  
Warns before activating broadly triggered workflows while allowing
narrowly scoped workflows to proceed directly.

**desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx**  
Connects structured trigger filters and template-aware step inputs while
preserving schedules, trigger transitions, and selected YAML authority.

**desktop/src/features/workflows/ui/WorkflowStepCard.tsx**  
Replaces generic labels with deterministic configured-step descriptions.

**desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx**  
Adds caret-aware variable suggestions with keyboard navigation and focus
restoration.

**desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx**  
Adds structured local filters, validated author/message IDs, and a
lossless advanced-expression fallback.

**desktop/src/features/workflows/ui/workflowActivationWarning.ts** and
**workflowActivationWarning.test.mjs**
Classify broad message and frequent schedule triggers for contextual
activation warnings.

**desktop/src/features/workflows/ui/workflowConditionExpression.ts** and
**workflowConditionExpression.test.mjs**
Model and cover parsing, serialization, validation, and
advanced-expression preservation.

**desktop/src/features/workflows/ui/workflowDefinition.ts** and
**workflowDefinition.test.mjs**
Preserve trigger/step configuration and derive deterministic card
metadata across YAML round trips.

**desktop/src/features/workflows/ui/workflowStepDescription.ts** and
**workflowStepDescription.test.mjs**
Generate and cover local step summaries.

**desktop/src/features/workflows/ui/workflowTemplateVariables.ts** and
**workflowTemplateVariables.test.mjs**
Define and cover trigger-specific, order-bounded variables and caret
insertion.

**desktop/src/features/workflows/ui/workflowTriggerDescription.ts** and
**workflowTriggerDescription.test.mjs**
Generate and cover semantic trigger summaries without network lookups.

**desktop/tests/e2e/workflow-local-controls.spec.ts** and snapshot  
Cover filters, IDs, advanced expressions, autocomplete, activation
choices, summaries, and YAML authority.

**desktop/tests/e2e/workflow-reaction-picker.spec.ts**  
Covers configured reaction emoji in workflow nodes and summaries.

**desktop/tests/e2e/workflows.spec.ts**  
Covers risk-aware activation warnings, direct safe creation,
duplication, and card status controls.

</details>

## Reproduction steps

1. Create a message-posted workflow in **Workflows**, add a Send message
step, and type `{{trig`; verify keyboard-selectable variables insert at
the caret.
2. Configure message-text and manual ID filters; verify malformed IDs
block submission and advanced expressions survive Form/YAML transitions.
3. Create a broad message workflow; verify **Back** persists nothing,
**Keep off** saves it disabled, and **Turn on** enables it. Confirm a
narrowly triggered webhook skips the warning.
4. Inspect the saved card; verify its channel, semantic behavior,
configured actions/reaction, real step stack, and status are
understandable without opening YAML.

## Validation

Validated at exact clean head `f99503819889b95ee3c61657c5c3850aae35481e`
on base `24ec6a468ec9d0d425ee58fbfc4d416412c446ad`.

- Focused workflow regressions passed 59/60 locally; the only local miss
was a 438-pixel macOS snapshot drift, while the checked-in Linux
baseline comes from the failing CI artifact. Repository pre-push gates
and E2E build/typecheck passed.
- A broader 36-test smoke invocation had 31 passes and five unrelated
pre-existing expectation/snapshot failures, so it is not claimed as
fully green. Adversarial fixes are recorded in [round
one](https://github.com/block/buzz/pull/6470#issuecomment-5366366670)
and [round
two](https://github.com/block/buzz/pull/6470#issuecomment-5366407535).

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
2026-08-21 16:53:50 -07:00

1082 lines
40 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 { parse as parseYaml } from "yaml";
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?.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();
if (!options?.trigger || options.trigger === "message_posted") {
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await activationConfirmation
.getByRole("button", {
name: options?.enabled === false ? "Keep off" : "Turn on",
})
.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("creates a narrowly triggered workflow without an activation warning", async ({
page,
}) => {
await navigateToWorkflows(page);
await createWorkflow(page, `safe_webhook_${Date.now()}`, {
trigger: "webhook",
});
await expect(
page.getByTestId("workflow-activation-confirmation"),
).toHaveCount(0);
const yaml = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((candidate) => candidate.command === "create_workflow");
return (call?.payload as { yamlDefinition?: string } | undefined)
?.yamlDefinition;
});
expect(parseYaml(yaml ?? "").enabled).not.toBe(false);
});
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();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await activationConfirmation.getByRole("button", { name: "Turn on" }).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("Diff text")).toHaveAttribute(
"placeholder",
"e.g. 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()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName, {
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.locator("h3")).toHaveText(
"When a diff is posted, send “Workflow notification”",
);
await expect(card).toContainText(workflowName);
await expect(
card.getByRole("switch", { name: "Disable workflow" }),
).toBeChecked();
});
test("enables and disables a workflow from its card status toggle", 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 disable = workflowCard().getByRole("switch", {
name: "Disable workflow",
});
await expect(disable).toBeChecked();
await disable.click();
const enable = workflowCard().getByRole("switch", {
name: "Enable workflow",
});
await expect(enable).not.toBeChecked();
await enable.click();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await expect(activationConfirmation).toBeVisible();
await activationConfirmation.getByRole("button", { name: "Turn on" }).click();
await expect(
workflowCard().getByRole("switch", { name: "Disable workflow" }),
).toBeChecked();
await workflowCard()
.getByRole("button", { name: "Workflow actions" })
.click();
await expect(
page.getByRole("menuitemcheckbox", { name: "Enable" }),
).toHaveCount(0);
});
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("switch", { name: "Disable workflow" }).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("switch", { name: "Disable workflow" }).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.getByRole("switch", { name: "Disable workflow" }),
).toBeChecked();
});
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();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await expect(activationConfirmation).toBeVisible();
await activationConfirmation.getByRole("button", { name: "Back" }).click();
await expect(dialog).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
).length,
),
)
.toBe(1);
await dialog.getByRole("button", { name: "Create copy" }).click();
await activationConfirmation
.getByRole("button", { name: "Keep off" })
.click();
await expect(page.getByRole("dialog")).not.toBeVisible();
const copiedYaml = await page.evaluate(() => {
const calls = (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
);
return (calls.at(-1)?.payload as { yamlDefinition?: string } | undefined)
?.yamlDefinition;
});
expect(parseYaml(copiedYaml ?? "").enabled).toBe(false);
// 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:/ }).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:/ }).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$/);
});