Polish workflow trigger conditions and sequence editor

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 22:35:03 -07:00
parent 2d51c6beef
commit 314cfd0b7b
9 changed files with 230 additions and 181 deletions
+60 -32
View File
@@ -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()
+8 -3
View File
@@ -47,6 +47,9 @@ pub enum TriggerDef {
/// Optional: only fire for this specific emoji.
#[serde(default)]
emoji: Option<String>,
/// Optional evalexpr filter over the reaction context.
#[serde(default)]
filter: Option<String>,
},
/// 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:?}"),
}
@@ -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 (
<div className="space-y-3">
<fieldset>
<legend className="sr-only">Condition</legend>
<fieldset aria-label="Condition">
<legend className="mb-2 text-sm font-medium text-foreground">
Run when
</legend>
<div className="grid grid-cols-2 gap-2.5">
{fieldOptions.map((field) => {
const isMatchAll = field.value === "";
@@ -331,19 +328,6 @@ export function WorkflowConditionBuilder({
}
value={editor.value}
/>
) : editor.field === "trigger_channel_id" ? (
<ChannelCombobox
ariaLabel="Channel ID"
channels={channels}
disabled={disabled}
emptyLabel="Choose a channel"
id={`${idPrefix}-value`}
onChange={(channelId) =>
emitEditor({ ...editor, value: channelId })
}
value={editor.value}
variant="field"
/>
) : editor.field === "trigger_emoji" ? (
<WorkflowEmojiField
ariaLabel="Choose condition emoji"
@@ -1,4 +1,5 @@
import {
ArrowDown,
Check,
ChevronDown,
Plus,
@@ -24,9 +25,9 @@ import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
import { WorkflowStepCard } from "./WorkflowStepCard";
import { buildConditionExpression } from "./workflowConditionExpression";
import { FieldLabel } from "./workflowFormPrimitives";
import {
DEFAULT_FORM_STATE,
@@ -78,19 +79,25 @@ function TriggerConfigFields({
);
case "reaction_added":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor="wf-trigger-emoji">
Emoji filter (optional)
</FieldLabel>
<WorkflowEmojiField
ariaLabel="Choose emoji filter"
clearAriaLabel="Clear emoji filter"
disabled={disabled}
id="wf-trigger-emoji"
onChange={(emoji) => onUpdate({ ...trigger, emoji })}
value={trigger.emoji}
/>
</div>
<WorkflowConditionBuilder
channels={channels}
disabled={disabled}
idPrefix="wf-trigger-filter"
matchAllLabel="All reactions"
onChange={(filter) =>
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 (
<li className="flex flex-col items-center">
@@ -295,14 +305,29 @@ function WorkflowNode({
) : null}
</div>
<span className="relative flex h-18 items-center justify-center">
<DropdownMenu>
<div
className="group relative flex h-18 items-center justify-center"
data-menu-open={addMenuOpen}
data-terminal={terminal}
data-testid="workflow-node-ingress"
>
{terminal ? null : (
<ArrowDown
aria-hidden="true"
className="h-5 w-5 text-muted-foreground transition-opacity duration-200 ease-out group-hover:opacity-10 group-data-[menu-open=true]:opacity-10 group-has-[:focus-visible]:opacity-10 motion-reduce:transition-none"
/>
)}
<DropdownMenu onOpenChange={setAddMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
aria-label={
title === "Trigger" ? "Add step" : `Add after ${title}`
}
className="relative z-10 h-7 w-7 rounded-full bg-background shadow-sm"
className={cn(
"relative z-10 h-7 w-7 rounded-full bg-background shadow-sm",
!terminal &&
"pointer-events-none absolute scale-125 opacity-0 transition-[opacity,transform,background-color,color,border-color,box-shadow] duration-200 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-data-[menu-open=true]:pointer-events-auto group-data-[menu-open=true]:scale-100 group-data-[menu-open=true]:opacity-100 focus-visible:pointer-events-auto focus-visible:scale-100 focus-visible:opacity-100 motion-reduce:transition-none",
)}
disabled={disabled}
size="icon"
type="button"
@@ -325,7 +350,7 @@ function WorkflowNode({
))}
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
</li>
);
}
@@ -348,7 +373,9 @@ export function WorkflowFormBuilder({
? initialParseRef.current.state
: DEFAULT_FORM_STATE,
);
const [selectedNode, setSelectedNode] = React.useState<SelectedNode>(null);
const [selectedNode, setSelectedNode] = React.useState<SelectedNode>({
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 ? (
<li className="flex justify-center">
<span className="rounded-full border border-border bg-muted/30 px-5 py-1.5 text-xs font-medium text-muted-foreground">
End
</span>
</li>
) : null}
</ol>
</div>
</div>
@@ -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({
</section>
<section className="space-y-4 border-t border-border/50 py-5">
<SectionHeading title="Run controls" />
<div>
<WorkflowConditionBuilder
channels={channels}
disabled={disabled}
idPrefix={`${prefix}-condition`}
matchAllLabel="Every run"
onChange={(condition) => onUpdate({ ...step, condition })}
triggerType={triggerType}
value={step.condition ?? ""}
/>
</div>
<SectionHeading title="Step timeout" />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
Timeout (seconds)
@@ -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",
@@ -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<TriggerType, ConditionField[]> = {
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(
@@ -181,7 +181,8 @@ export function formStateToYaml(state: WorkflowFormState): string {
const trigger: Record<string, unknown> = { 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;
+69 -59
View File
@@ -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");