mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
1074 lines
39 KiB
TypeScript
1074 lines
39 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
|
||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||
|
||
const TRIGGER_OPTION_LABELS: Record<string, string> = {
|
||
diff_posted: "Diff posted",
|
||
message_posted: "Message posted",
|
||
reaction_added: "Reaction added",
|
||
schedule: "Schedule",
|
||
webhook: "Webhook",
|
||
};
|
||
|
||
const WORKFLOW_AUTHOR_DIRECTORY = Array.from({ length: 60 }, (_, index) => {
|
||
const memberNumber = index + 1;
|
||
return {
|
||
pubkey: (10_000 + memberNumber).toString(16).padStart(64, "0"),
|
||
displayName: `Workflow member ${memberNumber.toString().padStart(2, "0")}`,
|
||
};
|
||
});
|
||
const PROFILELESS_WORKFLOW_MEMBER = "abcdef12".padEnd(64, "3");
|
||
|
||
test.beforeEach(async ({ page }, testInfo) => {
|
||
const needsLargeAuthorDirectory =
|
||
testInfo.title ===
|
||
"chooses a trigger author condition from live user search";
|
||
await installMockBridge(
|
||
page,
|
||
needsLargeAuthorDirectory
|
||
? {
|
||
additionalRelayMembers: WORKFLOW_AUTHOR_DIRECTORY.map(
|
||
({ pubkey }) => ({ pubkey }),
|
||
).concat({ pubkey: PROFILELESS_WORKFLOW_MEMBER }),
|
||
searchProfiles: WORKFLOW_AUTHOR_DIRECTORY,
|
||
}
|
||
: undefined,
|
||
);
|
||
});
|
||
|
||
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 selectFirstChannel(dialog: import("@playwright/test").Locator) {
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
await dialog
|
||
.getByTestId("channel-combobox-list")
|
||
.getByRole("button")
|
||
.first()
|
||
.click();
|
||
}
|
||
|
||
async function expectUnsupportedActionWarnings(
|
||
menu: import("@playwright/test").Locator,
|
||
) {
|
||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||
"Delay",
|
||
"Send message",
|
||
"Call webhook",
|
||
"Send DM",
|
||
"Request approval",
|
||
"Add reaction",
|
||
"Set channel topic",
|
||
]);
|
||
|
||
for (const label of [
|
||
"Send DM",
|
||
"Request approval",
|
||
"Add reaction",
|
||
"Set channel topic",
|
||
]) {
|
||
await expect(
|
||
menu
|
||
.getByRole("menuitem", { name: label })
|
||
.getByRole("img", { name: "Not supported yet" }),
|
||
).toBeVisible();
|
||
}
|
||
|
||
for (const label of ["Delay", "Send message", "Call webhook"]) {
|
||
await expect(
|
||
menu
|
||
.getByRole("menuitem", { name: label, exact: true })
|
||
.getByRole("img", { name: "Not supported yet" }),
|
||
).toHaveCount(0);
|
||
}
|
||
}
|
||
|
||
async function createWorkflow(
|
||
page: import("@playwright/test").Page,
|
||
name: string,
|
||
options?: {
|
||
description?: string;
|
||
enabled?: boolean;
|
||
trigger?: string;
|
||
triggerCondition?: string;
|
||
stepName?: string;
|
||
stepTimeoutSecs?: string;
|
||
},
|
||
) {
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByText("Choose a channel")).toBeVisible();
|
||
await expect(
|
||
dialog.getByRole("button", { name: "Create workflow" }),
|
||
).toBeDisabled();
|
||
await selectFirstChannel(dialog);
|
||
|
||
await dialog.getByLabel("Workflow name").fill(name);
|
||
if (options?.description) {
|
||
await dialog.getByLabel("Description (optional)").fill(options.description);
|
||
}
|
||
if (options?.enabled === false) {
|
||
await dialog.getByLabel("Enable").click();
|
||
}
|
||
if (options?.trigger) {
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
await dialog.getByLabel("Trigger event").click();
|
||
await page
|
||
.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?.stepTimeoutSecs) {
|
||
await dialog.getByLabel("Timeout (seconds)").fill(options.stepTimeoutSecs);
|
||
}
|
||
|
||
await dialog.getByRole("button", { name: "Create workflow" }).click();
|
||
|
||
await expect(
|
||
page.getByRole("heading", { name: "Create workflow" }),
|
||
).not.toBeVisible();
|
||
}
|
||
|
||
test("navigates to workflows view and shows empty state", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await expect(page.getByText("No workflows yet")).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: "Create your first workflow" }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
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("disables autocapitalization in the workflow form", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
|
||
await expect(dialog.getByLabel("Workflow name")).toHaveAttribute(
|
||
"autocapitalize",
|
||
"off",
|
||
);
|
||
|
||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||
await page.getByRole("menuitem", { name: "Delay" }).click();
|
||
await expect(dialog.getByLabel("Name (optional)")).toHaveAttribute(
|
||
"autocapitalize",
|
||
"off",
|
||
);
|
||
});
|
||
|
||
test("configures common schedules and exposes custom cron", 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.getByLabel("Trigger event").click();
|
||
await page.getByRole("menuitem", { name: "Schedule" }).click();
|
||
|
||
const repeatOptions = inspector.getByRole("radio");
|
||
await expect(repeatOptions).toHaveCount(7);
|
||
await expect(repeatOptions.first()).toHaveAccessibleName("Every 15 minutes");
|
||
await expect(repeatOptions.last()).toHaveAccessibleName("Custom");
|
||
await expect(inspector.getByRole("radio", { name: "Daily" })).toBeChecked();
|
||
await expect(inspector.getByLabel("Run time (UTC)")).toHaveValue("09:00");
|
||
|
||
await inspector.getByText("Monthly", { exact: true }).click();
|
||
await inspector.getByLabel("Day of month").selectOption("31");
|
||
await expect(
|
||
inspector.getByText("This schedule won’t run in some months."),
|
||
).toBeVisible();
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/cron: 0 9 31 \* \*/,
|
||
);
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
|
||
await inspector.getByText("Weekly", { exact: true }).click();
|
||
const weekdayPicker = inspector.getByRole("group", { name: "Repeat on" });
|
||
await weekdayPicker.getByText("T", { exact: true }).nth(1).click();
|
||
await expect(
|
||
weekdayPicker.getByRole("checkbox", { name: "Monday" }),
|
||
).toBeChecked();
|
||
await expect(
|
||
weekdayPicker.getByRole("checkbox", { name: "Thursday" }),
|
||
).toBeChecked();
|
||
await inspector.getByLabel("Run time (UTC)").fill("14:30");
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/cron: 30 14 \* \* 1,4/,
|
||
);
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
await dialog.getByText("Custom", { exact: true }).click();
|
||
const cronInput = inspector.getByRole("group", { name: "Cron expression" });
|
||
const minuteField = cronInput.getByRole("textbox", { name: "Minute" });
|
||
await expect(cronInput).toBeVisible();
|
||
await expect(minuteField).toHaveValue("30");
|
||
await expect(cronInput.getByRole("textbox", { name: "Hour" })).toHaveValue(
|
||
"14",
|
||
);
|
||
await expect(cronInput.getByRole("textbox", { name: "Weekday" })).toHaveValue(
|
||
"1,4",
|
||
);
|
||
|
||
await minuteField.fill("60");
|
||
await expect(
|
||
inspector.getByText("Minute must be between 0 and 59."),
|
||
).toBeVisible();
|
||
|
||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||
await page.evaluate(() => navigator.clipboard.writeText("0 */2 * * 1,3,5"));
|
||
await minuteField.press("ControlOrMeta+V");
|
||
await expect(minuteField).toHaveValue("0");
|
||
await expect(cronInput.getByRole("textbox", { name: "Hour" })).toHaveValue(
|
||
"*/2",
|
||
);
|
||
await expect(cronInput.getByRole("textbox", { name: "Weekday" })).toHaveValue(
|
||
"1,3,5",
|
||
);
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/cron: 0 \*\/2 \* \* 1,3,5/,
|
||
);
|
||
});
|
||
|
||
test("builds a valid trigger condition from plain-language choices", 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");
|
||
|
||
const conditionFields = inspector.getByRole("group", { name: "Condition" });
|
||
const conditionOptions = conditionFields.getByRole("button");
|
||
await expect(conditionOptions).toHaveCount(4);
|
||
const allMessages = conditionFields.getByRole("button", {
|
||
name: "All messages",
|
||
});
|
||
await expect(allMessages).toHaveAttribute("aria-pressed", "true");
|
||
await allMessages.click();
|
||
await expect(allMessages).toHaveAttribute("aria-pressed", "true");
|
||
await expect(
|
||
conditionFields.getByRole("button", { name: "Message text" }),
|
||
).toHaveAttribute("aria-pressed", "false");
|
||
for (const name of ["Author", "Custom"]) {
|
||
await expect(conditionFields.getByRole("button", { name })).toBeVisible();
|
||
}
|
||
await expect(
|
||
conditionFields.getByRole("button", { name: "Channel ID" }),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
conditionFields.getByRole("button", { name: "Message", exact: true }),
|
||
).toHaveCount(0);
|
||
|
||
await conditionFields.getByRole("button", { name: "Message text" }).click();
|
||
await expect(allMessages).toHaveAttribute("aria-pressed", "false");
|
||
const matchSelect = inspector.getByLabel("Match");
|
||
await expect(matchSelect.locator('option[value="equals"]')).toHaveText("is");
|
||
await expect(matchSelect.locator('option[value="not_equals"]')).toHaveText(
|
||
"is not",
|
||
);
|
||
await expect(matchSelect.locator('option[value="is_not_empty"]')).toHaveText(
|
||
"has text",
|
||
);
|
||
await expect(matchSelect.locator('option[value="is_empty"]')).toHaveText(
|
||
"has no text",
|
||
);
|
||
await inspector.getByLabel("Text to match").fill('deploy "buzz"');
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/str_contains\(trigger_text, "deploy \\"buzz\\""\)/,
|
||
);
|
||
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
const roundTrippedFields = inspector.getByRole("group", {
|
||
name: "Condition",
|
||
});
|
||
await expect(
|
||
roundTrippedFields.getByRole("button", { name: "Message text" }),
|
||
).toHaveAttribute("aria-pressed", "true");
|
||
const customCondition = roundTrippedFields.getByRole("button", {
|
||
name: "Custom",
|
||
});
|
||
await customCondition.click();
|
||
await expect(inspector.getByLabel("Custom expression")).toHaveValue(
|
||
'str_contains(trigger_text, "deploy \\"buzz\\"")',
|
||
);
|
||
await expect(inspector.getByText(/Use an evalexpr expression/)).toBeVisible();
|
||
await customCondition.click();
|
||
await expect(customCondition).toHaveAttribute("aria-pressed", "true");
|
||
await expect(inspector.getByLabel("Custom expression")).toBeVisible();
|
||
await roundTrippedFields
|
||
.getByRole("button", { name: "All messages" })
|
||
.click();
|
||
await expect(customCondition).toHaveAttribute("aria-pressed", "false");
|
||
await expect(inspector.getByLabel("Custom expression")).not.toBeVisible();
|
||
});
|
||
|
||
test("keeps conditions at the trigger level", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
await inspector.getByRole("button", { name: "Message text" }).click();
|
||
await inspector.getByLabel("Text to match").fill("deploy");
|
||
|
||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||
await page.getByRole("menuitem", { name: "Send message" }).click();
|
||
|
||
await expect(inspector.getByLabel("Message text")).toBeVisible();
|
||
await expect(
|
||
inspector.getByRole("heading", { name: "Step condition" }),
|
||
).toHaveCount(0);
|
||
await expect(inspector.getByRole("group", { name: "Condition" })).toHaveCount(
|
||
0,
|
||
);
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/filter: str_contains\(trigger_text, "deploy"\)/,
|
||
);
|
||
await expect(dialog.getByLabel("Workflow YAML")).not.toHaveValue(/\n\s+if:/);
|
||
});
|
||
|
||
test("chooses a trigger author condition from live user search", 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: "Author" }).click();
|
||
const authorPicker = inspector.getByTestId("author-grid-picker");
|
||
await expect(authorPicker).toBeVisible();
|
||
await expect
|
||
.poll(() =>
|
||
authorPicker
|
||
.locator("fieldset")
|
||
.evaluate(
|
||
(element) =>
|
||
getComputedStyle(element)
|
||
.gridTemplateColumns.split(" ")
|
||
.filter(Boolean).length,
|
||
),
|
||
)
|
||
.toBe(3);
|
||
const authorList = authorPicker.getByTestId("author-grid-list");
|
||
const authorResults = page.getByTestId(/^workflow-author-result-/);
|
||
await expect.poll(() => authorResults.count()).toBeGreaterThanOrEqual(50);
|
||
await expect
|
||
.poll(() =>
|
||
authorResults.evaluateAll((elements) =>
|
||
elements
|
||
.slice(0, 3)
|
||
.map((element) =>
|
||
element
|
||
.getAttribute("data-testid")
|
||
?.replace("workflow-author-result-", ""),
|
||
),
|
||
),
|
||
)
|
||
.toEqual([
|
||
TEST_IDENTITIES.alice.pubkey,
|
||
TEST_IDENTITIES.bob.pubkey,
|
||
TEST_IDENTITIES.charlie.pubkey,
|
||
]);
|
||
await authorList.hover();
|
||
await page.mouse.wheel(0, 10_000);
|
||
await expect.poll(() => authorResults.count()).toBeGreaterThan(60);
|
||
|
||
await page
|
||
.getByPlaceholder("Search people or paste a public key...")
|
||
.fill("abcdef12");
|
||
await expect(
|
||
page.getByTestId(`workflow-author-result-${PROFILELESS_WORKFLOW_MEMBER}`),
|
||
).toBeVisible();
|
||
|
||
await page
|
||
.getByPlaceholder("Search people or paste a public key...")
|
||
.fill("charlie");
|
||
await expect(authorList.getByText("charlie", { exact: true })).toBeVisible();
|
||
|
||
await page
|
||
.getByPlaceholder("Search people or paste a public key...")
|
||
.fill("outsider");
|
||
await expect(
|
||
page.getByTestId(
|
||
`workflow-author-result-${TEST_IDENTITIES.outsider.pubkey}`,
|
||
),
|
||
).toBeVisible();
|
||
|
||
await page
|
||
.getByPlaceholder("Search people or paste a public key...")
|
||
.fill("Workflow");
|
||
await expect(authorResults).toHaveCount(50);
|
||
await page.getByRole("button", { name: "Load more authors" }).click();
|
||
await expect(authorResults).toHaveCount(60);
|
||
|
||
await page
|
||
.getByPlaceholder("Search people or paste a public key...")
|
||
.fill("Workflow member 59");
|
||
const selectedAuthor = page.getByTestId(
|
||
`workflow-author-result-${WORKFLOW_AUTHOR_DIRECTORY[58].pubkey}`,
|
||
);
|
||
await selectedAuthor.click();
|
||
await expect(selectedAuthor).toHaveAttribute("aria-pressed", "true");
|
||
const triggerNode = dialog.getByRole("button", {
|
||
name: "Trigger: Message posted by Workflow member 59",
|
||
});
|
||
await expect(triggerNode).toBeVisible();
|
||
await expect(
|
||
triggerNode.getByTestId("workflow-trigger-icon-message_posted"),
|
||
).toBeVisible();
|
||
await expect(
|
||
triggerNode.locator('[data-testid^="workflow-trigger-author-avatar-"]'),
|
||
).toBeVisible();
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/trigger_author\s+==\s+"[0-9a-f]{64}"/,
|
||
);
|
||
});
|
||
|
||
test("chooses and clears a reaction trigger with the app emoji picker", 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.getByLabel("Trigger event").click();
|
||
await page.getByRole("menuitem", { name: "Reaction added" }).click();
|
||
await inspector.getByRole("button", { name: "Reaction emoji" }).click();
|
||
|
||
const trigger = inspector.getByRole("button", {
|
||
name: "Choose condition emoji",
|
||
});
|
||
await expect(trigger).toContainText("Choose a reaction");
|
||
await trigger.click();
|
||
|
||
const picker = page.locator("em-emoji-picker");
|
||
await picker.locator("input[type='search']").fill("buzz");
|
||
await picker.getByRole("button", { name: ":buzz:" }).first().click();
|
||
|
||
await expect(trigger).toContainText(":buzz:");
|
||
const triggerNode = dialog.getByRole("button", {
|
||
name: "Trigger: Reaction added",
|
||
});
|
||
await expect(triggerNode).toBeVisible();
|
||
await expect(
|
||
triggerNode.getByTestId("workflow-trigger-selected-reaction"),
|
||
).toHaveAttribute("alt", ":buzz:");
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(/:buzz:/);
|
||
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
await dialog.getByRole("button", { name: "Clear condition emoji" }).click();
|
||
await expect(
|
||
dialog.getByRole("button", { name: "Choose condition emoji" }),
|
||
).toContainText("Choose a reaction");
|
||
await expect(
|
||
dialog
|
||
.getByRole("button", { name: "Trigger: Reaction added" })
|
||
.getByTestId("workflow-trigger-icon-reaction_added"),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("chooses an add-reaction step emoji with the app emoji picker", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||
await expectUnsupportedActionWarnings(page.getByRole("menu"));
|
||
await page.getByRole("menuitem", { name: "Add reaction" }).click();
|
||
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
await expect(inspector).toContainText(
|
||
"Backend note: `add_reaction` is not executed yet, so runs fail at this step.",
|
||
);
|
||
await inspector.getByRole("button", { name: "Action", exact: true }).click();
|
||
await expectUnsupportedActionWarnings(page.getByRole("menu"));
|
||
await page.keyboard.press("Escape");
|
||
const stepEmoji = inspector.getByRole("button", {
|
||
name: "Choose reaction emoji",
|
||
});
|
||
await expect(stepEmoji).toContainText("Choose a reaction");
|
||
await stepEmoji.click();
|
||
|
||
const picker = page.locator("em-emoji-picker");
|
||
await picker.locator("input[type='search']").fill("buzz");
|
||
await picker.getByRole("button", { name: ":buzz:" }).first().click();
|
||
|
||
await expect(stepEmoji).toContainText(":buzz:");
|
||
const reactionStepNode = dialog.getByRole("button", {
|
||
name: "Step 1: :buzz:",
|
||
});
|
||
await expect(reactionStepNode).toContainText("Add reaction");
|
||
await expect(
|
||
reactionStepNode.getByTestId("workflow-step-reaction-emoji"),
|
||
).toHaveAttribute("alt", ":buzz:");
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/action: add_reaction[\s\S]*emoji: ":buzz:"/,
|
||
);
|
||
});
|
||
|
||
test("chooses a reaction emoji condition with the app emoji picker", 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();
|
||
let inspector = dialog.getByTestId("workflow-node-inspector");
|
||
await inspector.getByLabel("Trigger event").click();
|
||
await page.getByRole("menuitem", { name: "Reaction added" }).click();
|
||
|
||
inspector = dialog.getByTestId("workflow-node-inspector");
|
||
await expect(
|
||
inspector.getByRole("button", { name: "Message", exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
inspector.getByRole("button", { name: "Channel ID" }),
|
||
).toHaveCount(0);
|
||
await inspector.getByRole("button", { name: "Reaction emoji" }).click();
|
||
|
||
const conditionEmoji = inspector.getByRole("button", {
|
||
name: "Choose condition emoji",
|
||
});
|
||
await conditionEmoji.click();
|
||
const picker = page.locator("em-emoji-picker");
|
||
await picker.locator("input[type='search']").fill("buzz");
|
||
await picker.getByRole("button", { name: ":buzz:" }).first().click();
|
||
|
||
await expect(conditionEmoji).toContainText(":buzz:");
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/filter: trigger_emoji == ":buzz:"/,
|
||
);
|
||
});
|
||
|
||
test("chooses a reacted-to message from the workflow channel", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
await dialog
|
||
.getByTestId("channel-combobox-list")
|
||
.getByRole("button", { name: /general/i })
|
||
.click();
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
await inspector.getByLabel("Trigger event").click();
|
||
await page.getByRole("menuitem", { name: "Reaction added" }).click();
|
||
const emojiField = inspector.getByRole("button", {
|
||
name: "Reaction emoji",
|
||
});
|
||
const authorField = inspector.getByRole("button", { name: "Author" });
|
||
const messageField = inspector.getByRole("button", {
|
||
name: /^Message(?:$| Selected message:| Excluded message:)/,
|
||
});
|
||
|
||
await emojiField.click();
|
||
await inspector
|
||
.getByRole("button", { name: "Choose condition emoji" })
|
||
.click();
|
||
const emojiPicker = page.locator("em-emoji-picker");
|
||
await emojiPicker.locator("input[type='search']").fill("buzz");
|
||
await emojiPicker.getByRole("button", { name: ":buzz:" }).first().click();
|
||
|
||
await authorField.click();
|
||
await page
|
||
.getByTestId(`workflow-author-result-${TEST_IDENTITIES.alice.pubkey}`)
|
||
.click();
|
||
|
||
await messageField.click();
|
||
|
||
const search = inspector.getByLabel("Search messages or paste a message ID");
|
||
const messageList = inspector.getByTestId("message-id-picker-list");
|
||
await expect(search).toBeVisible();
|
||
await expect(messageList).toHaveCSS("overflow-y", "auto");
|
||
await expect(
|
||
messageList.getByRole("button", { name: /Hey team — checking in\./ }),
|
||
).toBeVisible();
|
||
|
||
await search.fill("custom emoji");
|
||
const targetMessage = messageList.getByRole("button", {
|
||
name: /React to me with a custom emoji/,
|
||
});
|
||
await expect(targetMessage).toBeVisible();
|
||
await targetMessage.click();
|
||
|
||
const messageSummary = messageField.getByTestId("message-condition-summary");
|
||
await expect(messageSummary).toHaveText("“React to me with a custom emoji”");
|
||
const messageEditor = inspector.getByTestId(
|
||
"workflow-condition-editor-trigger_message_id",
|
||
);
|
||
await messageEditor.getByText("is not", { exact: true }).click();
|
||
await expect(messageSummary).toHaveCSS(
|
||
"text-decoration-line",
|
||
"line-through",
|
||
);
|
||
await messageEditor.getByText("is", { exact: true }).click();
|
||
await expect(messageSummary).toHaveCSS("text-decoration-line", "none");
|
||
|
||
for (const field of [emojiField, authorField, messageField]) {
|
||
await expect(field).toHaveAttribute("aria-pressed", "true");
|
||
}
|
||
await expect(
|
||
dialog.getByRole("button", {
|
||
name: "Trigger: Reaction added by alice to “React to me with a custom emoji”",
|
||
}),
|
||
).toBeVisible();
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const workflowYaml = dialog.getByLabel("Workflow YAML");
|
||
await expect(workflowYaml).toHaveValue(
|
||
/filter: trigger_emoji == ":buzz:" && trigger_author ==\s+"[0-9a-f]{64}" &&\s+trigger_message_id ==\s+"[0-9a-f]{64}"/,
|
||
);
|
||
await expect(workflowYaml).toHaveValue(
|
||
new RegExp(TEST_IDENTITIES.alice.pubkey),
|
||
);
|
||
});
|
||
|
||
test("switches an empty workflow between form and YAML modes", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toBeVisible();
|
||
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await expect(dialog.getByLabel("Workflow name")).toBeVisible();
|
||
});
|
||
|
||
test("confirms before discarding workflow changes", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await dialog.getByLabel("Workflow name").fill("unfinished_workflow");
|
||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||
|
||
const confirmation = page.getByRole("alertdialog");
|
||
await expect(confirmation).toContainText("Discard changes?");
|
||
await confirmation.getByRole("button", { name: "Keep editing" }).click();
|
||
await expect(confirmation).not.toBeVisible();
|
||
await expect(dialog.getByLabel("Workflow name")).toHaveValue(
|
||
"unfinished_workflow",
|
||
);
|
||
|
||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||
await confirmation.getByRole("button", { name: "Discard changes" }).click();
|
||
await expect(dialog).not.toBeVisible();
|
||
});
|
||
|
||
test("scrolls the channel list with the mouse wheel", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
|
||
const channelList = page.getByTestId("channel-combobox-list");
|
||
await expect(channelList).toBeVisible();
|
||
await channelList.hover();
|
||
await page.mouse.wheel(0, 500);
|
||
|
||
await expect
|
||
.poll(() => channelList.evaluate((element) => element.scrollTop))
|
||
.toBeGreaterThan(0);
|
||
});
|
||
|
||
test("omits destination controls for a channel workflow", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
await selectFirstChannel(dialog);
|
||
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
|
||
await dialog.getByLabel("Trigger event").click();
|
||
await page.getByRole("menuitem", { name: "Webhook" }).click();
|
||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||
await page.getByRole("menuitem", { name: "Send message" }).click();
|
||
|
||
await expect(
|
||
dialog.getByRole("combobox", { name: "Channel override (optional)" }),
|
||
).toHaveCount(0);
|
||
await expect(dialog.getByText("Posting channel")).toHaveCount(0);
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).not.toHaveValue(
|
||
/channel: [0-9a-f-]{36}/,
|
||
);
|
||
});
|
||
|
||
test("opens node configuration in a contextual inspector", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
|
||
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();
|
||
const triggerNode = dialog.getByRole("button", { name: /^Trigger:/ });
|
||
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
|
||
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
|
||
await expect(inspector.getByLabel("Action")).toHaveAttribute(
|
||
"data-value",
|
||
"send_message",
|
||
);
|
||
await expect(inspector.getByLabel("Message text")).toBeVisible();
|
||
await inspector.getByLabel("Message text").fill("hey yourself");
|
||
await expect(stepNode).toHaveAttribute(
|
||
"aria-label",
|
||
"Step 1: “hey yourself”",
|
||
);
|
||
await expect(stepNode).toContainText("Send message");
|
||
await expect(stepNode).toContainText("“hey yourself”");
|
||
await inspector.getByLabel("Name (optional)").fill("say hello");
|
||
await expect(stepNode).toHaveAttribute(
|
||
"aria-label",
|
||
"Step 1: say hello · “hey yourself”",
|
||
);
|
||
await expect(
|
||
inspector.getByRole("heading", { name: "Step condition" }),
|
||
).toHaveCount(0);
|
||
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(
|
||
await triggerNode.evaluate(
|
||
(element) => getComputedStyle(element).outlineColor,
|
||
),
|
||
).not.toBe(
|
||
await stepNode.evaluate(
|
||
(element) => getComputedStyle(element).outlineColor,
|
||
),
|
||
);
|
||
await expect
|
||
.poll(() =>
|
||
inspector.evaluate((element) => getComputedStyle(element).padding),
|
||
)
|
||
.toBe("16px");
|
||
|
||
await inspector.getByLabel("Action").click();
|
||
await page.getByRole("menuitem", { name: "Send DM" }).click();
|
||
await expect(inspector.getByLabel("Action")).toHaveAttribute(
|
||
"data-value",
|
||
"send_dm",
|
||
);
|
||
await expect(inspector.getByLabel("To (pubkey)")).toBeVisible();
|
||
|
||
await inspector.getByLabel("Name (optional)").fill("Notify deployer");
|
||
await expect(stepNode).toContainText("Notify deployer");
|
||
await expect(stepNode).toContainText("Send DM");
|
||
await expect(stepNode).toHaveAttribute("aria-pressed", "true");
|
||
|
||
await stepNode.hover();
|
||
const removeStep = dialog.getByRole("button", { name: "Remove Step 1" });
|
||
await expect(removeStep).toBeVisible();
|
||
await removeStep.click();
|
||
await expect(stepNode).not.toBeVisible();
|
||
await expect(inspector).toBeVisible();
|
||
await expect(inspector.getByLabel("Trigger event")).toBeVisible();
|
||
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
|
||
});
|
||
|
||
test("switches between the form and YAML editors", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog");
|
||
const nameInput = dialog.getByLabel("Workflow name");
|
||
|
||
await nameInput.fill("yaml_round_trip");
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
|
||
/name: yaml_round_trip/,
|
||
);
|
||
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await expect(nameInput).toHaveValue("yaml_round_trip");
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
await dialog.getByLabel("Workflow YAML").fill(`name: conditional_step
|
||
trigger:
|
||
on: message_posted
|
||
steps:
|
||
- id: notify
|
||
action: send_message
|
||
if: trigger_author == "abc"
|
||
text: hello
|
||
`);
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await expect(
|
||
dialog.getByText(
|
||
"Cannot switch to form view: Step conditions are only available in the YAML editor",
|
||
),
|
||
).toBeVisible();
|
||
await expect(dialog.getByLabel("Workflow YAML")).toBeVisible();
|
||
});
|
||
|
||
test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||
const workflowName = `diff_workflow_${Date.now()}`;
|
||
const description = "Watches diff events for src/ changes";
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName, {
|
||
description,
|
||
enabled: false,
|
||
trigger: "diff_posted",
|
||
stepName: "Notify reviewers",
|
||
triggerCondition: 'str_contains(trigger_text, "src/")',
|
||
stepTimeoutSecs: "45",
|
||
});
|
||
|
||
const card = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
await expect(card).toContainText(workflowName);
|
||
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");
|
||
});
|
||
|
||
test("enables and disables a workflow from its card menu", async ({ page }) => {
|
||
const workflowName = `toggle_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
const card = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
const actions = card.getByRole("button", { name: "Workflow actions" });
|
||
|
||
await actions.click();
|
||
await page.getByRole("menuitem", { name: "Disable" }).click();
|
||
await expect(card.getByText("disabled", { exact: true })).toBeVisible();
|
||
|
||
await actions.click();
|
||
await page.getByRole("menuitem", { name: "Enable" }).click();
|
||
await expect(card.getByText("active", { exact: true })).toBeVisible();
|
||
});
|
||
|
||
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 page.getByRole("button", { name: "Close" }).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
|
||
await expect(page.getByRole("dialog")).toBeVisible();
|
||
await expect(page.getByText("Edit workflow")).toBeVisible();
|
||
|
||
// Change the name
|
||
const nameInput = page.getByLabel("Workflow name");
|
||
await nameInput.clear();
|
||
await nameInput.fill(updatedName);
|
||
|
||
// Save
|
||
await page.getByRole("button", { name: "Save changes" }).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
|
||
await selectFirstChannel(page.getByRole("dialog"));
|
||
await page.getByRole("button", { name: "Create copy" }).click();
|
||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||
|
||
// 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 empty state
|
||
await expect(page.getByText("No workflows yet")).toBeVisible();
|
||
});
|
||
|
||
test("opens a workflow in the edit modal from its card", async ({ page }) => {
|
||
const workflowName = `open_edit_test_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
await page.getByRole("button", { name: `Edit ${workflowName}` }).click();
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByLabel("Workflow name")).toHaveValue(workflowName);
|
||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?pane=trigger/);
|
||
});
|