Files
buzz/desktop/tests/e2e/workflow-local-controls.spec.ts
Logan Johnson ad2a84131e fix(desktop): npub identity displays for mention, member, and workflow surfaces (#7495)
🤖
## Summary

Every Buzz account is identified by a long public key. Before this
change, when someone had no display name, surfaces fell back to
inconsistent labels — mostly raw hex fragments like `abcd1234…wxyz`,
sometimes a generic role label with no key — so the same person looked
different from surface to surface, and nothing looked like an npub
address. This PR applies the npub identity foundation from #7488 to the
everyday surfaces: a person without a display name now falls back to the
same compact npub everywhere — `npub1xxxx…yyyy`, the human-readable
spelling of their public key (first 8 + last 4 characters of the full
npub) — across messages and mentions, reactions, huddles, member and
participant lists, the sidebar and channel activity, search, projects,
tray, notifications, and workflow surfaces.

- **Mentions and messages**: key-only mention chips render the compact
npub. Pasting a copied mention back still re-binds it byte-exactly to
the identity it declares, for both the new npub chips and legacy
hex-truncated chips copied by older clients — wrong, missing, or
tampered key qualification is rejected instead of silently degrading to
plain text.
- **Reactions and huddles**: huddle reaction events and the huddle
roster/participants render the compact npub for unnamed participants;
workflow reaction triggers describe authors with the same form.
- **Members and sidebar**: channel and community member lists,
add-member results and invites, the members sidebar, the
channel-activity popover, search, projects (assignees/reviewers/PR
panels), the tray menu, and desktop notifications all fall back to the
compact npub; titles and aria labels keep the machine-readable full
labels.
- **Profile labels**: panel/popover display names and owner handles fall
back to the compact npub (never raw hex) when there is no name;
linked-event (nevent) message metadata shows the npub-shaped author
fallback while the event lookup and event IDs are unchanged.
- **Workflows**: author-picker secondary labels, step destination keys,
and trigger-author references render compact npubs; event and blob IDs
keep their existing hex compacts (they are not identities).
- **Avatars stay distinct**: fallback avatars for key-only identities
derive initials from the key's tail, so prefixed role labels like
"Participant npub1…" no longer collapse every unnamed participant onto
the same initials; people with names keep their name initials.

Preserved exactly: display names and distinct avatars, internal hex keys
(storage/API forms unchanged), clipboard identity roundtrips, event/blob
ID compaction, private keys (no nsec path is touched), and nevent link
handling.

Scope: this PR changes what identity labels **display**, not identity
controls — profile/settings copy controls, the respond-to allowlist,
workflow key fields, and agent dialogs are the sibling slice #7489, and
the shared primitives (`canonicalNpub`, `truncateNpub`, the `<PubKey>`
gate, strict input parsing) come from the foundation #7488.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for duplicates — none found;
the related work is the npub identity stack this slice belongs to.
- Base/dependency: stacks on #7488 (foundation) — this PR does not stand
alone on main.
- #7489 is a sibling slice on the same #7488 base
(profile/agent/workflow controls), not a dependency: this PR does not
require #7489, and #7489 does not require this PR — both only require
#7488.

### Testing

At exact head `4763cbeae1dd521309755e6d61f657324cb98667` (base:
`fix/desktop-npub-identity-d1a` @
`5f3a4a8111998c8aa41ad77cf66992bd1c85343c`; 71 files, +656/−189 —
production +277/−136, test support +379/−53):

- At this head: targeted `mentions.spec.ts` (1/1), the e2e build,
typecheck, and biome — green.
- 9 changed/related unit files: 100/100 green; typecheck, e2e build,
biome, and px text/truncation checks clean; huddle-roster focused run
green; channel-activity e2e 11/11; mutation checks confirm the fallback
wiring (removing it collapses shared initials and drops fallback rows).
- Known pre-existing local e2e failures, unchanged by this PR and
reproduced identically at the upstream merge-base: huddle-transcription
voice-menu attribution (25 pass / 1 fail) and the
`workflow-local-controls` 438px caret drift. Not claimed green locally.
- Update at head `236af9e6137386737e84d3a474d6bc808a704c50` (test-only
follow-ups `1143af345` + `236af9e6`): the `workflow-local-controls`
races were fixed in the test drivers, and the 438px diff was shown to be
a stale Darwin snapshot baseline (name-row enable switch already absent
and `message_posted` already MessageSquare at recording commit
`9390e11c9`) and refreshed — the focused screenshot test, including
keyboard/caret assertions, now passes locally (twice). The full spec was
not rerun after the snapshot refresh; the huddle-transcription item
above is unchanged.

Label/copy text changes are asserted by the e2e specs (`mentions`,
`mention-recipients`, `pubkey-display-screenshots`,
`huddle-transcription`, `channel-activity-popover`,
`workflow-local-controls`) rather than new screenshots; the screenshot
spec pins the compact npub text forms.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 14:07:24 -04:00

881 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { truncateNpub } from "../../src/shared/lib/pubkey";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
declare global {
interface Window {
__BUZZ_WORKFLOW_BATCH_CALLS__?: {
eventBatches: number[];
userBatches: number[];
};
}
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function openCreateWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const channelList = page.getByTestId("channel-combobox-list");
if (!(await channelList.isVisible())) {
await dialog.getByRole("combobox", { name: "Channel" }).click();
}
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
return dialog;
}
async function selectTrigger(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
trigger: string,
) {
await dialog.getByRole("button", { name: "Trigger event" }).click();
await page.getByRole("menuitem", { name: trigger, exact: true }).click();
}
async function openTriggerInspector(
dialog: import("@playwright/test").Locator,
) {
const menu = dialog.getByRole("button", { name: "Trigger event" });
if (!(await menu.isVisible())) {
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
}
await expect(menu).toBeVisible();
}
async function addMessageStep(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
) {
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog
.locator('textarea[id^="wf-step-"][id$="-text"]')
.fill("Workflow notification");
}
async function createEnabled(
page: import("@playwright/test").Page,
dialog: import("@playwright/test").Locator,
) {
await dialog.getByRole("button", { name: "Create" }).click();
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
if (await confirmation.isVisible()) {
await confirmation.getByRole("button", { name: "Turn on" }).click();
}
}
async function reopenWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name });
await card.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
return page.getByRole("dialog", { name: "Edit workflow" });
}
test("confirms activation after create and preserves a safe disabled path", async ({
page,
}) => {
const name = `activation_confirmation_${Date.now()}`;
const dialog = await openCreateWorkflow(page, name);
await addMessageStep(page, dialog);
await expect(
dialog.getByRole("switch", { name: "Enable workflow" }),
).toHaveCount(0);
await dialog.getByRole("button", { name: "Create" }).click();
const confirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",
});
await expect(confirmation).toBeVisible();
await expect(
confirmation.getByRole("button", { name: "Back" }),
).toBeFocused();
await confirmation.getByRole("button", { name: "Back" }).click();
await expect(confirmation).toBeHidden();
await expect(dialog).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(call) => call.command === "create_workflow",
).length,
),
)
.toBe(0);
await dialog.getByRole("button", { name: "Create" }).click();
await confirmation.getByRole("button", { name: "Keep off" }).click();
await expect(dialog).toBeHidden();
const yaml = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((candidate) => candidate.command === "create_workflow");
return (call?.payload as { yamlDefinition?: string } | undefined)
?.yamlDefinition;
});
expect(parseYaml(yaml ?? "").enabled).toBe(false);
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
await expect(
card.getByRole("switch", { name: "Enable workflow" }),
).not.toBeChecked();
});
test("inserts template variables with keyboard control and restores the caret", async ({
page,
}) => {
const dialog = await openCreateWorkflow(page, "template_variables_keyboard");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
const textarea = dialog.locator('textarea[id^="wf-step-"][id$="-text"]');
const listbox = page.getByRole("listbox");
await textarea.fill("Hello {{trig");
await expect(listbox).toBeVisible();
await expect(listbox.getByRole("option")).toHaveCount(5);
await waitForAnimations(page);
expect(await page.locator("body").screenshot()).toMatchSnapshot(
"workflow-template-variable-autocomplete.png",
);
await textarea.press("ArrowUp");
await expect(textarea).toHaveAttribute(
"aria-activedescendant",
/-variables-option-4$/,
);
await textarea.press("ArrowDown");
await expect(textarea).toHaveAttribute(
"aria-activedescendant",
/-variables-option-0$/,
);
await textarea.press("Enter");
await expect(textarea).toHaveValue("Hello {{trigger.text}}");
await expect(textarea).toBeFocused();
await expect
.poll(() =>
textarea.evaluate((element) =>
element instanceof HTMLTextAreaElement ? element.selectionStart : -1,
),
)
.toBe("Hello {{trigger.text}}".length);
await textarea.fill("Keep {{auth");
await expect(listbox).toBeVisible();
await textarea.press("Escape");
await expect(listbox).toBeHidden();
await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue("Keep {{auth");
await textarea.fill("By {{auth");
await textarea.press("Tab");
await expect(textarea).toHaveValue("By {{trigger.author}}");
await expect(textarea).toBeFocused();
});
test("round-trips schedule presets and saves a custom UTC cron", async ({
page,
}) => {
const name = `schedule_controls_${Date.now()}`;
const dialog = await openCreateWorkflow(page, name);
await selectTrigger(page, dialog, "Schedule");
await expect(dialog.getByRole("radio", { name: "Daily" })).toBeChecked();
await expect(dialog.getByLabel("Run time (UTC)")).toHaveValue("09:00");
await dialog.getByText("Every 15 minutes", { exact: true }).click();
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
let definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({ on: "schedule", interval: "15m" });
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await expect(
dialog.getByRole("radio", { name: "Every 15 minutes" }),
).toBeChecked();
await dialog.getByText("Monthly", { exact: true }).click();
await dialog.getByLabel("Day of month").selectOption("31");
await expect(
dialog.getByText("This schedule won’t run in some months."),
).toBeVisible();
await dialog.getByText("Custom cron", { exact: true }).click();
await dialog.getByRole("textbox", { name: "Minute", exact: true }).fill("5");
await dialog.getByRole("textbox", { name: "Hour", exact: true }).fill("*/2");
await dialog.getByRole("textbox", { name: "Day", exact: true }).fill("*");
await dialog.getByRole("textbox", { name: "Month", exact: true }).fill("*");
await dialog
.getByRole("textbox", { name: "Weekday", exact: true })
.fill("2-4");
await expect(dialog.getByText(/UTC · Paste all 5 fields/)).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({
on: "schedule",
cron: "5 */2 * * 2-4",
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await expect(
dialog.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
await expect(
dialog.getByRole("textbox", { name: "Weekday", exact: true }),
).toHaveValue("2-4");
await addMessageStep(page, dialog);
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened);
await expect(
reopened.getByRole("radio", { name: "Custom cron" }),
).toBeChecked();
await expect(
reopened.getByRole("textbox", { name: "Minute", exact: true }),
).toHaveValue("5");
await expect(
reopened.getByRole("textbox", { name: "Weekday", exact: true }),
).toHaveValue("2-4");
});
test("round-trips and reopens structured message-text conditions", async ({
page,
}) => {
const name = `message_condition_${Date.now()}`;
const text = 'deploy "buzz"\\path';
const expression = 'str_ends_with(trigger_text, "deploy \\"buzz\\"\\\\path")';
const dialog = await openCreateWorkflow(page, name);
await dialog
.getByRole("group", { name: "Match" })
.getByRole("button", {
name: "ends with",
})
.click();
await dialog.getByLabel("Message text").fill(text);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger.filter).toBe(expression);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await waitForAnimations(page);
const matchControls = dialog.getByRole("group", { name: "Match" });
const operatorButtons = matchControls.getByRole("button");
const firstOperatorBox = await operatorButtons.nth(0).boundingBox();
const secondOperatorBox = await operatorButtons.nth(1).boundingBox();
const thirdOperatorBox = await operatorButtons.nth(2).boundingBox();
expect(firstOperatorBox).not.toBeNull();
expect(secondOperatorBox).not.toBeNull();
expect(thirdOperatorBox).not.toBeNull();
expect(secondOperatorBox?.x).toBeGreaterThan(firstOperatorBox?.x ?? 0);
expect(
Math.abs((secondOperatorBox?.y ?? 0) - (firstOperatorBox?.y ?? 0)),
).toBeLessThan(1);
expect(thirdOperatorBox?.y).toBeGreaterThan(firstOperatorBox?.y ?? 0);
await waitForAnimations(page);
await matchControls.screenshot({
path: "test-results/workflow-message-condition-operators.png",
});
await expect(
matchControls.getByRole("button", { name: "ends with" }),
).toHaveAttribute("aria-pressed", "true");
await expect(dialog.getByLabel("Message text")).toHaveValue(text);
await dialog.getByRole("tab", { name: "Advanced" }).click();
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(
expression,
);
await dialog.getByRole("tab", { name: "Basic" }).click();
await addMessageStep(page, dialog);
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened);
await expect(
reopened
.getByRole("group", { name: "Match" })
.getByRole("button", { name: "ends with" }),
).toHaveAttribute("aria-pressed", "true");
await expect(reopened.getByLabel("Message text")).toHaveValue(text);
});
test("renders deterministic trigger, step, and workflow-card summaries", async ({
page,
}) => {
const name = `semantic_summaries_${Date.now()}`;
await page.addInitScript((workflowName) => {
const positions: number[] = [];
Object.defineProperty(window, "__WORKFLOW_METADATA_POSITIONS__", {
configurable: true,
value: positions,
});
const findTarget = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid="workflow-card-name"]',
),
).find((element) => element.textContent === workflowName);
if (!target) {
requestAnimationFrame(findTarget);
return;
}
let remainingFrames = 12;
const sample = () => {
positions.push(target.getBoundingClientRect().y);
remainingFrames -= 1;
if (remainingFrames > 0) requestAnimationFrame(sample);
};
sample();
};
requestAnimationFrame(findTarget);
}, name);
const dialog = await openCreateWorkflow(page, name);
await dialog.getByLabel("Message text").fill("deploy");
const triggerNode = dialog.getByRole("button", {
name: "Trigger: Message contains “deploy”",
});
await expect(triggerNode).toContainText("Trigger");
await expect(triggerNode).toContainText("Message contains “deploy”");
await addMessageStep(page, dialog);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
definition.steps[0].channel = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
await yamlEditor.fill(stringifyYaml(definition));
await dialog.getByRole("tab", { name: "Form" }).click();
const stepNode = dialog.getByRole("button", {
name: "Step 1: “Workflow notification” in #agents",
});
await expect(stepNode).toContainText("Send Message");
await expect(stepNode).toContainText("“Workflow notification” in #agents");
await createEnabled(page, dialog);
const card = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: name })
.first();
const semanticLabel = card.getByTestId("workflow-card-semantic-label");
const workflowName = card.getByTestId("workflow-card-name");
const channelName = card.getByTestId("workflow-card-channel");
await expect(card.getByTestId("workflow-card-trigger-summary")).toHaveCount(
0,
);
await expect(semanticLabel).toHaveText(
"When a message contains “deploy”, send “Workflow notification”",
);
await expect(workflowName).toHaveText(name);
await expect(channelName).toHaveText("#agents");
const semanticBox = await semanticLabel.boundingBox();
const nameBox = await workflowName.boundingBox();
const channelBox = await channelName.boundingBox();
expect(semanticBox).not.toBeNull();
expect(nameBox).not.toBeNull();
expect(channelBox).not.toBeNull();
expect(semanticBox?.y).toBeLessThan(nameBox?.y ?? 0);
expect(channelBox?.y).toBeLessThan(nameBox?.y ?? 0);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as typeof window & {
__WORKFLOW_METADATA_POSITIONS__?: number[];
}
).__WORKFLOW_METADATA_POSITIONS__?.length ?? 0,
),
)
.toBe(12);
const metadataPositions = await page.evaluate(
() =>
(
window as typeof window & {
__WORKFLOW_METADATA_POSITIONS__?: number[];
}
).__WORKFLOW_METADATA_POSITIONS__ ?? [],
);
expect(
Math.max(...metadataPositions) - Math.min(...metadataPositions),
).toBeLessThan(0.5);
});
test("Escape closes a filter picker, restores disclosure focus, then closes its inspector", async ({
page,
}) => {
for (const width of [760, 1280]) {
await page.setViewportSize({ width, height: 820 });
await page.goto(
"/#/workflows?view=create&channel=94a444a4-c0a3-5966-ab05-530c6ddc2301&pane=trigger",
);
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const inspector = dialog.getByTestId("workflow-node-inspector");
await selectTrigger(page, dialog, "Reaction Added");
for (const field of [
{
label: "Author",
picker: "workflow-author-picker",
search: "Search authors or paste a public key",
},
{
label: "Message",
picker: "workflow-message-picker",
search: "Search messages or paste a message ID",
},
]) {
const disclosure = dialog
.getByText(field.label, { exact: true })
.locator("..");
await disclosure.click();
const picker = dialog.getByTestId(field.picker);
const search = dialog.getByRole("combobox", { name: field.search });
await expect(picker).toBeVisible();
await search.press("Escape");
await expect(picker).toBeHidden();
await expect(disclosure).toBeFocused();
await expect(inspector).toBeVisible();
}
await page.keyboard.press("Escape");
await expect(inspector).toBeHidden();
await expect(dialog).toBeVisible();
}
});
test("workflow grid batches card author and message presentation reads", async ({
page,
}) => {
await page.goto("/");
await page.waitForFunction(
() => typeof window.__TAURI_INTERNALS__?.invoke === "function",
);
await page.evaluate(async () => {
const invoke = window.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("mock invoke bridge unavailable");
for (let index = 0; index < 40; index += 1) {
const messageId = (index + 1).toString(16).padStart(64, "0");
const author = (index + 101).toString(16).padStart(64, "0");
await invoke("create_workflow", {
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
yamlDefinition: JSON.stringify({
name: `batched_card_${index}`,
trigger: {
on: "reaction_added",
filter: `trigger_author == "${author}" && trigger_message_id == "${messageId}"`,
},
steps: [],
}),
});
}
const calls = { eventBatches: [], userBatches: [] };
window.__BUZZ_WORKFLOW_BATCH_CALLS__ = calls;
window.__TAURI_INTERNALS__.invoke = async (command, args) => {
if (command === "get_events") {
calls.eventBatches.push(
(args?.eventIds as unknown[] | undefined)?.length ?? 0,
);
}
if (command === "get_users_batch") {
calls.userBatches.push(
(args?.pubkeys as unknown[] | undefined)?.length ?? 0,
);
}
return invoke(command, args);
};
});
await page.getByTestId("open-workflows-view").click();
await expect(
page.locator('[data-testid^="workflow-card-mock-wf-"]'),
).toHaveCount(40);
await expect
.poll(() =>
page.evaluate(() => {
const calls = window.__BUZZ_WORKFLOW_BATCH_CALLS__;
return {
eventBatchCount: calls?.eventBatches.filter((size) => size === 40)
.length,
userBatchCount: calls?.userBatches.filter((size) => size === 40)
.length,
};
}),
)
.toEqual({ eventBatchCount: 1, userBatchCount: 1 });
});
test("does not select stale picker results when Enter outruns deferred filtering", async ({
page,
}) => {
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "agents",
content: "Deferred message candidate",
id: "d".repeat(64),
});
});
await page.getByTestId("open-workflows-view").click();
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const channelList = page.getByTestId("channel-combobox-list");
if (!(await channelList.isVisible())) {
await dialog.getByRole("combobox", { name: "Channel" }).click();
}
await channelList
.getByRole("option", { name: "agents", exact: true })
.click();
await selectTrigger(page, dialog, "Reaction Added");
await dialog.getByText("Author", { exact: true }).locator("..").click();
const authorSearch = dialog.getByRole("combobox", {
name: "Search authors or paste a public key",
});
await expect(dialog.getByRole("option").first()).toBeVisible();
await authorSearch.evaluate((input: HTMLInputElement) => {
input.value = "no-such-author";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
);
});
await dialog.getByText("Message", { exact: true }).locator("..").click();
const messageSearch = dialog.getByRole("combobox", {
name: "Search messages or paste a message ID",
});
await expect(
dialog.getByRole("option", { name: /Deferred message/ }),
).toBeVisible();
await messageSearch.evaluate((input: HTMLInputElement) => {
input.value = "no-such-message";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
);
});
await dialog.getByRole("tab", { name: "YAML" }).click();
const definition = parseYaml(
await dialog.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
);
expect(definition.trigger.filter).toBeUndefined();
});
test("round-trips manual author and reaction message IDs through save and reopen", async ({
page,
}) => {
const name = `manual_trigger_ids_${Date.now()}`;
const author = "a".repeat(64);
const messageId = "b".repeat(64);
const dialog = await openCreateWorkflow(page, name);
await selectTrigger(page, dialog, "Reaction Added");
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
await openTriggerInspector(dialog);
await dialog.getByText("Author", { exact: true }).locator("..").click();
const authorResults = dialog.getByTestId("workflow-author-picker-results");
await expect
.poll(() =>
authorResults.evaluate((results) => {
const template = results.querySelector<HTMLElement>("[role=option]");
if (!template) return false;
for (let index = 0; index < 20; index += 1) {
const clone = template.cloneNode(true) as HTMLElement;
clone.dataset.scrollFixture = "true";
clone.removeAttribute("id");
results.append(clone);
}
return results.scrollHeight > results.clientHeight;
}),
)
.toBe(true);
await authorResults.hover();
await page.mouse.wheel(0, 240);
await expect
.poll(() => authorResults.evaluate((node) => node.scrollTop))
.toBeGreaterThan(0);
await authorResults.evaluate((results) => {
for (const fixture of results.querySelectorAll("[data-scroll-fixture]")) {
fixture.remove();
}
results.scrollTop = 0;
});
const authorSearch = dialog.getByRole("combobox", {
name: "Search authors or paste a public key",
});
await authorSearch.fill(author);
await expect(
dialog.getByRole("option", { name: truncateNpub(author) }),
).toBeVisible();
await authorSearch.press("Enter");
await expect(
dialog.getByRole("option", { name: truncateNpub(author) }),
).toHaveAttribute("aria-selected", "true");
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
await dialog.getByRole("tab", { name: "YAML" }).click();
const correctionEditor = dialog.getByRole("textbox", {
name: "Workflow YAML",
});
const correctedDefinition = parseYaml(await correctionEditor.inputValue());
delete correctedDefinition.trigger.filter;
await correctionEditor.fill(stringifyYaml(correctedDefinition));
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await dialog.getByText("Author", { exact: true }).locator("..").click();
const correctedAuthorSearch = dialog.getByRole("combobox", {
name: "Search authors or paste a public key",
});
await correctedAuthorSearch.fill(author);
await expect(
dialog.getByRole("option", { name: truncateNpub(author) }),
).toBeVisible();
await correctedAuthorSearch.press("Enter");
await dialog
.getByRole("group", { name: "Match" })
.getByRole("button", { name: "is not", exact: true })
.click();
await dialog.getByText("Message", { exact: true }).locator("..").click();
const messageSearch = dialog.getByRole("combobox", {
name: "Search messages or paste a message ID",
});
await messageSearch.fill(messageId);
await messageSearch.press("Enter");
await expect(
dialog.getByRole("option", { name: new RegExp(messageId.slice(0, 12)) }),
).toHaveAttribute("aria-selected", "true");
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
let definition = parseYaml(await yamlEditor.inputValue());
expect(definition.trigger).toEqual({
on: "reaction_added",
filter: `trigger_author != "${author}" && trigger_message_id == "${messageId}"`,
});
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await createEnabled(page, dialog);
const reopened = await reopenWorkflow(page, name);
await openTriggerInspector(reopened);
await reopened.getByText("Author", { exact: true }).locator("..").click();
await expect(
reopened.getByRole("option", { name: truncateNpub(author) }),
).toHaveAttribute("aria-selected", "true");
await reopened.getByText("Message", { exact: true }).locator("..").click();
await expect(
reopened.getByRole("option", {
name: new RegExp(messageId.slice(0, 12)),
}),
).toHaveAttribute("aria-selected", "true");
await reopened.getByRole("tab", { name: "YAML" }).click();
definition = parseYaml(
await reopened.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
);
expect(definition.trigger.filter).toContain(messageId);
});
test("toggles selected author and message filters while preserving sibling conditions", async ({
page,
}) => {
const author = "a".repeat(64);
const replacementAuthor = "c".repeat(64);
const messageId = "b".repeat(64);
const dialog = await openCreateWorkflow(
page,
`toggle_trigger_ids_${Date.now()}`,
);
await selectTrigger(page, dialog, "Reaction Added");
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const definition = parseYaml(await yamlEditor.inputValue());
definition.trigger.filter =
`trigger_emoji == "👍" && trigger_author == "${author.toUpperCase()}" && ` +
`trigger_message_id == "${messageId.toUpperCase()}"`;
await yamlEditor.fill(stringifyYaml(definition));
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
const authorField = dialog
.getByText("Author", { exact: true })
.locator("..")
.locator("..");
await authorField.getByText("Author", { exact: true }).locator("..").click();
const authorOption = dialog.getByRole("option", {
name: truncateNpub(author),
});
await expect(authorOption).toHaveAttribute("aria-selected", "true");
await expect(
authorField.getByRole("button", { name: "Clear filter" }),
).toHaveCount(0);
await authorOption.click();
const authorSearch = dialog.getByRole("combobox", {
name: "Search authors or paste a public key",
});
await authorSearch.fill(replacementAuthor);
const replacementAuthorOption = dialog.getByRole("option", {
name: truncateNpub(replacementAuthor),
});
await expect(replacementAuthorOption).toBeVisible();
await authorSearch.press("Enter");
await expect(replacementAuthorOption).toHaveAttribute(
"aria-selected",
"true",
);
await expect(authorSearch).toHaveValue(replacementAuthor);
await authorSearch.press("Enter");
await expect(authorSearch).toHaveValue(replacementAuthor);
const messageField = dialog
.getByText("Message", { exact: true })
.locator("..")
.locator("..");
await messageField
.getByText("Message", { exact: true })
.locator("..")
.click();
const messageSearch = dialog.getByRole("combobox", {
name: "Search messages or paste a message ID",
});
await expect(
dialog.getByRole("option", { name: new RegExp(messageId.slice(0, 12)) }),
).toHaveAttribute("aria-selected", "true");
await expect(
messageField.getByRole("button", { name: "Clear filter" }),
).toHaveCount(0);
await messageSearch.fill(messageId);
await messageSearch.press("Enter");
await expect(messageSearch).toHaveValue(messageId);
await messageSearch.press("Enter");
const selectedMessage = dialog.getByRole("option", {
name: new RegExp(messageId.slice(0, 12)),
});
await expect(selectedMessage).toHaveAttribute("aria-selected", "true");
await selectedMessage.click();
await expect(messageSearch).toHaveValue(messageId);
await dialog.getByRole("button", { name: "Reaction emoji" }).click();
await expect(
dialog.getByRole("button", { name: "Clear filter" }),
).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
'trigger_emoji == "👍"',
);
});
test("hides and clears message-text step conditions for schedule triggers", async ({
page,
}) => {
const dialog = await openCreateWorkflow(
page,
`schedule_step_condition_${Date.now()}`,
);
await addMessageStep(page, dialog);
await dialog.getByRole("button", { name: "Run controls" }).click();
await dialog.getByLabel("Text to match").fill("deploy");
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
await selectTrigger(page, dialog, "Schedule");
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
await dialog.getByRole("button", { name: "Run controls" }).click();
await expect(dialog.getByLabel("Text to match")).toHaveCount(0);
await expect(
dialog.getByRole("textbox", { name: "Timeout", exact: true }),
).toBeVisible();
await dialog.getByRole("tab", { name: "YAML" }).click();
const definition = parseYaml(
await dialog.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
);
expect(definition.steps[0].if).toBeUndefined();
});
test("keeps advanced and malformed definitions lossless", async ({ page }) => {
const advanced =
'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
const dialog = await openCreateWorkflow(page, `lossless_${Date.now()}`);
await dialog.getByRole("tab", { name: "YAML" }).click();
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
const initialYaml = await yamlEditor.inputValue();
await yamlEditor.fill(
initialYaml.replace(
"trigger:\n on: message_posted",
`trigger:\n on: message_posted\n filter: '${advanced}'`,
),
);
await dialog.getByRole("tab", { name: "Form" }).click();
await openTriggerInspector(dialog);
await expect(dialog.getByRole("tab", { name: "Advanced" })).toHaveAttribute(
"data-state",
"active",
);
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(advanced);
await dialog.getByRole("tab", { name: "Basic" }).click();
await expect(
dialog.getByText(/advanced expression is active/i),
).toBeVisible();
await expect(
dialog.getByRole("switch", { name: "Enable workflow" }),
).toHaveCount(0);
await dialog.getByRole("tab", { name: "YAML" }).click();
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
advanced,
);
const malformedYaml = (await yamlEditor.inputValue()).replace(
/trigger:\n {2}on: message_posted\n {2}filter:.*\n/,
'trigger:\n on: schedule\n cron: "0 9 * * *"\n interval: 1h\n',
);
await yamlEditor.fill(malformedYaml);
await dialog.getByRole("tab", { name: "Form" }).click();
await expect(
dialog.getByText(/cannot specify both cron and interval/i),
).toBeVisible();
await expect(yamlEditor).toHaveValue(malformedYaml);
});