mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
🤖 ## 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>
881 lines
32 KiB
TypeScript
881 lines
32 KiB
TypeScript
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);
|
||
});
|