mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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>
1082 lines
40 KiB
TypeScript
1082 lines
40 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import { parse as parseYaml } from "yaml";
|
||
|
||
import { installMockBridge } from "../helpers/bridge";
|
||
import { waitForAnimations } from "../helpers/animations";
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await installMockBridge(page);
|
||
});
|
||
|
||
async function navigateToWorkflows(page: import("@playwright/test").Page) {
|
||
await page.goto("/");
|
||
await page.getByTestId("open-workflows-view").click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||
}
|
||
|
||
async function selectWorkflowChannel(
|
||
page: import("@playwright/test").Page,
|
||
dialog: import("@playwright/test").Locator,
|
||
) {
|
||
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();
|
||
}
|
||
|
||
async function editWorkflowName(
|
||
dialog: import("@playwright/test").Locator,
|
||
name: string,
|
||
) {
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
const input = dialog.getByRole("textbox", { name: "Workflow name" });
|
||
await input.fill(name);
|
||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||
await expect(input).not.toBeVisible();
|
||
await expect(dialog).toContainText(name);
|
||
await expect(
|
||
dialog.page().getByTestId("channel-combobox-list"),
|
||
).not.toBeVisible();
|
||
}
|
||
|
||
async function createWorkflow(
|
||
page: import("@playwright/test").Page,
|
||
name: string,
|
||
options?: {
|
||
description?: string;
|
||
enabled?: boolean;
|
||
trigger?: string;
|
||
stepConditionText?: string;
|
||
stepName?: string;
|
||
stepTimeoutSecs?: string;
|
||
},
|
||
) {
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await expect(dialog).toBeVisible();
|
||
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, name);
|
||
if (options?.description) {
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||
const yaml = await yamlEditor.inputValue();
|
||
await yamlEditor.fill(
|
||
yaml.replace(
|
||
`name: ${name}`,
|
||
`name: ${name}\ndescription: ${JSON.stringify(options.description)}`,
|
||
),
|
||
);
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
}
|
||
if (options?.trigger) {
|
||
await dialog
|
||
.getByRole("button", { name: "Trigger: Message Posted" })
|
||
.click();
|
||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||
await page
|
||
.getByRole("menuitem", {
|
||
name:
|
||
options.trigger === "diff_posted"
|
||
? "Diff Posted"
|
||
: options.trigger === "reaction_added"
|
||
? "Reaction Added"
|
||
: options.trigger === "webhook"
|
||
? "Webhook"
|
||
: "Message Posted",
|
||
})
|
||
.click();
|
||
}
|
||
|
||
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");
|
||
if (options?.stepName) {
|
||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||
await dialog.getByLabel("Name (optional)").fill(options.stepName);
|
||
}
|
||
if (options?.stepConditionText || options?.stepTimeoutSecs) {
|
||
await dialog.getByRole("button", { name: "Run controls" }).click();
|
||
}
|
||
if (options?.stepConditionText) {
|
||
await dialog.getByLabel("Text to match").fill(options.stepConditionText);
|
||
}
|
||
if (options?.stepTimeoutSecs) {
|
||
await dialog
|
||
.getByRole("textbox", { name: "Timeout", exact: true })
|
||
.fill(options.stepTimeoutSecs);
|
||
}
|
||
|
||
await dialog.getByRole("button", { name: "Create" }).click();
|
||
if (!options?.trigger || options.trigger === "message_posted") {
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await activationConfirmation
|
||
.getByRole("button", {
|
||
name: options?.enabled === false ? "Keep off" : "Turn on",
|
||
})
|
||
.click();
|
||
}
|
||
|
||
await expect(
|
||
page.getByRole("heading", { name: "Create Workflow" }),
|
||
).not.toBeVisible();
|
||
}
|
||
|
||
test("navigates to workflows view and shows the empty create tile", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||
});
|
||
|
||
test("creates a narrowly triggered workflow without an activation warning", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, `safe_webhook_${Date.now()}`, {
|
||
trigger: "webhook",
|
||
});
|
||
|
||
await expect(
|
||
page.getByTestId("workflow-activation-confirmation"),
|
||
).toHaveCount(0);
|
||
const yaml = await page.evaluate(() => {
|
||
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||
.reverse()
|
||
.find((candidate) => candidate.command === "create_workflow");
|
||
return (call?.payload as { yamlDefinition?: string } | undefined)
|
||
?.yamlDefinition;
|
||
});
|
||
expect(parseYaml(yaml ?? "").enabled).not.toBe(false);
|
||
});
|
||
|
||
test("creation reveals the trigger pane only after a one-shot channel pick", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
const channelList = page.getByTestId("channel-combobox-list");
|
||
await expect(channelList).toBeVisible();
|
||
await expect(dialog.getByTestId("workflow-node-inspector")).toHaveCount(0);
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(channelList).not.toBeVisible();
|
||
await expect
|
||
.poll(() => dialog.getByText("Untitled workflow", { exact: true }).count())
|
||
.toBe(0);
|
||
await expect(channelList).not.toBeVisible();
|
||
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
await channelList
|
||
.getByRole("option", { name: "agents", exact: true })
|
||
.click();
|
||
await expect(dialog.getByTestId("workflow-node-inspector")).toBeVisible();
|
||
await expect(
|
||
dialog.getByRole("button", { name: "Trigger event" }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("Escape closes the selected inspector before the workflow modal", 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 expect(dialog).toBeVisible();
|
||
await expect(inspector).toBeVisible();
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(inspector).not.toBeVisible();
|
||
await expect(dialog).toBeVisible();
|
||
await expect(page).toHaveURL(/view=create/);
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(dialog).not.toBeVisible();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
}
|
||
});
|
||
|
||
test("creates a workflow via the form builder", async ({ page }) => {
|
||
const workflowName = `test_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
// Verify workflow appears in the list
|
||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||
});
|
||
|
||
test("accepts the generated name through the first form mutation", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
const generatedName = (
|
||
await dialog
|
||
.getByRole("button", { name: "Edit workflow name" })
|
||
.evaluate((button) => button.previousElementSibling?.textContent ?? "")
|
||
).trim();
|
||
expect(generatedName).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
|
||
|
||
await dialog.getByRole("button", { name: "Add first step" }).click();
|
||
await dialog.getByRole("button", { name: "Create workflow" }).click();
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await activationConfirmation.getByRole("button", { name: "Turn on" }).click();
|
||
|
||
await expect(page.getByTestId("workflows-view")).toContainText(generatedName);
|
||
});
|
||
|
||
test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
|
||
await selectWorkflowChannel(page, dialog);
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
await expect(
|
||
dialog.getByRole("textbox", { name: "Workflow name" }),
|
||
).toHaveAttribute("autocapitalize", "off");
|
||
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||
await expect(dialog.getByLabel("Name (optional)")).toHaveAttribute(
|
||
"autocapitalize",
|
||
"off",
|
||
);
|
||
});
|
||
|
||
test("shows executable guidance for diff trigger conditions", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||
await page
|
||
.getByRole("menuitem", { name: "Diff Posted", exact: true })
|
||
.click();
|
||
|
||
await expect(dialog.getByLabel("Diff text")).toHaveAttribute(
|
||
"placeholder",
|
||
"e.g. deploy",
|
||
);
|
||
});
|
||
|
||
test("captures workflow library across responsive viewports", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, "Notify reviewers when source files change", {
|
||
description: "Watches diff events for src/ changes",
|
||
enabled: false,
|
||
trigger: "diff_posted",
|
||
});
|
||
await createWorkflow(page, "Post the daily standup reminder to the team", {
|
||
description: "Keeps the team aligned every morning",
|
||
trigger: "message_posted",
|
||
});
|
||
await createWorkflow(
|
||
page,
|
||
"Request approval before deploying to production",
|
||
{
|
||
description: "Requires a final review before release",
|
||
trigger: "reaction_added",
|
||
},
|
||
);
|
||
|
||
for (const viewport of [
|
||
{ width: 800, height: 720, name: "narrow" },
|
||
{ width: 1024, height: 720, name: "medium" },
|
||
{ width: 1280, height: 720, name: "wide" },
|
||
]) {
|
||
await page.setViewportSize(viewport);
|
||
await page.screenshot({
|
||
animations: "disabled",
|
||
path: `test-results/workflow-library-${viewport.name}.png`,
|
||
});
|
||
}
|
||
|
||
await page.setViewportSize({ width: 1280, height: 720 });
|
||
const firstCard = page.locator('[data-testid^="workflow-card-"]').first();
|
||
await firstCard.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.screenshot({
|
||
animations: "disabled",
|
||
path: "test-results/workflow-library-wide-actions.png",
|
||
});
|
||
});
|
||
|
||
test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||
const workflowName = `diff_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName, {
|
||
enabled: false,
|
||
trigger: "diff_posted",
|
||
stepName: "Notify reviewers",
|
||
stepConditionText: "src/",
|
||
stepTimeoutSecs: "45s",
|
||
});
|
||
|
||
const card = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
await expect(card.locator("h3")).toHaveText(
|
||
"When a diff is posted, send “Workflow notification”",
|
||
);
|
||
await expect(card).toContainText(workflowName);
|
||
await expect(
|
||
card.getByRole("switch", { name: "Disable workflow" }),
|
||
).toBeChecked();
|
||
});
|
||
|
||
test("enables and disables a workflow from its card status toggle", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `toggle_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
const workflowCard = () =>
|
||
page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
|
||
const disable = workflowCard().getByRole("switch", {
|
||
name: "Disable workflow",
|
||
});
|
||
await expect(disable).toBeChecked();
|
||
await disable.click();
|
||
const enable = workflowCard().getByRole("switch", {
|
||
name: "Enable workflow",
|
||
});
|
||
await expect(enable).not.toBeChecked();
|
||
await enable.click();
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await expect(activationConfirmation).toBeVisible();
|
||
await activationConfirmation.getByRole("button", { name: "Turn on" }).click();
|
||
await expect(
|
||
workflowCard().getByRole("switch", { name: "Disable workflow" }),
|
||
).toBeChecked();
|
||
|
||
await workflowCard()
|
||
.getByRole("button", { name: "Workflow actions" })
|
||
.click();
|
||
await expect(
|
||
page.getByRole("menuitemcheckbox", { name: "Enable" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("rejects a stale card toggle without overwriting a newer edit", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `stale_toggle_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
const workflowCard = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
|
||
await page.evaluate(async (name) => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||
.reverse()
|
||
.find((call) => call.command === "create_workflow");
|
||
const channelId = (
|
||
createCall?.payload as { channelId?: string } | undefined
|
||
)?.channelId;
|
||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||
const workflows = (await invoke("get_channels_workflows", {
|
||
channelIds: [channelId],
|
||
})) as Array<{
|
||
id: string;
|
||
revision: string;
|
||
definition: Record<string, unknown>;
|
||
}>;
|
||
const workflow = workflows.find(
|
||
(candidate) => candidate.definition.name === name,
|
||
);
|
||
if (!workflow) throw new Error("created workflow unavailable");
|
||
await invoke("update_workflow", {
|
||
workflowId: workflow.id,
|
||
expectedRevision: workflow.revision,
|
||
yamlDefinition: `name: ${name} edited elsewhere\nenabled: true\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: post_message\n`,
|
||
});
|
||
}, workflowName);
|
||
|
||
await workflowCard.getByRole("switch", { name: "Disable workflow" }).click();
|
||
|
||
await expect(
|
||
page
|
||
.locator("[data-sonner-toast][data-removed='false']")
|
||
.filter({ hasText: "workflow changed since it was loaded" }),
|
||
).toBeVisible();
|
||
const authoritativeName = await page.evaluate(async () => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||
.reverse()
|
||
.find((call) => call.command === "create_workflow");
|
||
const channelId = (
|
||
createCall?.payload as { channelId?: string } | undefined
|
||
)?.channelId;
|
||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||
const workflows = (await invoke("get_channels_workflows", {
|
||
channelIds: [channelId],
|
||
})) as Array<{ name: string }>;
|
||
return workflows[0]?.name;
|
||
});
|
||
expect(authoritativeName).toBe(`${workflowName} edited elsewhere`);
|
||
});
|
||
|
||
test("reports a rejected workflow status change", async ({ page }) => {
|
||
const workflowName = `rejected_toggle_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.evaluate(() => {
|
||
window.__BUZZ_E2E__ ??= {};
|
||
window.__BUZZ_E2E__.mock ??= {};
|
||
window.__BUZZ_E2E__.mock.workflowUpdateError = "relay refused the update";
|
||
});
|
||
|
||
const workflowCard = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
await workflowCard.getByRole("switch", { name: "Disable workflow" }).click();
|
||
|
||
const errorToast = page
|
||
.locator("[data-sonner-toast][data-removed='false']")
|
||
.filter({ hasText: "Couldn’t change workflow status" });
|
||
await expect(errorToast).toContainText("relay refused the update");
|
||
await expect(
|
||
workflowCard.getByRole("switch", { name: "Disable workflow" }),
|
||
).toBeChecked();
|
||
});
|
||
|
||
test("shows the webhook secret dialog after saving a webhook workflow", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `webhook_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName, {
|
||
trigger: "webhook",
|
||
});
|
||
|
||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Copy URL" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Copy Secret" })).toBeVisible();
|
||
|
||
await expect(page.getByText(/•{24}/)).toBeVisible();
|
||
await page.goBack();
|
||
const confirmation = page.getByRole("alertdialog", {
|
||
name: "Continue without this secret?",
|
||
});
|
||
await expect(confirmation).toBeVisible();
|
||
await confirmation.getByRole("button", { name: "Go back" }).click();
|
||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||
|
||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||
await confirmation.getByRole("button", { name: "Continue" }).click();
|
||
await expect(page.getByText("Webhook ready")).not.toBeVisible();
|
||
});
|
||
|
||
test("edits an existing workflow", async ({ page }) => {
|
||
const originalName = `edit_test_${Date.now()}`;
|
||
const updatedName = `${originalName}_updated`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, originalName);
|
||
|
||
// Verify it exists
|
||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||
|
||
// Open the dropdown menu and click Edit
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||
|
||
// Dialog should open in edit mode
|
||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByText("Edit workflow")).toBeVisible();
|
||
|
||
// Change the name
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
const nameInput = dialog
|
||
.getByRole("textbox", { name: "Workflow name" })
|
||
.first();
|
||
await nameInput.clear();
|
||
await nameInput.fill(updatedName);
|
||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||
|
||
// Save
|
||
await page.getByRole("button", { name: "Save" }).click();
|
||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||
|
||
// Verify the updated name appears
|
||
await expect(page.getByTestId("workflows-view")).toContainText(updatedName);
|
||
});
|
||
|
||
test("duplicates a workflow", async ({ page }) => {
|
||
const originalName = `dup_test_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, originalName);
|
||
|
||
// Open the dropdown menu and click Duplicate
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Duplicate" }).click();
|
||
|
||
// Dialog should open in duplicate mode with "(copy)" suffix
|
||
await expect(page.getByRole("dialog")).toBeVisible();
|
||
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
|
||
|
||
// Submit the duplicate after deliberately choosing its destination channel.
|
||
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await dialog.getByRole("button", { name: "Create copy" }).click();
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await expect(activationConfirmation).toBeVisible();
|
||
await activationConfirmation.getByRole("button", { name: "Back" }).click();
|
||
await expect(dialog).toBeVisible();
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(
|
||
() =>
|
||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||
(call) => call.command === "create_workflow",
|
||
).length,
|
||
),
|
||
)
|
||
.toBe(1);
|
||
await dialog.getByRole("button", { name: "Create copy" }).click();
|
||
await activationConfirmation
|
||
.getByRole("button", { name: "Keep off" })
|
||
.click();
|
||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||
|
||
const copiedYaml = await page.evaluate(() => {
|
||
const calls = (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||
(call) => call.command === "create_workflow",
|
||
);
|
||
return (calls.at(-1)?.payload as { yamlDefinition?: string } | undefined)
|
||
?.yamlDefinition;
|
||
});
|
||
expect(parseYaml(copiedYaml ?? "").enabled).toBe(false);
|
||
|
||
// Both the original and copy should exist
|
||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||
});
|
||
|
||
test("deletes a workflow with confirmation", async ({ page }) => {
|
||
const workflowName = `delete_test_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
// Verify it exists
|
||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||
|
||
// Open the dropdown menu and click Delete
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||
|
||
// Confirmation dialog should appear with workflow name
|
||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
|
||
|
||
// Confirm deletion
|
||
await page.getByRole("button", { name: "Delete" }).click();
|
||
await expect(page.getByRole("alertdialog")).not.toBeVisible();
|
||
|
||
// Verify workflow is gone — back to the empty create tile.
|
||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||
});
|
||
|
||
test("deleting the open workflow closes its editor", async ({ page }) => {
|
||
const workflowName = `delete_open_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
|
||
await page.getByRole("button", { name: "Delete" }).click();
|
||
|
||
await expect(detailDialog).toHaveCount(0);
|
||
await expect(page).toHaveURL(/#\/workflows(?:\?|$)/);
|
||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||
});
|
||
|
||
test("rejected deletion keeps the confirmation, editor, and draft", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `delete_rejected_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||
const editor = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await editor.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = editor.getByRole("textbox", { name: "Workflow YAML" });
|
||
await yamlEditor.fill(
|
||
(await yamlEditor.inputValue()).replace(
|
||
workflowName,
|
||
`${workflowName}_draft`,
|
||
),
|
||
);
|
||
await page.evaluate(() => {
|
||
window.__BUZZ_E2E__ ??= {};
|
||
window.__BUZZ_E2E__.mock ??= {};
|
||
window.__BUZZ_E2E__.mock.workflowDeleteError = "relay refused deletion";
|
||
});
|
||
|
||
await editor.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||
const confirmation = page.getByRole("alertdialog", {
|
||
name: "Delete workflow?",
|
||
});
|
||
await confirmation.getByRole("button", { name: "Delete" }).click();
|
||
|
||
await expect(confirmation).toBeVisible();
|
||
await expect(confirmation.getByRole("alert")).toContainText(
|
||
"relay refused deletion",
|
||
);
|
||
await confirmation.getByRole("button", { name: "Cancel" }).click();
|
||
await expect(editor).toBeVisible();
|
||
await expect(yamlEditor).toContainText(`${workflowName}_draft`);
|
||
});
|
||
|
||
test("captures the built editor at desktop and narrow widths", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "editor_screenshot");
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
await dialog.getByLabel("Message text").fill("Notify the workflow channel");
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
|
||
for (const viewport of [
|
||
{ height: 900, name: "wide", width: 1440 },
|
||
{ height: 820, name: "narrow", width: 760 },
|
||
]) {
|
||
await page.setViewportSize(viewport);
|
||
await inspector.getByRole("button", { name: "Run controls" }).click();
|
||
await waitForAnimations(page);
|
||
await page.screenshot({
|
||
path: `test-results/workflow-editor-${viewport.name}-run-controls.png`,
|
||
});
|
||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||
await waitForAnimations(page);
|
||
await page.screenshot({
|
||
path: `test-results/workflow-editor-${viewport.name}-step-details.png`,
|
||
});
|
||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||
}
|
||
});
|
||
|
||
test("preserves final sequence affordances and responsive inspector behavior", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "sequence_parity_test");
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
const runControls = inspector.getByRole("button", { name: "Run controls" });
|
||
const stepDetails = inspector.getByRole("button", { name: "Step details" });
|
||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||
await expect(stepDetails).toHaveAttribute("aria-expanded", "false");
|
||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||
await expect(inspector.getByLabel("Name (optional)")).toHaveCount(0);
|
||
|
||
await runControls.click();
|
||
await expect(runControls).toHaveAttribute("aria-expanded", "true");
|
||
await inspector.getByLabel("Text to match").fill("deploy");
|
||
const timeoutField = inspector.getByRole("textbox", {
|
||
name: "Timeout",
|
||
exact: true,
|
||
});
|
||
const timeoutSlider = inspector.getByRole("slider", {
|
||
name: "Timeout slider",
|
||
});
|
||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "5m");
|
||
await timeoutField.fill("1h 2s");
|
||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "1h");
|
||
await timeoutField.fill("45s");
|
||
await expect(runControls).toContainText("Conditional · 45s");
|
||
|
||
await stepDetails.click();
|
||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||
await expect(stepDetails).toHaveAttribute("aria-expanded", "true");
|
||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||
await inspector.getByLabel("Name (optional)").fill("Notify deployers");
|
||
await expect(stepDetails).toContainText("Notify deployers");
|
||
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
|
||
|
||
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
|
||
const ingresses = dialog.getByTestId("workflow-node-ingress");
|
||
await expect(ingresses).toHaveCount(2);
|
||
await expect(
|
||
ingresses.first().locator("svg.lucide-arrow-down"),
|
||
).toBeVisible();
|
||
await expect(ingresses.last().locator("svg.lucide-arrow-down")).toHaveCount(
|
||
0,
|
||
);
|
||
await expect(
|
||
ingresses.last().getByRole("button", { name: "Add after Step 1" }),
|
||
).toBeVisible();
|
||
|
||
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
|
||
const removeStep = dialog.getByRole("button", { name: "Remove Step 1" });
|
||
await expect(removeStep).toHaveClass(/-right-8/);
|
||
await stepNode.hover();
|
||
await expect(removeStep).toBeVisible();
|
||
|
||
await page.setViewportSize({ width: 760, height: 820 });
|
||
await expect(
|
||
dialog.getByTestId("workflow-node-inspector-backdrop"),
|
||
).toBeVisible();
|
||
await dialog.getByTestId("workflow-node-inspector-backdrop").click({
|
||
position: { x: 20, y: 20 },
|
||
});
|
||
await expect(dialog.getByTestId("workflow-node-inspector")).not.toBeVisible();
|
||
});
|
||
|
||
test("pane routes use stable IDs and Form/YAML changes stay synchronized", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "pane_sync_test");
|
||
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
await dialog.getByLabel("Message text").fill("first message");
|
||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||
|
||
await dialog.getByRole("button", { name: "Add after Step 1" }).click();
|
||
await page.getByRole("menuitem", { name: "Delay" }).click();
|
||
await dialog.getByLabel("Duration").fill("5m");
|
||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||
|
||
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
|
||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||
await dialog
|
||
.getByTestId("workflow-node-inspector")
|
||
.getByRole("button", { name: "Remove step", exact: true })
|
||
.click();
|
||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||
await expect(yamlEditor).toContainText("id: step_2");
|
||
await expect(yamlEditor).not.toContainText("id: step_1");
|
||
const yaml = await yamlEditor.inputValue();
|
||
await yamlEditor.fill(yaml.replace("duration: 5m", "duration: 10m"));
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
|
||
await expect(dialog.getByLabel("Duration")).toHaveValue("10m");
|
||
});
|
||
|
||
test("clean editor close respects in-app versus direct-entry provenance", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
await expect(page).toHaveURL(/view=create/);
|
||
await page
|
||
.getByRole("dialog", { name: "Create workflow" })
|
||
.getByRole("button", {
|
||
name: "Close",
|
||
exact: true,
|
||
})
|
||
.click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
|
||
await page.goto("/#/workflows?view=create");
|
||
await page
|
||
.getByRole("dialog", { name: "Create workflow" })
|
||
.getByRole("button", {
|
||
name: "Close",
|
||
exact: true,
|
||
})
|
||
.click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
await page.goBack();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
});
|
||
|
||
test("direct workflow routes survive refresh and invalid view opens detail", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `route_test_${Date.now()}`;
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?.*view=edit/);
|
||
const workflowId = new URL(page.url()).hash.match(/workflows\/([^?]+)/)?.[1];
|
||
expect(workflowId).toBeTruthy();
|
||
await page.goto(`/#/workflows/${workflowId}?view=invalid`);
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await expect(detailDialog).toContainText(workflowName);
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Run history" }),
|
||
).toHaveCount(0);
|
||
|
||
await page.goto("/#/workflows?view=create");
|
||
await page.reload();
|
||
await expect(
|
||
page.getByRole("dialog", { name: "Create workflow" }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("dirty create close is blocked and keep editing preserves the draft", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "preserved_dirty_draft");
|
||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||
|
||
const confirmation = page.getByRole("alertdialog", {
|
||
name: "Discard changes?",
|
||
});
|
||
await expect(confirmation).toBeVisible();
|
||
await confirmation.getByRole("button", { name: "Keep editing" }).click();
|
||
await expect(dialog).toContainText("preserved_dirty_draft");
|
||
});
|
||
|
||
test("stale editor save preserves the local draft and reports the conflict", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `stale_editor_${Date.now()}`;
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
const nameInput = dialog
|
||
.getByRole("textbox", { name: "Workflow name" })
|
||
.first();
|
||
await nameInput.fill(`${workflowName}_local`);
|
||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||
|
||
await page.evaluate(async () => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const workflowId = new URL(window.location.href).hash.match(
|
||
/workflows\/([^?]+)/,
|
||
)?.[1];
|
||
if (!workflowId) throw new Error("workflow id unavailable");
|
||
const workflow = (await invoke("get_workflow", { workflowId })) as {
|
||
revision: string;
|
||
};
|
||
await invoke("update_workflow", {
|
||
expectedRevision: workflow.revision,
|
||
workflowId,
|
||
yamlDefinition:
|
||
"name: authoritative_remote\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: send_message\n text: remote\n",
|
||
});
|
||
});
|
||
|
||
await dialog.getByRole("button", { name: "Save changes" }).click();
|
||
await expect(dialog).toContainText("workflow changed since it was loaded");
|
||
await expect(dialog).toContainText(`${workflowName}_local`);
|
||
await expect(dialog).toBeVisible();
|
||
});
|
||
|
||
test("unsupported workflow opens canonical YAML without a fabricated sequence", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `unsupported_detail_${Date.now()}`;
|
||
await navigateToWorkflows(page);
|
||
const workflowId = await page.evaluate(async (name) => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const workflow = (await invoke("create_workflow", {
|
||
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||
yamlDefinition: `name: ${name}\ntrigger:\n on: message_posted\n legacy_filter:\n author: alice\nsteps:\n - id: legacy_step\n action: send_message\n text: preserve me\n`,
|
||
})) as { id: string };
|
||
return workflow.id;
|
||
}, workflowName);
|
||
|
||
await page.goto(`/#/workflows/${workflowId}`);
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await expect(detailDialog).toContainText(workflowName);
|
||
await expect(detailDialog.getByRole("tab", { name: "YAML" })).toHaveAttribute(
|
||
"aria-selected",
|
||
"true",
|
||
);
|
||
const yamlEditor = detailDialog.getByRole("textbox", {
|
||
name: "Workflow YAML",
|
||
});
|
||
await expect(yamlEditor).toContainText("legacy_filter:");
|
||
await expect(yamlEditor).toContainText("author: alice");
|
||
await expect(yamlEditor).toContainText("id: legacy_step");
|
||
await expect(yamlEditor).toContainText("text: preserve me");
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Step 1: Send Message" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("card click opens the animated trigger inspector, triggers a run, and hides unsupported run history", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `card_editor_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
const inspectorEntryAnimation = page.evaluate(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
const timeoutId = window.setTimeout(() => {
|
||
observer.disconnect();
|
||
resolve(false);
|
||
}, 1_000);
|
||
const observer = new MutationObserver(() => {
|
||
const inspector = document.querySelector<HTMLElement>(
|
||
'[data-testid="workflow-node-inspector"]',
|
||
);
|
||
if (!inspector) return;
|
||
|
||
observer.disconnect();
|
||
window.clearTimeout(timeoutId);
|
||
const initialRect = inspector.getBoundingClientRect();
|
||
const initialStyles = getComputedStyle(inspector);
|
||
const initial = {
|
||
opacity: initialStyles.opacity,
|
||
transform: initialStyles.transform,
|
||
width: initialRect.width,
|
||
};
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
const nextRect = inspector.getBoundingClientRect();
|
||
const nextStyles = getComputedStyle(inspector);
|
||
resolve(
|
||
nextRect.width !== initial.width ||
|
||
nextStyles.opacity !== initial.opacity ||
|
||
nextStyles.transform !== initial.transform,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
}),
|
||
);
|
||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||
|
||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?pane=trigger$/);
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await expect(detailDialog).toContainText(workflowName);
|
||
await expect(inspectorEntryAnimation).resolves.toBe(true);
|
||
await expect(page.getByTestId("workflow-node-inspector")).toBeVisible();
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||
).toHaveAttribute("aria-pressed", "true");
|
||
const triggerCallsBefore = await page.evaluate(
|
||
() =>
|
||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||
(call) => call.command === "trigger_workflow",
|
||
).length ?? 0,
|
||
);
|
||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Trigger" }).click();
|
||
await expect
|
||
.poll(async () =>
|
||
page.evaluate(
|
||
() =>
|
||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||
(call) => call.command === "trigger_workflow",
|
||
).length ?? 0,
|
||
),
|
||
)
|
||
.toBe(triggerCallsBefore + 1);
|
||
await expect(page.getByTestId("workflow-detail-panel")).toHaveCount(0);
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Run history" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("missing workflow routes show an unavailable modal with close and retry", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.goto("/#/workflows/missing-workflow?view=edit");
|
||
|
||
const unavailable = page.getByRole("dialog", {
|
||
name: "Workflow unavailable",
|
||
});
|
||
await expect(unavailable).toBeVisible();
|
||
await expect(
|
||
unavailable.getByRole("button", { name: "Retry" }),
|
||
).toBeVisible();
|
||
await unavailable.getByRole("button", { name: "Close" }).click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
});
|