mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
**Category:** improvement **User Impact:** Workflow authors can discover people and messages while configuring trigger filters, then see readable enriched labels instead of raw identifiers. **Problem:** Author and message filters required users to know and paste raw public keys or event IDs, and configured workflows surfaced those opaque values afterward. **Solution:** Add network-backed pickers and presentation enrichment while keeping deterministic local public-key and event-ID fallbacks authoritative whenever discovery is unavailable or untrusted. Related issue: none found. <details> <summary>File changes</summary> **desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx** Adds channel-aware author discovery, profile search, keyboard navigation, loading states, and deterministic public-key fallback selection. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Uses enriched trigger presentation when building the workflow card’s readable summary. **desktop/src/features/workflows/ui/WorkflowDialog.tsx** Keeps Escape scoped to an active filter picker before allowing the inspector or dialog to close. **desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx** Threads channel context into trigger filters and renders enriched author/message summaries in the workflow sequence. **desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx** Adds paged channel-history discovery, message search, exact event lookup, profile labels, keyboard navigation, and bounded results. **desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx** Renders compact author identity details and loading presentation inside trigger summaries. **desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx** Connects author and message filter accordions to their pickers while preserving selected and excluded condition semantics. **desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts** Resolves configured author keys to trusted display labels with deterministic fallbacks. **desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts** Enriches configured message IDs only after validating the fetched event and channel. **desktop/src/features/workflows/ui/workflowAuthorCandidates.test.mjs** Covers author candidate normalization, ordering, deduplication, and fallback behavior. **desktop/src/features/workflows/ui/workflowAuthorCandidates.ts** Builds stable author candidates from channel members, profiles, and raw public keys. **desktop/src/features/workflows/ui/workflowConditionExpression.ts** Allows message IDs to participate in basic trigger-filter parsing. **desktop/src/features/workflows/ui/workflowDefinition.ts** Accepts enriched trigger text when generating workflow card labels. **desktop/src/features/workflows/ui/workflowMessageCandidates.test.mjs** Covers event validation, source merging, deterministic ordering, and exact-lookup enrichment boundaries. **desktop/src/features/workflows/ui/workflowMessageCandidates.ts** Validates message candidates by event kind, channel, and exact event ID before permitting enrichment. **desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs** Covers readable selected/excluded author and message descriptions plus loading fallbacks. **desktop/src/features/workflows/ui/workflowTriggerDescription.ts** Builds concise enriched trigger descriptions while retaining stable raw-ID fallbacks. **desktop/tests/e2e/workflow-local-controls.spec.ts** Exercises picker discovery, selection toggles, Escape ownership, bounded scrolling, and enriched workflow summaries. </details> ## Reproduction steps 1. Open Workflows and create a workflow for a channel with members and message history. 2. Choose **Reaction Added** as the trigger and expand **Author**. 3. Confirm channel members and fetched profile results are discoverable, searchable, and keyboard accessible; choose one. 4. Expand **Message**, confirm recent channel messages appear in a bounded list, and choose one. 5. Toggle either selected filter between **is** and **is not**, then collapse the inspector and confirm the sequence summary stays readable. 6. Add a send-message step and create the workflow; confirm its card uses the resolved author and message labels. 7. Repeat while discovery is unavailable and confirm raw public keys/event IDs remain selectable and authoritative. ## Screenshots ### Author discovery  ### Message discovery  ### Selected filter summaries  ### Enriched workflow card  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <0366ccd5ee09c2779a9d6bd6683daa17c16a508a51f6a7e7314018dab8fdc49b@buzz.block.builderlab.xyz> Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
877 lines
31 KiB
TypeScript
877 lines
31 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||
|
||
import { waitForAnimations } from "../helpers/animations";
|
||
import { installMockBridge } from "../helpers/bridge";
|
||
|
||
declare global {
|
||
interface Window {
|
||
__BUZZ_WORKFLOW_BATCH_CALLS__?: {
|
||
eventBatches: number[];
|
||
userBatches: number[];
|
||
};
|
||
}
|
||
}
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await installMockBridge(page);
|
||
});
|
||
|
||
async function openCreateWorkflow(
|
||
page: import("@playwright/test").Page,
|
||
name: string,
|
||
) {
|
||
await page.goto("/");
|
||
await page.getByTestId("open-workflows-view").click();
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
const channelList = page.getByTestId("channel-combobox-list");
|
||
if (!(await channelList.isVisible())) {
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
}
|
||
await channelList
|
||
.getByRole("option", { name: "agents", exact: true })
|
||
.click();
|
||
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();
|
||
return dialog;
|
||
}
|
||
|
||
async function selectTrigger(
|
||
page: import("@playwright/test").Page,
|
||
dialog: import("@playwright/test").Locator,
|
||
trigger: string,
|
||
) {
|
||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||
await page.getByRole("menuitem", { name: trigger, exact: true }).click();
|
||
}
|
||
|
||
async function openTriggerInspector(
|
||
dialog: import("@playwright/test").Locator,
|
||
) {
|
||
const menu = dialog.getByRole("button", { name: "Trigger event" });
|
||
if (!(await menu.isVisible())) {
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
}
|
||
await expect(menu).toBeVisible();
|
||
}
|
||
|
||
async function addMessageStep(
|
||
page: import("@playwright/test").Page,
|
||
dialog: import("@playwright/test").Locator,
|
||
) {
|
||
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");
|
||
}
|
||
|
||
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,
|
||
) {
|
||
const card = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: name });
|
||
await card.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||
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,
|
||
}) => {
|
||
const name = `schedule_controls_${Date.now()}`;
|
||
const dialog = await openCreateWorkflow(page, name);
|
||
await selectTrigger(page, dialog, "Schedule");
|
||
|
||
await expect(dialog.getByRole("radio", { name: "Daily" })).toBeChecked();
|
||
await expect(dialog.getByLabel("Run time (UTC)")).toHaveValue("09:00");
|
||
|
||
await dialog.getByText("Every 15 minutes", { exact: true }).click();
|
||
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: "schedule", interval: "15m" });
|
||
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await openTriggerInspector(dialog);
|
||
await expect(
|
||
dialog.getByRole("radio", { name: "Every 15 minutes" }),
|
||
).toBeChecked();
|
||
|
||
await dialog.getByText("Monthly", { exact: true }).click();
|
||
await dialog.getByLabel("Day of month").selectOption("31");
|
||
await expect(
|
||
dialog.getByText("This schedule won’t run in some months."),
|
||
).toBeVisible();
|
||
|
||
await dialog.getByText("Custom cron", { exact: true }).click();
|
||
await dialog.getByRole("textbox", { name: "Minute", exact: true }).fill("5");
|
||
await dialog.getByRole("textbox", { name: "Hour", exact: true }).fill("*/2");
|
||
await dialog.getByRole("textbox", { name: "Day", exact: true }).fill("*");
|
||
await dialog.getByRole("textbox", { name: "Month", exact: true }).fill("*");
|
||
await dialog
|
||
.getByRole("textbox", { name: "Weekday", exact: true })
|
||
.fill("2-4");
|
||
await expect(dialog.getByText(/UTC · Paste all 5 fields/)).toBeVisible();
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
definition = parseYaml(await yamlEditor.inputValue());
|
||
expect(definition.trigger).toEqual({
|
||
on: "schedule",
|
||
cron: "5 */2 * * 2-4",
|
||
});
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await openTriggerInspector(dialog);
|
||
await expect(
|
||
dialog.getByRole("radio", { name: "Custom cron" }),
|
||
).toBeChecked();
|
||
await expect(
|
||
dialog.getByRole("textbox", { name: "Weekday", exact: true }),
|
||
).toHaveValue("2-4");
|
||
|
||
await addMessageStep(page, dialog);
|
||
await createEnabled(page, dialog);
|
||
const reopened = await reopenWorkflow(page, name);
|
||
await openTriggerInspector(reopened);
|
||
await expect(
|
||
reopened.getByRole("radio", { name: "Custom cron" }),
|
||
).toBeChecked();
|
||
await expect(
|
||
reopened.getByRole("textbox", { name: "Minute", exact: true }),
|
||
).toHaveValue("5");
|
||
await expect(
|
||
reopened.getByRole("textbox", { name: "Weekday", exact: true }),
|
||
).toHaveValue("2-4");
|
||
});
|
||
|
||
test("round-trips and reopens structured message-text conditions", async ({
|
||
page,
|
||
}) => {
|
||
const name = `message_condition_${Date.now()}`;
|
||
const text = 'deploy "buzz"\\path';
|
||
const expression = 'str_ends_with(trigger_text, "deploy \\"buzz\\"\\\\path")';
|
||
const dialog = await openCreateWorkflow(page, name);
|
||
|
||
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);
|
||
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,
|
||
);
|
||
await dialog.getByRole("tab", { name: "Basic" }).click();
|
||
|
||
await addMessageStep(page, dialog);
|
||
await createEnabled(page, dialog);
|
||
const reopened = await reopenWorkflow(page, name);
|
||
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("Escape closes a filter picker, restores disclosure focus, then closes its inspector", async ({
|
||
page,
|
||
}) => {
|
||
for (const width of [760, 1280]) {
|
||
await page.setViewportSize({ width, height: 820 });
|
||
await page.goto(
|
||
"/#/workflows?view=create&channel=94a444a4-c0a3-5966-ab05-530c6ddc2301&pane=trigger",
|
||
);
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
await selectTrigger(page, dialog, "Reaction Added");
|
||
for (const field of [
|
||
{
|
||
label: "Author",
|
||
picker: "workflow-author-picker",
|
||
search: "Search authors or paste a public key",
|
||
},
|
||
{
|
||
label: "Message",
|
||
picker: "workflow-message-picker",
|
||
search: "Search messages or paste a message ID",
|
||
},
|
||
]) {
|
||
const disclosure = dialog
|
||
.getByText(field.label, { exact: true })
|
||
.locator("..");
|
||
await disclosure.click();
|
||
const picker = dialog.getByTestId(field.picker);
|
||
const search = dialog.getByRole("combobox", { name: field.search });
|
||
await expect(picker).toBeVisible();
|
||
|
||
await search.press("Escape");
|
||
await expect(picker).toBeHidden();
|
||
await expect(disclosure).toBeFocused();
|
||
await expect(inspector).toBeVisible();
|
||
}
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(inspector).toBeHidden();
|
||
await expect(dialog).toBeVisible();
|
||
}
|
||
});
|
||
|
||
test("workflow grid batches card author and message presentation reads", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/");
|
||
await page.waitForFunction(
|
||
() => typeof window.__TAURI_INTERNALS__?.invoke === "function",
|
||
);
|
||
await page.evaluate(async () => {
|
||
const invoke = window.__TAURI_INTERNALS__?.invoke;
|
||
if (!invoke) throw new Error("mock invoke bridge unavailable");
|
||
for (let index = 0; index < 40; index += 1) {
|
||
const messageId = (index + 1).toString(16).padStart(64, "0");
|
||
const author = (index + 101).toString(16).padStart(64, "0");
|
||
await invoke("create_workflow", {
|
||
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||
yamlDefinition: JSON.stringify({
|
||
name: `batched_card_${index}`,
|
||
trigger: {
|
||
on: "reaction_added",
|
||
filter: `trigger_author == "${author}" && trigger_message_id == "${messageId}"`,
|
||
},
|
||
steps: [],
|
||
}),
|
||
});
|
||
}
|
||
|
||
const calls = { eventBatches: [], userBatches: [] };
|
||
window.__BUZZ_WORKFLOW_BATCH_CALLS__ = calls;
|
||
window.__TAURI_INTERNALS__.invoke = async (command, args) => {
|
||
if (command === "get_events") {
|
||
calls.eventBatches.push(
|
||
(args?.eventIds as unknown[] | undefined)?.length ?? 0,
|
||
);
|
||
}
|
||
if (command === "get_users_batch") {
|
||
calls.userBatches.push(
|
||
(args?.pubkeys as unknown[] | undefined)?.length ?? 0,
|
||
);
|
||
}
|
||
return invoke(command, args);
|
||
};
|
||
});
|
||
|
||
await page.getByTestId("open-workflows-view").click();
|
||
await expect(
|
||
page.locator('[data-testid^="workflow-card-mock-wf-"]'),
|
||
).toHaveCount(40);
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(() => {
|
||
const calls = window.__BUZZ_WORKFLOW_BATCH_CALLS__;
|
||
return {
|
||
eventBatchCount: calls?.eventBatches.filter((size) => size === 40)
|
||
.length,
|
||
userBatchCount: calls?.userBatches.filter((size) => size === 40)
|
||
.length,
|
||
};
|
||
}),
|
||
)
|
||
.toEqual({ eventBatchCount: 1, userBatchCount: 1 });
|
||
});
|
||
|
||
test("does not select stale picker results when Enter outruns deferred filtering", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/");
|
||
await page.waitForFunction(
|
||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||
);
|
||
await page.evaluate(() => {
|
||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||
channelName: "agents",
|
||
content: "Deferred message candidate",
|
||
id: "d".repeat(64),
|
||
});
|
||
});
|
||
await page.getByTestId("open-workflows-view").click();
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
const channelList = page.getByTestId("channel-combobox-list");
|
||
if (!(await channelList.isVisible())) {
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
}
|
||
await channelList
|
||
.getByRole("option", { name: "agents", exact: true })
|
||
.click();
|
||
await selectTrigger(page, dialog, "Reaction Added");
|
||
|
||
await dialog.getByText("Author", { exact: true }).locator("..").click();
|
||
const authorSearch = dialog.getByRole("combobox", {
|
||
name: "Search authors or paste a public key",
|
||
});
|
||
await expect(dialog.getByRole("option").first()).toBeVisible();
|
||
await authorSearch.evaluate((input: HTMLInputElement) => {
|
||
input.value = "no-such-author";
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
input.dispatchEvent(
|
||
new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
|
||
);
|
||
});
|
||
|
||
await dialog.getByText("Message", { exact: true }).locator("..").click();
|
||
const messageSearch = dialog.getByRole("combobox", {
|
||
name: "Search messages or paste a message ID",
|
||
});
|
||
await expect(
|
||
dialog.getByRole("option", { name: /Deferred message/ }),
|
||
).toBeVisible();
|
||
await messageSearch.evaluate((input: HTMLInputElement) => {
|
||
input.value = "no-such-message";
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
input.dispatchEvent(
|
||
new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
|
||
);
|
||
});
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const definition = parseYaml(
|
||
await dialog.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
|
||
);
|
||
expect(definition.trigger.filter).toBeUndefined();
|
||
});
|
||
|
||
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.getByText("Author", { exact: true }).locator("..").click();
|
||
const authorResults = dialog.getByTestId("workflow-author-picker-results");
|
||
await expect
|
||
.poll(() =>
|
||
authorResults.evaluate((results) => {
|
||
const template = results.querySelector<HTMLElement>("[role=option]");
|
||
if (!template) return false;
|
||
for (let index = 0; index < 20; index += 1) {
|
||
const clone = template.cloneNode(true) as HTMLElement;
|
||
clone.dataset.scrollFixture = "true";
|
||
clone.removeAttribute("id");
|
||
results.append(clone);
|
||
}
|
||
return results.scrollHeight > results.clientHeight;
|
||
}),
|
||
)
|
||
.toBe(true);
|
||
await authorResults.hover();
|
||
await page.mouse.wheel(0, 240);
|
||
await expect
|
||
.poll(() => authorResults.evaluate((node) => node.scrollTop))
|
||
.toBeGreaterThan(0);
|
||
await authorResults.evaluate((results) => {
|
||
for (const fixture of results.querySelectorAll("[data-scroll-fixture]")) {
|
||
fixture.remove();
|
||
}
|
||
results.scrollTop = 0;
|
||
});
|
||
const authorSearch = dialog.getByRole("combobox", {
|
||
name: "Search authors or paste a public key",
|
||
});
|
||
await authorSearch.fill(author);
|
||
await expect(
|
||
dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }),
|
||
).toBeVisible();
|
||
await authorSearch.press("Enter");
|
||
await expect(
|
||
dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }),
|
||
).toHaveAttribute("aria-selected", "true");
|
||
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
|
||
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.getByText("Author", { exact: true }).locator("..").click();
|
||
const correctedAuthorSearch = dialog.getByRole("combobox", {
|
||
name: "Search authors or paste a public key",
|
||
});
|
||
await correctedAuthorSearch.fill(author);
|
||
await expect(
|
||
dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }),
|
||
).toBeVisible();
|
||
await correctedAuthorSearch.press("Enter");
|
||
await dialog
|
||
.getByRole("group", { name: "Match" })
|
||
.getByRole("button", { name: "is not", exact: true })
|
||
.click();
|
||
|
||
await dialog.getByText("Message", { exact: true }).locator("..").click();
|
||
const messageSearch = dialog.getByRole("combobox", {
|
||
name: "Search messages or paste a message ID",
|
||
});
|
||
await messageSearch.fill(messageId);
|
||
await messageSearch.press("Enter");
|
||
await expect(
|
||
dialog.getByRole("option", { name: new RegExp(messageId.slice(0, 12)) }),
|
||
).toHaveAttribute("aria-selected", "true");
|
||
|
||
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.getByText("Author", { exact: true }).locator("..").click();
|
||
await expect(
|
||
reopened.getByRole("option", { name: new RegExp(author.slice(0, 8)) }),
|
||
).toHaveAttribute("aria-selected", "true");
|
||
await reopened.getByText("Message", { exact: true }).locator("..").click();
|
||
await expect(
|
||
reopened.getByRole("option", {
|
||
name: new RegExp(messageId.slice(0, 12)),
|
||
}),
|
||
).toHaveAttribute("aria-selected", "true");
|
||
await reopened.getByRole("tab", { name: "YAML" }).click();
|
||
definition = parseYaml(
|
||
await reopened.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
|
||
);
|
||
expect(definition.trigger.filter).toContain(messageId);
|
||
});
|
||
|
||
test("toggles selected author and message filters while preserving sibling conditions", async ({
|
||
page,
|
||
}) => {
|
||
const author = "a".repeat(64);
|
||
const replacementAuthor = "c".repeat(64);
|
||
const messageId = "b".repeat(64);
|
||
const dialog = await openCreateWorkflow(
|
||
page,
|
||
`toggle_trigger_ids_${Date.now()}`,
|
||
);
|
||
await selectTrigger(page, dialog, "Reaction Added");
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||
const definition = parseYaml(await yamlEditor.inputValue());
|
||
definition.trigger.filter =
|
||
`trigger_emoji == "👍" && trigger_author == "${author.toUpperCase()}" && ` +
|
||
`trigger_message_id == "${messageId.toUpperCase()}"`;
|
||
await yamlEditor.fill(stringifyYaml(definition));
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await openTriggerInspector(dialog);
|
||
const authorField = dialog
|
||
.getByText("Author", { exact: true })
|
||
.locator("..")
|
||
.locator("..");
|
||
await authorField.getByText("Author", { exact: true }).locator("..").click();
|
||
const authorOption = dialog.getByRole("option", {
|
||
name: new RegExp(author.slice(0, 8)),
|
||
});
|
||
await expect(authorOption).toHaveAttribute("aria-selected", "true");
|
||
await expect(
|
||
authorField.getByRole("button", { name: "Clear filter" }),
|
||
).toHaveCount(0);
|
||
await authorOption.click();
|
||
|
||
const authorSearch = dialog.getByRole("combobox", {
|
||
name: "Search authors or paste a public key",
|
||
});
|
||
await authorSearch.fill(replacementAuthor);
|
||
const replacementAuthorOption = dialog.getByRole("option", {
|
||
name: new RegExp(replacementAuthor.slice(0, 8)),
|
||
});
|
||
await expect(replacementAuthorOption).toBeVisible();
|
||
await authorSearch.press("Enter");
|
||
await expect(replacementAuthorOption).toHaveAttribute(
|
||
"aria-selected",
|
||
"true",
|
||
);
|
||
await expect(authorSearch).toHaveValue(replacementAuthor);
|
||
await authorSearch.press("Enter");
|
||
await expect(authorSearch).toHaveValue(replacementAuthor);
|
||
|
||
const messageField = dialog
|
||
.getByText("Message", { exact: true })
|
||
.locator("..")
|
||
.locator("..");
|
||
await messageField
|
||
.getByText("Message", { exact: true })
|
||
.locator("..")
|
||
.click();
|
||
const messageSearch = dialog.getByRole("combobox", {
|
||
name: "Search messages or paste a message ID",
|
||
});
|
||
await expect(
|
||
dialog.getByRole("option", { name: new RegExp(messageId.slice(0, 12)) }),
|
||
).toHaveAttribute("aria-selected", "true");
|
||
await expect(
|
||
messageField.getByRole("button", { name: "Clear filter" }),
|
||
).toHaveCount(0);
|
||
await messageSearch.fill(messageId);
|
||
await messageSearch.press("Enter");
|
||
await expect(messageSearch).toHaveValue(messageId);
|
||
await messageSearch.press("Enter");
|
||
const selectedMessage = dialog.getByRole("option", {
|
||
name: new RegExp(messageId.slice(0, 12)),
|
||
});
|
||
await expect(selectedMessage).toHaveAttribute("aria-selected", "true");
|
||
await selectedMessage.click();
|
||
await expect(messageSearch).toHaveValue(messageId);
|
||
|
||
await dialog.getByRole("button", { name: "Reaction emoji" }).click();
|
||
await expect(
|
||
dialog.getByRole("button", { name: "Clear filter" }),
|
||
).toBeVisible();
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
|
||
'trigger_emoji == "👍"',
|
||
);
|
||
});
|
||
|
||
test("hides and clears message-text step conditions for schedule triggers", async ({
|
||
page,
|
||
}) => {
|
||
const dialog = await openCreateWorkflow(
|
||
page,
|
||
`schedule_step_condition_${Date.now()}`,
|
||
);
|
||
await addMessageStep(page, dialog);
|
||
await dialog.getByRole("button", { name: "Run controls" }).click();
|
||
await dialog.getByLabel("Text to match").fill("deploy");
|
||
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
await selectTrigger(page, dialog, "Schedule");
|
||
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);
|
||
await expect(
|
||
dialog.getByRole("textbox", { name: "Timeout", exact: true }),
|
||
).toBeVisible();
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const definition = parseYaml(
|
||
await dialog.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
|
||
);
|
||
expect(definition.steps[0].if).toBeUndefined();
|
||
});
|
||
|
||
test("keeps advanced and malformed definitions lossless", async ({ page }) => {
|
||
const advanced =
|
||
'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
|
||
const dialog = await openCreateWorkflow(page, `lossless_${Date.now()}`);
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||
const initialYaml = await yamlEditor.inputValue();
|
||
await yamlEditor.fill(
|
||
initialYaml.replace(
|
||
"trigger:\n on: message_posted",
|
||
`trigger:\n on: message_posted\n filter: '${advanced}'`,
|
||
),
|
||
);
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await openTriggerInspector(dialog);
|
||
await expect(dialog.getByRole("tab", { name: "Advanced" })).toHaveAttribute(
|
||
"data-state",
|
||
"active",
|
||
);
|
||
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(advanced);
|
||
await dialog.getByRole("tab", { name: "Basic" }).click();
|
||
await expect(
|
||
dialog.getByText(/advanced expression is active/i),
|
||
).toBeVisible();
|
||
|
||
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,
|
||
);
|
||
|
||
const malformedYaml = (await yamlEditor.inputValue()).replace(
|
||
/trigger:\n {2}on: message_posted\n {2}filter:.*\n/,
|
||
'trigger:\n on: schedule\n cron: "0 9 * * *"\n interval: 1h\n',
|
||
);
|
||
await yamlEditor.fill(malformedYaml);
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await expect(
|
||
dialog.getByText(/cannot specify both cron and interval/i),
|
||
).toBeVisible();
|
||
await expect(yamlEditor).toHaveValue(malformedYaml);
|
||
});
|