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

306 lines
11 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 finishRiskyActivationIfShown(page: Page, dialog: Locator) {
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await Promise.race([
confirmation.waitFor({ state: "visible" }),
dialog.waitFor({ state: "hidden" }),
]);
if (await confirmation.isVisible()) {
await confirmation.getByRole("button", { name: "Turn on" }).click();
}
await expect(dialog).toBeHidden();
}
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.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const yaml = await yamlEditor.inputValue();
await yamlEditor.fill(
yaml.replace(
" action: send_message",
' action: send_message\n text: "Workflow notification"',
),
);
await dialog.getByRole("button", { name: "Create workflow" }).click();
await finishRiskyActivationIfShown(page, dialog);
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
.getByTestId("workflow-node-inspector")
.getByLabel("Message text")
.fill("Workflow notification");
expect(await readWorkflowName(dialog)).toBe(name);
await dialog.getByRole("button", { name: "Create workflow" }).click();
await finishRiskyActivationIfShown(page, dialog);
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();
await dialog.getByRole("tab", { name: "Form" }).click();
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();
await dialog.getByRole("tab", { name: "Form" }).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);
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();
await dialog.getByRole("tab", { name: "Form" }).click();
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)`);
});