mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
**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. | |  |  | | 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. | |  |  | ## 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>
253 lines
9.8 KiB
TypeScript
253 lines
9.8 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
// The Reaction Added trigger stores the reaction event's content verbatim — a
|
|
// native glyph or a custom `:shortcode:`. These cover the picker interaction and
|
|
// that the chosen value survives into canonical YAML and back out of a reload,
|
|
// which a free-text field silently got wrong (a typed `thumbsup` never matches).
|
|
|
|
const EMOJI = "🚀";
|
|
const EMOJI_SHORTCODE = ":rocket:";
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await installMockBridge(page);
|
|
});
|
|
|
|
async function openReactionTrigger(page: import("@playwright/test").Page) {
|
|
await page.goto("/");
|
|
await page.getByTestId("open-workflows-view").click();
|
|
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Create Workflow" }).click();
|
|
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
|
await expect(dialog).toBeVisible();
|
|
|
|
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();
|
|
|
|
await dialog.getByRole("button", { name: "Trigger: Message Posted" }).click();
|
|
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
|
await page.getByRole("menuitem", { name: "Reaction Added" }).click();
|
|
|
|
return dialog;
|
|
}
|
|
|
|
async function pickEmoji(
|
|
page: import("@playwright/test").Page,
|
|
dialog: import("@playwright/test").Locator,
|
|
query: string,
|
|
glyph: string,
|
|
) {
|
|
await dialog.getByRole("button", { name: "Choose trigger emoji" }).click();
|
|
const picker = page.locator("em-emoji-picker");
|
|
await expect(picker).toBeVisible();
|
|
await picker.locator("input[type='search']").fill(query);
|
|
await picker.locator(`button[aria-label='${glyph}']`).first().click();
|
|
}
|
|
|
|
test("reaction trigger uses the emoji picker instead of a free-text field", async ({
|
|
page,
|
|
}) => {
|
|
const dialog = await openReactionTrigger(page);
|
|
|
|
// The old plain text input is gone; the picker trigger stands in its place.
|
|
const pickerButton = dialog.getByRole("button", {
|
|
name: "Choose trigger emoji",
|
|
});
|
|
await expect(pickerButton).toBeVisible();
|
|
await expect(pickerButton).toHaveText(/Choose a reaction/);
|
|
await expect(
|
|
dialog.getByRole("textbox", { name: "Emoji filter (optional)" }),
|
|
).toHaveCount(0);
|
|
|
|
// Nothing to clear while the optional filter is empty.
|
|
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,
|
|
}) => {
|
|
const dialog = await openReactionTrigger(page);
|
|
await pickEmoji(page, dialog, "rocket", EMOJI);
|
|
|
|
await dialog.getByRole("tab", { name: "YAML" }).click();
|
|
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
|
const yaml = await yamlEditor.inputValue();
|
|
expect(yaml).toContain("on: reaction_added");
|
|
// 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: ${EMOJI} reaction added` })
|
|
.click();
|
|
await dialog.getByRole("button", { name: "Clear trigger emoji" }).click();
|
|
await expect(
|
|
dialog.getByRole("button", { name: "Choose trigger emoji" }),
|
|
).toHaveText(/Choose a reaction/);
|
|
|
|
await dialog.getByRole("tab", { name: "YAML" }).click();
|
|
const clearedYaml = await yamlEditor.inputValue();
|
|
expect(clearedYaml).toContain("on: reaction_added");
|
|
expect(clearedYaml).not.toContain("trigger_emoji");
|
|
});
|
|
|
|
test("legacy free-text emoji values survive a trip through the picker", async ({
|
|
page,
|
|
}) => {
|
|
// The field used to be free text, so existing definitions can hold a bare
|
|
// shortcode. Swapping in a picker must not silently drop a value it cannot
|
|
// render as a glyph — the user has to be able to see and re-pick it.
|
|
const dialog = await openReactionTrigger(page);
|
|
|
|
await dialog.getByRole("tab", { name: "YAML" }).click();
|
|
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
|
const seeded = (await yamlEditor.inputValue()).replace(
|
|
"trigger:\n on: reaction_added",
|
|
"trigger:\n on: reaction_added\n emoji: thumbsup",
|
|
);
|
|
await yamlEditor.fill(seeded);
|
|
|
|
await dialog.getByRole("tab", { name: "Form" }).click();
|
|
await dialog.getByRole("button", { name: "Trigger: Reaction Added" }).click();
|
|
const pickerButton = dialog.getByRole("button", {
|
|
name: "Choose trigger emoji",
|
|
});
|
|
await expect(pickerButton).toContainText("thumbsup");
|
|
await expect(pickerButton).not.toContainText("Choose a reaction");
|
|
|
|
await dialog.getByRole("tab", { name: "YAML" }).click();
|
|
expect(await yamlEditor.inputValue()).toContain("emoji: thumbsup");
|
|
});
|
|
|
|
test("saved reaction filter round-trips back into the picker", async ({
|
|
page,
|
|
}) => {
|
|
const name = `reaction_picker_${Date.now()}`;
|
|
const dialog = await openReactionTrigger(page);
|
|
|
|
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 pickEmoji(page, dialog, "rocket", EMOJI);
|
|
|
|
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");
|
|
await dialog.getByRole("button", { name: "Create" }).click();
|
|
await expect(page.getByRole("dialog")).not.toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
|
await page.getByRole("menuitem", { name: "Edit" }).click();
|
|
|
|
const editDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
|
await expect(editDialog).toBeVisible();
|
|
await editDialog
|
|
.getByRole("button", { name: `Trigger: ${EMOJI} reaction added` })
|
|
.click();
|
|
|
|
// Reopening rehydrates the stored glyph into the picker, not an empty field.
|
|
await expect(
|
|
editDialog.getByRole("button", { name: "Choose trigger emoji" }),
|
|
).toContainText(EMOJI_SHORTCODE);
|
|
});
|
|
|
|
test("emoji picker opens within the viewport at narrow width", async ({
|
|
page,
|
|
}) => {
|
|
// Reach the trigger inspector at desktop width — below 58rem the inspector
|
|
// becomes an absolute overlay covering the canvas nodes — then narrow the
|
|
// window with the inspector already open and drive the picker there.
|
|
const dialog = await openReactionTrigger(page);
|
|
await page.setViewportSize({ width: 800, height: 720 });
|
|
await waitForAnimations(page);
|
|
|
|
await dialog.getByRole("button", { name: "Choose trigger emoji" }).click();
|
|
const picker = page.locator("em-emoji-picker");
|
|
await expect(picker).toBeVisible();
|
|
await waitForAnimations(page);
|
|
|
|
// Radix collision handling must keep the popover on-screen, otherwise the
|
|
// picker is unreachable at this width.
|
|
const box = await picker.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
if (box) {
|
|
expect(box.x).toBeGreaterThanOrEqual(0);
|
|
expect(box.x + box.width).toBeLessThanOrEqual(800);
|
|
}
|
|
|
|
await picker.locator("input[type='search']").fill("rocket");
|
|
await picker.locator(`button[aria-label='${EMOJI}']`).first().click();
|
|
await expect(
|
|
dialog.getByRole("button", { name: "Choose trigger emoji" }),
|
|
).toContainText(EMOJI_SHORTCODE);
|
|
});
|