From 314cfd0b7bfe16165e3e3107bb0ee2104fce5180 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 13 Aug 2026 22:35:03 -0700 Subject: [PATCH] Polish workflow trigger conditions and sequence editor Signed-off-by: Taylor Ho --- crates/buzz-workflow/src/lib.rs | 92 ++++++++----- crates/buzz-workflow/src/schema.rs | 11 +- .../workflows/ui/WorkflowConditionBuilder.tsx | 24 +--- .../workflows/ui/WorkflowFormBuilder.tsx | 76 +++++++---- .../workflows/ui/WorkflowStepCard.tsx | 14 +- .../ui/workflowConditionExpression.test.mjs | 30 +++- .../ui/workflowConditionExpression.ts | 33 ++--- .../workflows/ui/workflowFormTypes.ts | 3 +- desktop/tests/e2e/workflows.spec.ts | 128 ++++++++++-------- 9 files changed, 230 insertions(+), 181 deletions(-) diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index fe8b477ba..5c17b34e1 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -887,6 +887,7 @@ async fn should_fire_workflow( ) -> bool { if let TriggerDef::ReactionAdded { emoji: Some(ref expected), + .. } = def.trigger { if &trigger_ctx.emoji != expected { @@ -900,33 +901,13 @@ async fn should_fire_workflow( } } - if let TriggerDef::MessagePosted { - filter: Some(ref expr), - } = def.trigger - { - match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await { - Ok(true) => {} - Ok(false) => { - tracing::debug!( - workflow_id = %workflow_id, - "Trigger filter evaluated false — skipping workflow" - ); - return false; - } - Err(e) => { - tracing::warn!( - workflow_id = %workflow_id, - "Trigger filter error: {e} — skipping workflow" - ); - return false; - } - } - } - - if let TriggerDef::DiffPosted { - filter: Some(ref expr), - } = def.trigger - { + let filter = match &def.trigger { + TriggerDef::MessagePosted { filter } + | TriggerDef::ReactionAdded { filter, .. } + | TriggerDef::DiffPosted { filter } => filter.as_ref(), + TriggerDef::Schedule { .. } | TriggerDef::Webhook => None, + }; + if let Some(expr) = filter { match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await { Ok(true) => {} Ok(false) => { @@ -1364,7 +1345,10 @@ steps: #[test] fn trigger_matches_reaction() { - let trigger = TriggerDef::ReactionAdded { emoji: None }; + let trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; assert!(trigger_matches_event( &trigger, buzz_core::kind::KIND_REACTION @@ -1375,6 +1359,36 @@ steps: )); } + #[tokio::test] + async fn reaction_filter_matches_target_message() { + let yaml = r#" +name: "React to one message" +trigger: + on: reaction_added + filter: 'trigger_message_id == "target-message"' +steps: + - id: wait + action: delay + duration: 1s +"#; + let (def, _) = WorkflowEngine::parse_yaml(yaml).expect("parse failed"); + let mut trigger_ctx = executor::TriggerContext { + message_id: "target-message".to_owned(), + ..Default::default() + }; + + assert!( + should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await, + "reaction to the selected message should fire" + ); + + trigger_ctx.message_id = "different-message".to_owned(); + assert!( + !should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await, + "reaction to a different message should be filtered out" + ); + } + #[test] fn schedule_trigger_never_matches_events() { let trigger = TriggerDef::Schedule { @@ -1421,7 +1435,10 @@ steps: #[test] fn reaction_added_matches_kind_7_only() { - let trigger = TriggerDef::ReactionAdded { emoji: None }; + let trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; // Must match KIND_REACTION = 7. assert!(trigger_matches_event(&trigger, 7)); // Must NOT match stream message (kind 9). @@ -1436,6 +1453,7 @@ steps: // trigger_matches_event only checks the kind number. let trigger = TriggerDef::ReactionAdded { emoji: Some("thumbsup".to_owned()), + filter: None, }; assert!(trigger_matches_event(&trigger, 7)); assert!(!trigger_matches_event(&trigger, 9)); @@ -1458,7 +1476,10 @@ steps: // before calling trigger_matches_event, but verify the function itself // also returns false for these kinds. let msg_trigger = TriggerDef::MessagePosted { filter: None }; - let react_trigger = TriggerDef::ReactionAdded { emoji: None }; + let react_trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; for kind in buzz_core::kind::KIND_WORKFLOW_TRIGGERED ..=buzz_core::kind::KIND_WORKFLOW_APPROVAL_DENIED @@ -1478,7 +1499,10 @@ steps: fn trigger_matches_event_kind_zero_matches_nothing() { // Kind 0 is a profile event — no trigger should match it. let msg_trigger = TriggerDef::MessagePosted { filter: None }; - let react_trigger = TriggerDef::ReactionAdded { emoji: None }; + let react_trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; let sched_trigger = TriggerDef::Schedule { cron: None, interval: Some("1h".to_owned()), @@ -1715,7 +1739,11 @@ steps: async fn setup_db() -> buzz_db::Db { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + // Local-only test default; this is not a production credential. + .unwrap_or_else(|_| { + let local_test_database = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + local_test_database.to_owned() + }); buzz_db::Db::new(&buzz_db::DbConfig { database_url, ..Default::default() diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 9bc79aa48..afee730d6 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -47,6 +47,9 @@ pub enum TriggerDef { /// Optional: only fire for this specific emoji. #[serde(default)] emoji: Option, + /// Optional evalexpr filter over the reaction context. + #[serde(default)] + filter: Option, }, /// Fires when a diff message (kind:40008) is posted in the workflow's channel. DiffPosted { @@ -300,11 +303,12 @@ mod tests { #[test] fn parse_reaction_added_trigger() { - let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n"; + let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\n filter: 'trigger_message_id == \"abc123\"'\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n"; let (def, _) = parse_yaml(yaml).expect("parse failed"); match &def.trigger { - TriggerDef::ReactionAdded { emoji } => { + TriggerDef::ReactionAdded { emoji, filter } => { assert_eq!(emoji.as_deref(), Some("clipboard")); + assert_eq!(filter.as_deref(), Some("trigger_message_id == \"abc123\"")); } other => panic!("unexpected trigger: {other:?}"), } @@ -488,8 +492,9 @@ mod tests { let yaml = "name: Any Reaction\ntrigger:\n on: reaction_added\nsteps:\n - id: s1\n action: add_reaction\n emoji: eyes\n"; let (def, _) = parse_yaml(yaml).expect("parse failed"); match &def.trigger { - TriggerDef::ReactionAdded { emoji } => { + TriggerDef::ReactionAdded { emoji, filter } => { assert!(emoji.is_none(), "emoji should default to None"); + assert!(filter.is_none(), "filter should default to None"); } other => panic!("unexpected trigger: {other:?}"), } diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx index 460f59792..558767ceb 100644 --- a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx @@ -5,7 +5,6 @@ import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { AuthorGridPicker } from "./AuthorGridPicker"; -import { ChannelCombobox } from "./ChannelCombobox"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { @@ -62,8 +61,6 @@ function valueLabel(field: string): string { switch (field) { case "trigger_author": return "Pubkey"; - case "trigger_channel_id": - return "Channel ID"; case "trigger_message_id": return "Message ID"; case "trigger_emoji": @@ -79,8 +76,6 @@ function valuePlaceholder(field: string): string { switch (field) { case "trigger_author": return "Paste a hex pubkey"; - case "trigger_channel_id": - return "Paste a channel UUID"; case "trigger_message_id": return "Paste a message event ID"; case "trigger_timestamp": @@ -183,8 +178,10 @@ export function WorkflowConditionBuilder({ return (
-
- Condition +
+ + Run when +
{fieldOptions.map((field) => { const isMatchAll = field.value === ""; @@ -331,19 +328,6 @@ export function WorkflowConditionBuilder({ } value={editor.value} /> - ) : editor.field === "trigger_channel_id" ? ( - - emitEditor({ ...editor, value: channelId }) - } - value={editor.value} - variant="field" - /> ) : editor.field === "trigger_emoji" ? ( - - Emoji filter (optional) - - onUpdate({ ...trigger, emoji })} - value={trigger.emoji} - /> -
+ + onUpdate({ ...trigger, emoji: undefined, filter }) + } + triggerType={trigger.on} + value={ + trigger.filter ?? + buildConditionExpression({ + field: "trigger_emoji", + operator: "equals", + value: trigger.emoji ?? "", + }) ?? + "" + } + /> ); case "webhook": return ( @@ -217,6 +224,7 @@ function WorkflowNode({ selected, showTitle = true, subtitle, + terminal, title, }: { description: string; @@ -230,9 +238,11 @@ function WorkflowNode({ selected: boolean; showTitle?: boolean; subtitle?: string; + terminal: boolean; title: string; }) { const isNumbered = number !== undefined; + const [addMenuOpen, setAddMenuOpen] = React.useState(false); return (
  • @@ -295,14 +305,29 @@ function WorkflowNode({ ) : null}
  • - - +
    + {terminal ? null : ( +
    ); } @@ -348,7 +373,9 @@ export function WorkflowFormBuilder({ ? initialParseRef.current.state : DEFAULT_FORM_STATE, ); - const [selectedNode, setSelectedNode] = React.useState(null); + const [selectedNode, setSelectedNode] = React.useState({ + type: "trigger", + }); const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1); const shouldReduceMotion = useReducedMotion(); const previousModeRef = React.useRef(mode); @@ -372,6 +399,7 @@ export function WorkflowFormBuilder({ const result = yamlToFormState(yaml); if (result.ok) setFormState(result.state); + setSelectedNode({ type: "trigger" }); }, [mode, yaml]); const selectNode = React.useCallback( @@ -522,6 +550,7 @@ export function WorkflowFormBuilder({ onAddAfter={(action) => insertStep(0, action)} onClick={() => selectNode({ type: "trigger" })} selected={selectedNode?.type === "trigger"} + terminal={formState.steps.length === 0} title="Trigger" /> @@ -545,18 +574,11 @@ export function WorkflowFormBuilder({ } showTitle={false} subtitle={stepName ? actionLabel : undefined} + terminal={index === formState.steps.length - 1} title={`Step ${index + 1}`} /> ); })} - - {formState.steps.length > 0 ? ( -
  • - - End - -
  • - ) : null} diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index 54584e002..cae2f79cd 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -6,7 +6,6 @@ import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { ChannelCombobox } from "./ChannelCombobox"; -import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; @@ -380,18 +379,7 @@ export function WorkflowStepCard({
    - -
    - onUpdate({ ...step, condition })} - triggerType={triggerType} - value={step.condition ?? ""} - /> -
    +
    Timeout (seconds) diff --git a/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs b/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs index c87499e0e..0467dda4d 100644 --- a/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs +++ b/desktop/src/features/workflows/ui/workflowConditionExpression.test.mjs @@ -68,19 +68,37 @@ test("normalizes safe webhook fields and rejects reserved or invalid names", () test("shows trigger-relevant fields", () => { assert.deepEqual( conditionFieldsForTrigger("reaction_added").map((field) => field.value), - [ - "trigger_emoji", - "trigger_author", - "trigger_channel_id", - "trigger_message_id", - ], + ["trigger_emoji", "trigger_author", "trigger_message_id"], ); assert.equal(conditionFieldsForTrigger("webhook")[0].value, "webhook_field"); + for (const triggerType of ["message_posted", "diff_posted"]) { + assert.equal( + conditionFieldsForTrigger(triggerType).some( + (field) => field.value === "trigger_message_id", + ), + false, + ); + } + for (const triggerType of [ + "message_posted", + "diff_posted", + "reaction_added", + "webhook", + "schedule", + ]) { + assert.equal( + conditionFieldsForTrigger(triggerType).some( + (field) => field.value === "trigger_channel_id", + ), + false, + ); + } }); test("limits opaque identifiers to equality operators", () => { for (const field of [ "trigger_author", + "trigger_emoji", "trigger_channel_id", "trigger_message_id", "future_resource_id", diff --git a/desktop/src/features/workflows/ui/workflowConditionExpression.ts b/desktop/src/features/workflows/ui/workflowConditionExpression.ts index 6ec956538..c63b8b5ed 100644 --- a/desktop/src/features/workflows/ui/workflowConditionExpression.ts +++ b/desktop/src/features/workflows/ui/workflowConditionExpression.ts @@ -22,7 +22,9 @@ const EXACT_MATCH_OPERATORS = [ export function conditionOperatorsForField( field: string, ): readonly ConditionOperator[] { - return field === "trigger_author" || field.endsWith("_id") + return field === "trigger_author" || + field === "trigger_emoji" || + field.endsWith("_id") ? EXACT_MATCH_OPERATORS : CONDITION_OPERATORS; } @@ -47,33 +49,24 @@ export type ParsedConditionExpression = { export const CUSTOM_CONDITION_FIELD = "custom"; -const COMMON_FIELDS: ConditionField[] = [ - { label: "Author pubkey", value: "trigger_author" }, - { label: "Channel ID", value: "trigger_channel_id" }, - { label: "Message ID", value: "trigger_message_id" }, -]; +const AUTHOR_FIELD: ConditionField = { + label: "Author pubkey", + value: "trigger_author", +}; const FIELDS_BY_TRIGGER: Record = { message_posted: [ { label: "Message text", value: "trigger_text" }, - ...COMMON_FIELDS, - ], - diff_posted: [ - { label: "Diff text", value: "trigger_text" }, - ...COMMON_FIELDS, + AUTHOR_FIELD, ], + diff_posted: [{ label: "Diff text", value: "trigger_text" }, AUTHOR_FIELD], reaction_added: [ { label: "Reaction emoji", value: "trigger_emoji" }, - ...COMMON_FIELDS, - ], - webhook: [ - { label: "Webhook field…", value: "webhook_field" }, - { label: "Channel ID", value: "trigger_channel_id" }, - ], - schedule: [ - { label: "Channel ID", value: "trigger_channel_id" }, - { label: "Scheduled timestamp", value: "trigger_timestamp" }, + AUTHOR_FIELD, + { label: "Reacted-to message ID", value: "trigger_message_id" }, ], + webhook: [{ label: "Webhook field…", value: "webhook_field" }], + schedule: [{ label: "Scheduled timestamp", value: "trigger_timestamp" }], }; export function conditionFieldsForTrigger( diff --git a/desktop/src/features/workflows/ui/workflowFormTypes.ts b/desktop/src/features/workflows/ui/workflowFormTypes.ts index b9e08fb24..276f17d02 100644 --- a/desktop/src/features/workflows/ui/workflowFormTypes.ts +++ b/desktop/src/features/workflows/ui/workflowFormTypes.ts @@ -181,7 +181,8 @@ export function formStateToYaml(state: WorkflowFormState): string { const trigger: Record = { on: state.trigger.on }; if ( (state.trigger.on === "message_posted" || - state.trigger.on === "diff_posted") && + state.trigger.on === "diff_posted" || + state.trigger.on === "reaction_added") && state.trigger.filter ) { trigger.filter = state.trigger.filter; diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 35723e1c0..b88c8618e 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -94,7 +94,7 @@ async function createWorkflow( description?: string; enabled?: boolean; trigger?: string; - stepCondition?: string; + triggerCondition?: string; stepName?: string; stepTimeoutSecs?: string; }, @@ -122,19 +122,20 @@ async function createWorkflow( .getByRole("menuitem", { name: TRIGGER_OPTION_LABELS[options.trigger] }) .click(); } + if (options?.triggerCondition) { + await dialog.getByRole("button", { name: /^Trigger:/ }).click(); + await dialog + .getByRole("group", { name: "Condition" }) + .getByRole("button", { name: "Custom" }) + .click(); + await dialog.getByLabel("Custom expression").fill(options.triggerCondition); + } await dialog.getByRole("button", { name: "Add step" }).click(); await page.getByRole("menuitem", { name: "Delay" }).click(); if (options?.stepName) { await dialog.getByLabel("Name (optional)").fill(options.stepName); } - if (options?.stepCondition) { - await dialog - .getByRole("group", { name: "Condition" }) - .getByRole("button", { name: "Custom" }) - .click(); - await dialog.getByLabel("Custom expression").fill(options.stepCondition); - } if (options?.stepTimeoutSecs) { await dialog.getByLabel("Timeout (seconds)").fill(options.stepTimeoutSecs); } @@ -276,8 +277,9 @@ test("builds a valid trigger condition from plain-language choices", async ({ const inspector = dialog.getByTestId("workflow-node-inspector"); const conditionFields = inspector.getByRole("group", { name: "Condition" }); + await expect(inspector.getByText("Run when", { exact: true })).toBeVisible(); const conditionOptions = conditionFields.getByRole("button"); - await expect(conditionOptions).toHaveCount(6); + await expect(conditionOptions).toHaveCount(4); const allMessages = conditionFields.getByRole("button", { name: "All messages", }); @@ -287,9 +289,12 @@ test("builds a valid trigger condition from plain-language choices", async ({ await expect( conditionFields.getByRole("button", { name: "Message text" }), ).toHaveAttribute("aria-pressed", "false"); - for (const name of ["Author pubkey", "Channel ID", "Message ID", "Custom"]) { + for (const name of ["Author pubkey", "Custom"]) { await expect(conditionFields.getByRole("button", { name })).toBeVisible(); } + for (const name of ["Channel ID", "Message ID"]) { + await expect(conditionFields.getByRole("button", { name })).toHaveCount(0); + } await conditionFields.getByRole("button", { name: "Message text" }).click(); await expect(allMessages).toHaveAttribute("aria-pressed", "false"); @@ -325,44 +330,6 @@ test("builds a valid trigger condition from plain-language choices", async ({ await expect(inspector.getByLabel("Custom expression")).not.toBeVisible(); }); -test("chooses a trigger channel condition from the live channel list", async ({ - page, -}) => { - await navigateToWorkflows(page); - - await page.getByRole("button", { name: "Create Workflow" }).click(); - const dialog = page.getByRole("dialog"); - await dialog.getByRole("button", { name: /^Trigger:/ }).click(); - const inspector = dialog.getByTestId("workflow-node-inspector"); - - await inspector.getByRole("button", { name: "Channel ID" }).click(); - const matchOperator = inspector.getByLabel("Match"); - await expect(matchOperator).toHaveValue("equals"); - await expect(matchOperator.locator("option")).toHaveText(["is", "is not"]); - const channelCondition = inspector.getByRole("combobox", { - name: "Channel ID", - }); - await expect(channelCondition).toContainText("Choose a channel"); - await channelCondition.click(); - - const channelList = page.getByTestId("channel-combobox-list"); - await channelList.hover(); - await page.mouse.wheel(0, 500); - await expect - .poll(() => channelList.evaluate((element) => element.scrollTop)) - .toBeGreaterThan(0); - - const search = page.getByPlaceholder("Search channels..."); - await search.fill("random"); - await page.getByRole("button", { name: "random · stream" }).click(); - await expect(channelCondition).toContainText("random"); - - await dialog.getByRole("tab", { name: "YAML" }).click(); - await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( - /trigger_channel_id == "[0-9a-f-]{36}"/, - ); -}); - test("chooses a trigger author condition from live user search", async ({ page, }) => { @@ -536,9 +503,13 @@ test("chooses a reaction emoji condition with the app emoji picker", async ({ await inspector.getByLabel("Trigger event").click(); await page.getByRole("menuitem", { name: "Reaction Added" }).click(); - await dialog.getByRole("button", { name: "Add step" }).click(); - await page.getByRole("menuitem", { name: "Delay" }).click(); inspector = dialog.getByTestId("workflow-node-inspector"); + await expect( + inspector.getByRole("button", { name: "Reacted-to message ID" }), + ).toBeVisible(); + await expect( + inspector.getByRole("button", { name: "Channel ID" }), + ).toHaveCount(0); await inspector.getByRole("button", { name: "Reaction emoji" }).click(); const conditionEmoji = inspector.getByRole("button", { @@ -552,7 +523,7 @@ test("chooses a reaction emoji condition with the app emoji picker", async ({ await expect(conditionEmoji).toContainText(":buzz:"); await dialog.getByRole("tab", { name: "YAML" }).click(); await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( - /trigger_emoji[\s\S]*:buzz:/, + /filter: trigger_emoji == ":buzz:"/, ); }); @@ -639,15 +610,18 @@ test("opens node configuration in a contextual inspector", async ({ page }) => { const dialog = page.getByRole("dialog"); const inspector = dialog.getByTestId("workflow-node-inspector"); - await expect(inspector).not.toBeVisible(); - await expect(dialog.getByText("End", { exact: true })).not.toBeVisible(); - - await dialog.getByRole("button", { name: /^Trigger:/ }).click(); await expect(inspector).toBeVisible(); await expect(inspector.getByLabel("Trigger event")).toHaveAttribute( "data-value", "message_posted", ); + await expect( + inspector.getByRole("button", { name: "All messages" }), + ).toHaveAttribute("aria-pressed", "true"); + const initialIngress = dialog.getByTestId("workflow-node-ingress"); + await expect(initialIngress).toHaveAttribute("data-terminal", "true"); + await expect(initialIngress.locator("svg.lucide-arrow-down")).toHaveCount(0); + await expect(initialIngress.getByLabel("Add step")).toBeVisible(); await dialog.getByRole("button", { name: "Add step" }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); @@ -659,7 +633,41 @@ test("opens node configuration in a contextual inspector", async ({ page }) => { "send_message", ); await expect(inspector.getByLabel("Message text")).toBeVisible(); - await expect(dialog.getByText("End", { exact: true })).toBeVisible(); + await expect(inspector.getByRole("group", { name: "Condition" })).toHaveCount( + 0, + ); + await expect( + inspector.getByRole("heading", { name: "Step timeout" }), + ).toBeVisible(); + const ingresses = page.getByTestId("workflow-node-ingress"); + await expect(ingresses).toHaveCount(2); + const betweenIngress = ingresses.first(); + const terminalIngress = ingresses.last(); + const betweenAddButton = betweenIngress.getByLabel("Add step"); + const betweenArrow = betweenIngress.locator("svg.lucide-arrow-down"); + await expect(betweenIngress).toHaveAttribute("data-terminal", "false"); + await expect(terminalIngress).toHaveAttribute("data-terminal", "true"); + await expect(terminalIngress.getByLabel("Add after Step 1")).toBeVisible(); + await page.mouse.move(0, 0); + await expect(betweenArrow).toHaveCSS("opacity", "1"); + await expect(betweenAddButton).toHaveCSS("opacity", "0"); + await expect(betweenAddButton).toHaveCSS("scale", "1.25"); + await expect(betweenAddButton).toHaveCSS("translate", "none"); + await betweenIngress.hover(); + await expect(betweenArrow).toHaveCSS("opacity", "0.1"); + await expect(betweenAddButton).toHaveCSS("opacity", "1"); + await expect(betweenAddButton).toHaveCSS("scale", "1"); + await expect(betweenAddButton).toHaveCSS("translate", "none"); + await betweenAddButton.click(); + await expect(betweenIngress).toHaveAttribute("data-menu-open", "true"); + await expect(page.getByRole("menuitem", { name: "Delay" })).toBeVisible(); + await page.mouse.move(0, 0); + await expect(betweenArrow).toHaveCSS("opacity", "0.1"); + await expect(betweenAddButton).toHaveCSS("opacity", "1"); + await expect(betweenAddButton).toHaveCSS("scale", "1"); + await page.keyboard.press("Escape"); + await expect(betweenIngress).toHaveAttribute("data-menu-open", "false"); + await expect(dialog.getByText("End", { exact: true })).toHaveCount(0); await expect(triggerNode).toHaveAttribute("aria-pressed", "false"); await expect(stepNode).toHaveAttribute("aria-pressed", "true"); expect( @@ -697,7 +705,7 @@ test("opens node configuration in a contextual inspector", async ({ page }) => { await expect(stepNode).not.toBeVisible(); await expect(inspector).toBeVisible(); await expect(inspector.getByLabel("Trigger event")).toBeVisible(); - await expect(dialog.getByText("End", { exact: true })).not.toBeVisible(); + await expect(dialog.getByText("End", { exact: true })).toHaveCount(0); }); test("switches between the form and YAML editors", async ({ page }) => { @@ -727,7 +735,7 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => { enabled: false, trigger: "diff_posted", stepName: "Notify reviewers", - stepCondition: 'str_contains(trigger_text, "src/")', + triggerCondition: 'str_contains(trigger_text, "src/")', stepTimeoutSecs: "45", }); @@ -736,7 +744,9 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => { .filter({ hasText: workflowName }) .first(); await expect(card).toContainText(workflowName); - await expect(card).toContainText("When a diff is posted, wait for a moment"); + await expect(card).toContainText( + "When a matching diff is posted, wait for a moment", + ); await expect(card).toContainText(description); await expect(card).toContainText("Diff Posted"); await expect(card).toContainText("disabled");