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>
This commit is contained in:
Taylor Ho
2026-08-21 16:53:50 -07:00
committed by GitHub
parent b85d680fb1
commit 0e48ff2691
29 changed files with 3812 additions and 272 deletions

View File

@@ -62,6 +62,18 @@ async function createWorkflow(
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await dialog.getByRole("button", { name: "Create" }).click();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await Promise.race([
activationConfirmation.waitFor({ state: "visible" }),
dialog.waitFor({ state: "hidden" }),
]);
if (await activationConfirmation.isVisible()) {
await activationConfirmation
.getByRole("button", { name: "Turn on" })
.click();
}
await expect(dialog).not.toBeVisible();
}

View File

@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { parse as parseYaml } from "yaml";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
@@ -39,11 +40,10 @@ async function selectTrigger(
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 dialog.getByRole("button", { name: /^Trigger:/ }).click();
}
await expect(menu).toBeVisible();
}
@@ -57,6 +57,19 @@ async function addMessageStep(
await dialog.getByLabel("Message text").fill("Workflow notification");
}
async function createEnabled(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
) {
await dialog.getByRole("button", { name: "Create" }).click();
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
if (await confirmation.isVisible()) {
await confirmation.getByRole("button", { name: "Turn on" }).click();
}
}
async function reopenWorkflow(
page: import("@playwright/test").Page,
name: string,
@@ -69,6 +82,111 @@ async function reopenWorkflow(
return page.getByRole("dialog", { name: "Edit workflow" });
}
test("confirms activation after create and preserves a safe disabled path", async ({
page,
}) => {
const name = `activation_confirmation_${Date.now()}`;
const dialog = await openCreateWorkflow(page, name);
await addMessageStep(page, dialog);
await expect(
dialog.getByRole("switch", { name: "Enable workflow" }),
).toHaveCount(0);
await dialog.getByRole("button", { name: "Create" }).click();
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await expect(confirmation).toBeVisible();
await expect(
confirmation.getByRole("button", { name: "Back" }),
).toBeFocused();
await confirmation.getByRole("button", { name: "Back" }).click();
await expect(confirmation).toBeHidden();
await expect(dialog).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
).length,
),
)
.toBe(0);
await dialog.getByRole("button", { name: "Create" }).click();
await confirmation.getByRole("button", { name: "Keep off" }).click();
await expect(dialog).toBeHidden();
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).toBe(false);
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
await expect(
card.getByRole("switch", { name: "Enable workflow" }),
).not.toBeChecked();
});
test("inserts template variables with keyboard control and restores the caret", async ({
page,
}) => {
const dialog = await openCreateWorkflow(page, "template_variables_keyboard");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
const textarea = dialog.getByLabel("Message text");
const listbox = page.getByRole("listbox");
await textarea.fill("Hello {{trig");
await expect(listbox).toBeVisible();
await expect(listbox.getByRole("option")).toHaveCount(5);
await waitForAnimations(page);
expect(await page.locator("body").screenshot()).toMatchSnapshot(
"workflow-template-variable-autocomplete.png",
);
await textarea.press("ArrowUp");
await expect(textarea).toHaveAttribute(
"aria-activedescendant",
/-variables-option-4$/,
);
await textarea.press("ArrowDown");
await expect(textarea).toHaveAttribute(
"aria-activedescendant",
/-variables-option-0$/,
);
await textarea.press("Enter");
await expect(textarea).toHaveValue("Hello {{trigger.text}}");
await expect(textarea).toBeFocused();
await expect
.poll(() =>
textarea.evaluate((element) =>
element instanceof HTMLTextAreaElement ? element.selectionStart : -1,
),
)
.toBe("Hello {{trigger.text}}".length);
await textarea.fill("Keep {{auth");
await expect(listbox).toBeVisible();
await textarea.press("Escape");
await expect(listbox).toBeHidden();
await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue("Keep {{auth");
await textarea.fill("By {{auth");
await textarea.press("Tab");
await expect(textarea).toHaveValue("By {{trigger.author}}");
await expect(textarea).toBeFocused();
});
test("round-trips schedule presets and saves a custom UTC cron", async ({
page,
}) => {
@@ -86,7 +204,7 @@ test("round-trips schedule presets and saves a custom UTC cron", async ({
expect(definition.trigger).toEqual({ on: "schedule", interval: "15m" });
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await openTriggerInspector(dialog);
await expect(
dialog.getByRole("radio", { name: "Every 15 minutes" }),
).toBeChecked();
@@ -114,7 +232,7 @@ test("round-trips schedule presets and saves a custom UTC cron", async ({
cron: "5 */2 * * 2-4",
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Schedule");
await openTriggerInspector(dialog);
await expect(
dialog.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
@@ -123,9 +241,9 @@ test("round-trips schedule presets and saves a custom UTC cron", async ({
).toHaveValue("2-4");
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened, "Schedule");
await openTriggerInspector(reopened);
await expect(
reopened.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
@@ -145,17 +263,41 @@ test("round-trips and reopens structured message-text conditions", async ({
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("group", { name: "Match" })
.getByRole("button", {
name: "ends with",
})
.click();
await dialog.getByLabel("Message text").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 openTriggerInspector(dialog);
const matchControls = dialog.getByRole("group", { name: "Match" });
const operatorButtons = matchControls.getByRole("button");
const firstOperatorBox = await operatorButtons.nth(0).boundingBox();
const secondOperatorBox = await operatorButtons.nth(1).boundingBox();
const thirdOperatorBox = await operatorButtons.nth(2).boundingBox();
expect(firstOperatorBox).not.toBeNull();
expect(secondOperatorBox).not.toBeNull();
expect(thirdOperatorBox).not.toBeNull();
expect(secondOperatorBox?.x).toBeGreaterThan(firstOperatorBox?.x ?? 0);
expect(
Math.abs((secondOperatorBox?.y ?? 0) - (firstOperatorBox?.y ?? 0)),
).toBeLessThan(1);
expect(thirdOperatorBox?.y).toBeGreaterThan(firstOperatorBox?.y ?? 0);
await waitForAnimations(page);
await matchControls.screenshot({
path: "test-results/workflow-message-condition-operators.png",
});
await expect(
matchControls.getByRole("button", { name: "ends with" }),
).toHaveAttribute("aria-pressed", "true");
await expect(dialog.getByLabel("Message text")).toHaveValue(text);
await dialog.getByRole("tab", { name: "Advanced" }).click();
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(
expression,
@@ -163,11 +305,196 @@ test("round-trips and reopens structured message-text conditions", async ({
await dialog.getByRole("tab", { name: "Basic" }).click();
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Create" }).click();
await createEnabled(page, dialog);
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);
await openTriggerInspector(reopened);
await expect(
reopened
.getByRole("group", { name: "Match" })
.getByRole("button", { name: "ends with" }),
).toHaveAttribute("aria-pressed", "true");
await expect(reopened.getByLabel("Message text")).toHaveValue(text);
});
test("renders deterministic trigger, step, and workflow-card summaries", async ({
page,
}) => {
const name = `semantic_summaries_${Date.now()}`;
await page.addInitScript((workflowName) => {
const positions: number[] = [];
Object.defineProperty(window, "__WORKFLOW_METADATA_POSITIONS__", {
configurable: true,
value: positions,
});
const findTarget = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid="workflow-card-name"]',
),
).find((element) => element.textContent === workflowName);
if (!target) {
requestAnimationFrame(findTarget);
return;
}
let remainingFrames = 12;
const sample = () => {
positions.push(target.getBoundingClientRect().y);
remainingFrames -= 1;
if (remainingFrames > 0) requestAnimationFrame(sample);
};
sample();
};
requestAnimationFrame(findTarget);
}, name);
const dialog = await openCreateWorkflow(page, name);
await dialog.getByLabel("Message text").fill("deploy");
const triggerNode = dialog.getByRole("button", {
name: "Trigger: Message contains “deploy”",
});
await expect(triggerNode).toContainText("Trigger");
await expect(triggerNode).toContainText("Message contains “deploy”");
await addMessageStep(page, dialog);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
definition.steps[0].channel = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
await yamlEditor.fill(stringifyYaml(definition));
await dialog.getByRole("tab", { name: "Form" }).click();
const stepNode = dialog.getByRole("button", {
name: "Step 1: “Workflow notification” in #agents",
});
await expect(stepNode).toContainText("Send Message");
await expect(stepNode).toContainText("“Workflow notification” in #agents");
await createEnabled(page, dialog);
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
const semanticLabel = card.getByTestId("workflow-card-semantic-label");
const workflowName = card.getByTestId("workflow-card-name");
const channelName = card.getByTestId("workflow-card-channel");
await expect(card.getByTestId("workflow-card-trigger-summary")).toHaveCount(
0,
);
await expect(semanticLabel).toHaveText(
"When a message contains “deploy”, send “Workflow notification”",
);
await expect(workflowName).toHaveText(name);
await expect(channelName).toHaveText("#agents");
const semanticBox = await semanticLabel.boundingBox();
const nameBox = await workflowName.boundingBox();
const channelBox = await channelName.boundingBox();
expect(semanticBox).not.toBeNull();
expect(nameBox).not.toBeNull();
expect(channelBox).not.toBeNull();
expect(semanticBox?.y).toBeLessThan(nameBox?.y ?? 0);
expect(channelBox?.y).toBeLessThan(nameBox?.y ?? 0);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as typeof window & {
__WORKFLOW_METADATA_POSITIONS__?: number[];
}
).__WORKFLOW_METADATA_POSITIONS__?.length ?? 0,
),
)
.toBe(12);
const metadataPositions = await page.evaluate(
() =>
(
window as typeof window & {
__WORKFLOW_METADATA_POSITIONS__?: number[];
}
).__WORKFLOW_METADATA_POSITIONS__ ?? [],
);
expect(
Math.max(...metadataPositions) - Math.min(...metadataPositions),
).toBeLessThan(0.5);
});
test("round-trips manual author and reaction message IDs through save and reopen", async ({
page,
}) => {
const name = `manual_trigger_ids_${Date.now()}`;
const author = "a".repeat(64);
const messageId = "b".repeat(64);
const dialog = await openCreateWorkflow(page, name);
await selectTrigger(page, dialog, "Reaction Added");
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
await openTriggerInspector(dialog);
await dialog.getByRole("button", { name: "Author pubkey" }).click();
await dialog.getByLabel("Author pubkey").fill("not-hex");
await expect(
dialog.getByText("Enter a 64-character hex pubkey."),
).toBeVisible();
await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
).length,
),
)
.toBe(0);
await dialog.getByLabel("Author pubkey").fill(author);
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
await dialog.getByLabel("Author pubkey").fill("still-not-hex");
await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled();
await dialog.getByRole("tab", { name: "YAML" }).click();
const correctionEditor = dialog.getByRole("textbox", {
name: "Workflow YAML",
});
const correctedDefinition = parseYaml(await correctionEditor.inputValue());
delete correctedDefinition.trigger.filter;
await correctionEditor.fill(stringifyYaml(correctedDefinition));
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await dialog.getByRole("button", { name: "Author pubkey" }).click();
await expect(dialog.getByLabel("Author pubkey")).toHaveValue("");
await dialog.getByLabel("Author pubkey").fill(author);
await dialog
.getByRole("group", { name: "Match" })
.getByRole("button", { name: "is not", exact: true })
.click();
await dialog.getByRole("button", { name: "Message event ID" }).click();
await dialog.getByLabel("Message event ID").fill(messageId);
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: "reaction_added",
filter: `trigger_author != "${author}" && trigger_message_id == "${messageId}"`,
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened);
await reopened.getByRole("button", { name: "Author pubkey" }).click();
await expect(reopened.getByLabel("Author pubkey")).toHaveValue(author);
await reopened.getByRole("button", { name: "Message event ID" }).click();
await expect(reopened.getByLabel("Message event ID")).toHaveValue(messageId);
await reopened.getByRole("tab", { name: "YAML" }).click();
definition = parseYaml(
await reopened.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
);
expect(definition.trigger.filter).toContain(messageId);
});
test("hides and clears message-text step conditions for schedule triggers", async ({
@@ -181,9 +508,9 @@ test("hides and clears message-text step conditions for schedule triggers", asyn
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 dialog.getByRole("button", { name: /^Trigger:/ }).click();
await selectTrigger(page, dialog, "Schedule");
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
await dialog.getByRole("button", { name: "Run controls" }).click();
await expect(dialog.getByLabel("Text to match")).toHaveCount(0);
@@ -212,7 +539,7 @@ test("keeps advanced and malformed definitions lossless", async ({ page }) => {
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog, "Message Posted");
await openTriggerInspector(dialog);
await expect(dialog.getByRole("tab", { name: "Advanced" })).toHaveAttribute(
"data-state",
"active",
@@ -223,7 +550,9 @@ test("keeps advanced and malformed definitions lossless", async ({ page }) => {
dialog.getByText(/advanced expression is active/i),
).toBeVisible();
await dialog.getByRole("switch", { name: "Enable workflow" }).click();
await expect(
dialog.getByRole("switch", { name: "Enable workflow" }),
).toHaveCount(0);
await dialog.getByRole("tab", { name: "YAML" }).click();
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
advanced,

View File

@@ -73,15 +73,60 @@ test("reaction trigger uses the emoji picker instead of a free-text field", asyn
await expect(
dialog.getByRole("button", { name: "Clear trigger emoji" }),
).toHaveCount(0);
await expect(
dialog
.getByRole("button", { name: "Trigger: Reaction Added" })
.getByTestId("workflow-node-icon")
.locator("svg"),
).toHaveCount(1);
await pickEmoji(page, dialog, "rocket", EMOJI);
const triggerNode = dialog.getByRole("button", {
name: "Trigger: 🚀 reaction added",
});
await expect(triggerNode.getByTestId("workflow-node-icon")).toHaveText(EMOJI);
await expect(triggerNode).toContainText("Reaction added");
await expect(triggerNode).not.toContainText(`${EMOJI} reaction added`);
await expect(
triggerNode.getByTestId("workflow-node-icon").locator("svg"),
).toHaveCount(0);
await waitForAnimations(page);
await triggerNode.screenshot({
path: "test-results/workflow-reaction-trigger-node.png",
});
// Selecting closes the popover and shows the chosen reaction by name.
await expect(page.locator("em-emoji-picker")).toHaveCount(0);
await expect(pickerButton).toContainText(EMOJI_SHORTCODE);
await expect(pickerButton).not.toContainText("Choose a reaction");
});
test("add-reaction step replaces its sequence number with the configured emoji", async ({
page,
}) => {
const dialog = await openReactionTrigger(page);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
await yamlEditor.fill(
(await yamlEditor.inputValue()).replace(
"steps: []",
"steps:\n - id: step_1\n action: add_reaction\n emoji: ✅",
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
const stepNode = dialog.getByRole("button", { name: "Step 1: ✅" });
await expect(stepNode.getByTestId("workflow-node-icon")).toHaveText("✅");
await expect(stepNode).toContainText("Add Reaction");
await expect(stepNode).not.toContainText("✅✅");
await expect(stepNode.getByTestId("workflow-node-icon")).not.toHaveText("1");
await waitForAnimations(page);
await stepNode.screenshot({
path: "test-results/workflow-add-reaction-step-node.png",
});
});
test("picked reaction persists into canonical YAML and clears back out", async ({
page,
}) => {
@@ -92,15 +137,17 @@ test("picked reaction persists into canonical YAML and clears back out", async (
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const yaml = await yamlEditor.inputValue();
expect(yaml).toContain("on: reaction_added");
// Stored verbatim as the reaction content the executor compares against.
expect(yaml).toContain(`emoji: ${EMOJI}`);
// Stored verbatim in the local filter expression the executor compares.
expect(yaml).toContain(`trigger_emoji == "${EMOJI}"`);
// Clearing the optional filter removes the key entirely rather than
// writing an empty string, so the trigger matches any reaction again.
await dialog.getByRole("tab", { name: "Form" }).click();
// Switching editor modes drops the inspector selection, so re-open the
// trigger node before reaching for its fields.
await dialog.getByRole("button", { name: "Trigger: Reaction Added" }).click();
await dialog
.getByRole("button", { name: `Trigger: ${EMOJI} reaction added` })
.click();
await dialog.getByRole("button", { name: "Clear trigger emoji" }).click();
await expect(
dialog.getByRole("button", { name: "Choose trigger emoji" }),
@@ -109,7 +156,7 @@ test("picked reaction persists into canonical YAML and clears back out", async (
await dialog.getByRole("tab", { name: "YAML" }).click();
const clearedYaml = await yamlEditor.inputValue();
expect(clearedYaml).toContain("on: reaction_added");
expect(clearedYaml).not.toContain("emoji:");
expect(clearedYaml).not.toContain("trigger_emoji");
});
test("legacy free-text emoji values survive a trip through the picker", async ({
@@ -164,7 +211,7 @@ test("saved reaction filter round-trips back into the picker", async ({
const editDialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(editDialog).toBeVisible();
await editDialog
.getByRole("button", { name: "Trigger: Reaction Added" })
.getByRole("button", { name: `Trigger: ${EMOJI} reaction added` })
.click();
// Reopening rehydrates the stored glyph into the picker, not an empty field.

View File

@@ -62,6 +62,20 @@ async function addStepAfterTrigger(page: Page, dialog: Locator) {
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" });
@@ -73,8 +87,17 @@ async function createNamedWorkflow(page: Page, name: string) {
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("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);
}
@@ -154,10 +177,14 @@ test("renames stay visible across pane navigation before the step is filled in",
// 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");
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);
});
@@ -174,6 +201,7 @@ test("keeps a header disable across a later form edit", async ({ page }) => {
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
@@ -223,6 +251,7 @@ test("keeps the saved name while editing an existing workflow", async ({
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);
@@ -259,6 +288,7 @@ test("keeps the copy name while duplicating an existing workflow", async ({
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);

View File

@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { parse as parseYaml } from "yaml";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
@@ -74,9 +75,6 @@ async function createWorkflow(
);
await dialog.getByRole("tab", { name: "Form" }).click();
}
if (options?.enabled === false) {
await dialog.getByRole("switch", { name: "Enable" }).click();
}
if (options?.trigger) {
await dialog
.getByRole("button", { name: "Trigger: Message Posted" })
@@ -116,6 +114,16 @@ async function createWorkflow(
}
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" }),
@@ -131,6 +139,27 @@ test("navigates to workflows view and shows the empty create tile", async ({
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,
}) => {
@@ -211,6 +240,10 @@ test("accepts the generated name through the first form mutation", async ({
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);
});
@@ -249,9 +282,9 @@ test("shows executable guidance for diff trigger conditions", async ({
.getByRole("menuitem", { name: "Diff Posted", exact: true })
.click();
await expect(dialog.getByLabel("Condition (optional)")).toHaveAttribute(
await expect(dialog.getByLabel("Diff text")).toHaveAttribute(
"placeholder",
'e.g. str_contains(trigger_text, "deploy")',
"e.g. deploy",
);
});
@@ -300,11 +333,9 @@ test("captures workflow library across responsive viewports", async ({
test("captures disabled diff workflows in the list UI", async ({ page }) => {
const workflowName = `diff_workflow_${Date.now()}`;
const description = "Watches diff events for src/ changes";
await navigateToWorkflows(page);
await createWorkflow(page, workflowName, {
description,
enabled: false,
trigger: "diff_posted",
stepName: "Notify reviewers",
@@ -316,13 +347,18 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await expect(card.getByText("Diff Posted", { exact: true })).toBeVisible();
await expect(card.locator("h3")).toHaveText(workflowName);
await expect(card.getByText(description, { exact: true })).toBeVisible();
await expect(card).toContainText("disabled");
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 menu", async ({ page }) => {
test("enables and disables a workflow from its card status toggle", async ({
page,
}) => {
const workflowName = `toggle_workflow_${Date.now()}`;
await navigateToWorkflows(page);
@@ -333,26 +369,32 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
const workflowActions = () =>
workflowCard().getByRole("button", { name: "Workflow actions" });
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
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 workflowActions().click();
await expect(enableItem).toHaveAttribute("aria-checked", "true");
await expect(enableItem.locator("button")).toHaveCount(0);
await workflowCard()
.getByRole("button", { name: "Workflow actions" })
.click();
await expect(
enableItem.getByTestId("workflow-enabled-switch-visual"),
).toHaveAttribute("aria-hidden", "true");
await enableItem.click();
await expect(
workflowCard().getByText("disabled", { exact: true }),
).toBeVisible();
await enableItem.click();
await expect(
workflowCard().getByText("active", { exact: true }),
).toBeVisible();
page.getByRole("menuitemcheckbox", { name: "Enable" }),
).toHaveCount(0);
});
test("rejects a stale card toggle without overwriting a newer edit", async ({
@@ -395,8 +437,7 @@ test("rejects a stale card toggle without overwriting a newer edit", async ({
});
}, workflowName);
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
await workflowCard.getByRole("switch", { name: "Disable workflow" }).click();
await expect(
page
@@ -436,14 +477,15 @@ test("reports a rejected workflow status change", async ({ page }) => {
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
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.getByText("active", { exact: true })).toBeVisible();
await expect(
workflowCard.getByRole("switch", { name: "Disable workflow" }),
).toBeChecked();
});
test("shows the webhook secret dialog after saving a webhook workflow", async ({
@@ -528,8 +570,37 @@ test("duplicates a workflow", async ({ page }) => {
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);
});
@@ -742,7 +813,7 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async
await dialog.getByLabel("Duration").fill("5m");
await expect(page).toHaveURL(/pane=step%3Astep_2/);
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
await expect(page).toHaveURL(/pane=step%3Astep_1/);
await dialog
.getByTestId("workflow-node-inspector")
@@ -757,7 +828,7 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async
const yaml = await yamlEditor.inputValue();
await yamlEditor.fill(yaml.replace("duration: 5m", "duration: 10m"));
await dialog.getByRole("tab", { name: "Form" }).click();
await dialog.getByRole("button", { name: "Step 1: Delay" }).click();
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
await expect(dialog.getByLabel("Duration")).toHaveValue("10m");
});