mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
## Summary Test-side fixes for ten desktop E2E flake clusters and six long-tail rows from the 21–28 Oct 2026 CI audit. Each spec now waits for the state it assumes (a rendered row, a mounted listener, a finished animation, a held boot read), not a fixed delay or a one-shot probe. Two mock-bridge seams support this. The PR changes test code and mock-bridge seams only, apart from one product constant extracted with its value unchanged. It is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. - #8622, #8244 and #8386 touch some of these spec files, but not the tests fixed here. - §7 mode A (`navigation:472`) is left to open PR #6852, which already sets the width and measures in one polled evaluate. - Draft #8279 queues cold-launch nostr-bind links in the product. Its mock returns null, so the §12 listener seam is still needed. ### Mock-bridge seams - `__BUZZ_E2E_EVENT_LISTENER_COUNT__(event)` counts live **app** listeners per Tauri event. It wraps the mocked `plugin:event|listen`/`unlisten`. The spec-facing `__TAURI_INTERNALS__.listen` passes a marker target that the wrapper skips, so a spec's own subscription can't satisfy the wait. Tauri 2.11.1's mock ignores targets, so delivery is unchanged. - `deferFirstChannelsRead` arms the existing channels-read hold at boot. `__BUZZ_E2E_RELEASE_CHANNELS_READ__` releases it. - `CREATE_PROJECT_REFOCUS_DELAY_MS` moves to `createProjectFormFocus.ts` with its value unchanged (50 ms). Both `CreateProjectFormContent` and `project-commit-detail.spec` import it. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §2 `overscroll-boundary.spec.ts:34` (and its sibling) | 39601763602, 40367495044, 40583830861, 40597484657, 40373404267 | The `message-timeline` test id first lands on the loading skeleton, which doesn't overflow, so the scroll lock rightly cancels the wheel. | Wait for a rendered row, and content taller than the viewport, before dispatching wheels. | | §4 `project-conversation-load-failure.spec.ts:73` | 40367495044 (all 3 attempts), 40608383847, 40583830861; PRs 39541263507, 40509460168 | The seeded root and reply were dated `now`. The panel's live REQ (`since = floor(now)`) still replayed the reply, so the error card never appeared. | Date both events a minute in the past, and correct the comment. | | §6 `workflows.spec.ts` `createWorkflow` cluster (:222, :365, :297, :525, :471, :406, :614, :997) | 14 of 50 main jobs; :222 ×4, e.g. 40353619362 | `AnimatePresence mode="wait"` keeps the trigger body mounted through its exit, so "Message text" could fill the trigger's field (:222 and the callers). For :297, `isVisible()` read "closed" one frame before the dialog opened its own channel list. | Wait for the step body's "Step details" control before filling. Create dialogs wait for the list; only the duplicate dialog clicks it. | | §12 `nostr-bind.spec.ts` (:156, :198, :228, :247, :260, :335, :417, :447) | 8 main jobs, each test once | The spec emitted `deep-link-nostr-bind` before the app subscribed, and the mock drops events that have no listener. | Wait for `__BUZZ_E2E_EVENT_LISTENER_COUNT__` to report the app listener before emitting. | | §15 `profile.spec.ts:1128` (integration) | 7 of 50 main jobs | The 320 ms motion-sampling window started before the click. | Start the window when `activeTab` reads "runtime". | | §17 `agent-availability.spec.ts:602`, both shutdown-failure variants (integration) | 6 jobs (4 disconnected-online, 2 failed-online) | The "shutdown refused" toast sits over the Delete row and pauses while the pointer is on it, so the retry click is intercepted. | Assert the toast, then retry with focus + Enter. The toaster has no close button. | | §19 `virtualization.spec.ts:247` "08 — cascading older pages…" | 4 of 50 main jobs; PR 40605736093 (all 3 attempts) | The 120 ms and 400 ms windows were timed from before the wheel bursts, and CDP can deliver wheels late. | Count the wheels the page receives. Each window opens at the first wheel and closes only after all have landed, then waits for still frames. **A second, product-side mode remains** (see below). | | §20 `message-copy-link.spec.ts:160` | 4 of 50 main jobs (5 failed attempts), e.g. 40595483966 | The huddle row reused `"d".repeat(64)`, which is a seeded ordinary `#general` message. | Use ids that no seed uses, and emit only after `waitForMockChannelHeadReady`. | | §21 `navigation.spec.ts:687` | 4 of 50 main jobs | The pending chip's `boundingBox()` was read once, and can be null while the row mounts. | Poll until the chip has a width. | | §23 `sidebar-snapshot.spec.ts:358`, `:388`, `:438` | PR 40427822003 (all 3 attempts); never on main | The 500 ms loading-state budget counted app boot, so a slow runner finished the channels read first. | Hold the first channels read with `deferFirstChannelsRead`, assert the loading state, then release it. | | `project-commit-detail.spec.ts:259` | 1–2 CI failures (audit long tail) | A 50 ms refocus timer can land mid-fill and type the description into the name field. | Once the name field is focused, wait out a page timer of `CREATE_PROJECT_REFOCUS_DELAY_MS`; equal-delay timers fire in order. | | `project-pr-review.spec.ts:3520` | 1–2 CI failures | The same timer pattern in `CreateProjectWorkItemDialog`. | Wait for `toBeFocused()` on the title, which only that timer can satisfy. | | `thread-load-failure.spec.ts:79` | 1–2 CI failures | `openWelcomeThread` branched on a one-shot `count()` and fell back to a hover-only Reply click. | Wait for the summary row, then click it. | | `virtualization.spec.ts:201` | 1–2 CI failures | The drop point sat 2 px above the target's bottom edge, after two fixed frames. | Drop at the centre, after dnd-kit's live region names the target. | | `channel-composer-overflow.spec.ts:217` | 1–2 CI failures | Same-second emits sort by id, and the clearance was checked once. | `createdAt: base + i` and `expect.poll`. | | `scroll-history.spec.ts:1289` | 1–2 CI failures | A fixed 250 ms wait before idle sampling. | Poll for coverage first. | Local before/after counts are from the original `flakey-tests` commits (e286a6ae0, 816c7c35e, 67185df6a). Each used a forcing that made `main`'s copy fail with CI's signature: | Test | Before | After | |---|---|---| | §2 | 3/15 | 15/15 | | §4 | 0/10 | 10/10 | | §6 :222 | 0/3 | 3/3 | | §6 :297 | 0/3 | 3/3 | | §12 | 0/3 | 3/3 | | §15 | 0/3 | 3/3 | | §17 | 1/4 | 4/4 | | §19 late-wheel modes | 0/3 | 3/3 and 2/3 | | §23 | 2/6 | 6/6 | | project-commit-detail | 0/3 | 3/3 | | project-pr-review | 0/3 | 3/3 | §20, §21, `thread-load-failure:79`, `virtualization:201`, `channel-composer-overflow:217` and `scroll-history:1289` were not reproduced; they get the fix that matches their CI signature. Not fixed (follow-up): `virtualization:247` also has a product-side mode. In one frame after a prepend, `scrollHeight` can drop 458–639 px while `scrollTop` moves only 50–114 px, so the anchor row jumps 382–457 px. That is lost anchor compensation, and this test can still fail on it. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs. - Every file except `virtualization.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). That file carries only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on the partial spec (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness Test and mock-bridge change; the one product edit is a constant extraction with its value unchanged. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order No required order. `desktop/tests/e2e/virtualization.spec.ts` is also edited by **flakey-tests-desktop-races**, which adds one test after line 929; this PR's hunks are all above line 551. They don't touch, so the two merge cleanly in either order, as checked with `git merge-tree`. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8682 (desktop E2E composer + row actions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
1105 lines
41 KiB
TypeScript
1105 lines
41 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import { parse as parseYaml } from "yaml";
|
||
|
||
import { installMockBridge } from "../helpers/bridge";
|
||
import { waitForAnimations } from "../helpers/animations";
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await installMockBridge(page);
|
||
});
|
||
|
||
async function navigateToWorkflows(page: import("@playwright/test").Page) {
|
||
await page.goto("/");
|
||
await page.getByTestId("open-workflows-view").click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||
}
|
||
|
||
// A new workflow's channel picker opens itself one animation frame after the
|
||
// dialog mounts. A visibility probe taken before that frame reads "closed",
|
||
// and the click that follows can land after it and toggle the list shut, so
|
||
// create dialogs wait for the list instead. Dialogs that never open it
|
||
// themselves (duplicate) open it with a click.
|
||
async function selectWorkflowChannel(
|
||
page: import("@playwright/test").Page,
|
||
dialog: import("@playwright/test").Locator,
|
||
{ opensItself = true }: { opensItself?: boolean } = {},
|
||
) {
|
||
const channelList = page.getByTestId("channel-combobox-list");
|
||
if (!opensItself) {
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
}
|
||
await expect(channelList).toBeVisible();
|
||
await waitForAnimations(page);
|
||
await channelList
|
||
.getByRole("option", { name: "agents", exact: true })
|
||
.click();
|
||
}
|
||
|
||
async function editWorkflowName(
|
||
dialog: import("@playwright/test").Locator,
|
||
name: string,
|
||
) {
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
const input = dialog.getByRole("textbox", { name: "Workflow name" });
|
||
await input.fill(name);
|
||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||
await expect(input).not.toBeVisible();
|
||
await expect(dialog).toContainText(name);
|
||
await expect(
|
||
dialog.page().getByTestId("channel-combobox-list"),
|
||
).not.toBeVisible();
|
||
}
|
||
|
||
async function createWorkflow(
|
||
page: import("@playwright/test").Page,
|
||
name: string,
|
||
options?: {
|
||
description?: string;
|
||
enabled?: boolean;
|
||
trigger?: string;
|
||
stepConditionText?: string;
|
||
stepName?: string;
|
||
stepTimeoutSecs?: string;
|
||
},
|
||
) {
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await expect(dialog).toBeVisible();
|
||
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, name);
|
||
if (options?.description) {
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||
const yaml = await yamlEditor.inputValue();
|
||
await yamlEditor.fill(
|
||
yaml.replace(
|
||
`name: ${name}`,
|
||
`name: ${name}\ndescription: ${JSON.stringify(options.description)}`,
|
||
),
|
||
);
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
}
|
||
if (options?.trigger) {
|
||
await dialog
|
||
.getByRole("button", { name: "Trigger: Message Posted" })
|
||
.click();
|
||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||
await page
|
||
.getByRole("menuitem", {
|
||
name:
|
||
options.trigger === "diff_posted"
|
||
? "Diff Posted"
|
||
: options.trigger === "reaction_added"
|
||
? "Reaction Added"
|
||
: options.trigger === "webhook"
|
||
? "Webhook"
|
||
: "Message Posted",
|
||
})
|
||
.click();
|
||
}
|
||
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
// The inspector swaps from the trigger to the new step after the click; an
|
||
// unscoped fill can land in the trigger's message-text condition instead.
|
||
// The "Action" header switches at once, but the trigger body stays mounted
|
||
// through its exit animation (AnimatePresence mode="wait"), so also wait
|
||
// for a control only the step body renders.
|
||
const stepInspector = dialog.getByTestId("workflow-node-inspector");
|
||
await expect(
|
||
stepInspector.getByRole("button", { name: "Action", exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
stepInspector.getByRole("button", { name: "Step details" }),
|
||
).toBeVisible();
|
||
await stepInspector.getByLabel("Message text").fill("Workflow notification");
|
||
if (options?.stepName) {
|
||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||
await dialog.getByLabel("Name (optional)").fill(options.stepName);
|
||
}
|
||
if (options?.stepConditionText || options?.stepTimeoutSecs) {
|
||
await dialog.getByRole("button", { name: "Run controls" }).click();
|
||
}
|
||
if (options?.stepConditionText) {
|
||
await dialog.getByLabel("Text to match").fill(options.stepConditionText);
|
||
}
|
||
if (options?.stepTimeoutSecs) {
|
||
await dialog
|
||
.getByRole("textbox", { name: "Timeout", exact: true })
|
||
.fill(options.stepTimeoutSecs);
|
||
}
|
||
|
||
await dialog.getByRole("button", { name: "Create" }).click();
|
||
if (!options?.trigger || options.trigger === "message_posted") {
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await activationConfirmation
|
||
.getByRole("button", {
|
||
name: options?.enabled === false ? "Keep off" : "Turn on",
|
||
})
|
||
.click();
|
||
}
|
||
|
||
await expect(
|
||
page.getByRole("heading", { name: "Create Workflow" }),
|
||
).not.toBeVisible();
|
||
}
|
||
|
||
test("navigates to workflows view and shows the empty create tile", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||
});
|
||
|
||
test("creates a narrowly triggered workflow without an activation warning", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, `safe_webhook_${Date.now()}`, {
|
||
trigger: "webhook",
|
||
});
|
||
|
||
await expect(
|
||
page.getByTestId("workflow-activation-confirmation"),
|
||
).toHaveCount(0);
|
||
const yaml = await page.evaluate(() => {
|
||
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||
.reverse()
|
||
.find((candidate) => candidate.command === "create_workflow");
|
||
return (call?.payload as { yamlDefinition?: string } | undefined)
|
||
?.yamlDefinition;
|
||
});
|
||
expect(parseYaml(yaml ?? "").enabled).not.toBe(false);
|
||
});
|
||
|
||
test("creation reveals the trigger pane only after a one-shot channel pick", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
const channelList = page.getByTestId("channel-combobox-list");
|
||
await expect(channelList).toBeVisible();
|
||
await expect(dialog.getByTestId("workflow-node-inspector")).toHaveCount(0);
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(channelList).not.toBeVisible();
|
||
await expect
|
||
.poll(() => dialog.getByText("Untitled workflow", { exact: true }).count())
|
||
.toBe(0);
|
||
await expect(channelList).not.toBeVisible();
|
||
|
||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||
await channelList
|
||
.getByRole("option", { name: "agents", exact: true })
|
||
.click();
|
||
await expect(dialog.getByTestId("workflow-node-inspector")).toBeVisible();
|
||
await expect(
|
||
dialog.getByRole("button", { name: "Trigger event" }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("Escape closes the selected inspector before the workflow modal", async ({
|
||
page,
|
||
}) => {
|
||
for (const width of [760, 1280]) {
|
||
await page.setViewportSize({ width, height: 820 });
|
||
await page.goto(
|
||
"/#/workflows?view=create&channel=94a444a4-c0a3-5966-ab05-530c6ddc2301&pane=trigger",
|
||
);
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
await expect(dialog).toBeVisible();
|
||
await expect(inspector).toBeVisible();
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(inspector).not.toBeVisible();
|
||
await expect(dialog).toBeVisible();
|
||
await expect(page).toHaveURL(/view=create/);
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(dialog).not.toBeVisible();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
}
|
||
});
|
||
|
||
test("creates a workflow via the form builder", async ({ page }) => {
|
||
const workflowName = `test_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
// Verify workflow appears in the list
|
||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||
});
|
||
|
||
test("accepts the generated name through the first form mutation", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
const generatedName = (
|
||
await dialog
|
||
.getByRole("button", { name: "Edit workflow name" })
|
||
.evaluate((button) => button.previousElementSibling?.textContent ?? "")
|
||
).trim();
|
||
expect(generatedName).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
|
||
|
||
await dialog.getByRole("button", { name: "Add first step" }).click();
|
||
await dialog.getByRole("button", { name: "Create workflow" }).click();
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await activationConfirmation.getByRole("button", { name: "Turn on" }).click();
|
||
|
||
await expect(page.getByTestId("workflows-view")).toContainText(generatedName);
|
||
});
|
||
|
||
test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
|
||
await selectWorkflowChannel(page, dialog);
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
await expect(
|
||
dialog.getByRole("textbox", { name: "Workflow name" }),
|
||
).toHaveAttribute("autocapitalize", "off");
|
||
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||
await expect(dialog.getByLabel("Name (optional)")).toHaveAttribute(
|
||
"autocapitalize",
|
||
"off",
|
||
);
|
||
});
|
||
|
||
test("shows executable guidance for diff trigger conditions", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||
await page
|
||
.getByRole("menuitem", { name: "Diff Posted", exact: true })
|
||
.click();
|
||
|
||
await expect(dialog.getByLabel("Diff text")).toHaveAttribute(
|
||
"placeholder",
|
||
"e.g. deploy",
|
||
);
|
||
});
|
||
|
||
test("captures workflow library across responsive viewports", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, "Notify reviewers when source files change", {
|
||
description: "Watches diff events for src/ changes",
|
||
enabled: false,
|
||
trigger: "diff_posted",
|
||
});
|
||
await createWorkflow(page, "Post the daily standup reminder to the team", {
|
||
description: "Keeps the team aligned every morning",
|
||
trigger: "message_posted",
|
||
});
|
||
await createWorkflow(
|
||
page,
|
||
"Request approval before deploying to production",
|
||
{
|
||
description: "Requires a final review before release",
|
||
trigger: "reaction_added",
|
||
},
|
||
);
|
||
|
||
for (const viewport of [
|
||
{ width: 800, height: 720, name: "narrow" },
|
||
{ width: 1024, height: 720, name: "medium" },
|
||
{ width: 1280, height: 720, name: "wide" },
|
||
]) {
|
||
await page.setViewportSize(viewport);
|
||
await page.screenshot({
|
||
animations: "disabled",
|
||
path: `test-results/workflow-library-${viewport.name}.png`,
|
||
});
|
||
}
|
||
|
||
await page.setViewportSize({ width: 1280, height: 720 });
|
||
const firstCard = page.locator('[data-testid^="workflow-card-"]').first();
|
||
await firstCard.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.screenshot({
|
||
animations: "disabled",
|
||
path: "test-results/workflow-library-wide-actions.png",
|
||
});
|
||
});
|
||
|
||
test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||
const workflowName = `diff_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName, {
|
||
enabled: false,
|
||
trigger: "diff_posted",
|
||
stepName: "Notify reviewers",
|
||
stepConditionText: "src/",
|
||
stepTimeoutSecs: "45s",
|
||
});
|
||
|
||
const card = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
await expect(card.locator("h3")).toHaveText(
|
||
"When a diff is posted, send “Workflow notification”",
|
||
);
|
||
await expect(card).toContainText(workflowName);
|
||
await expect(
|
||
card.getByRole("switch", { name: "Disable workflow" }),
|
||
).toBeChecked();
|
||
});
|
||
|
||
test("enables and disables a workflow from its card status toggle", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `toggle_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
const workflowCard = () =>
|
||
page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
|
||
const disable = workflowCard().getByRole("switch", {
|
||
name: "Disable workflow",
|
||
});
|
||
await expect(disable).toBeChecked();
|
||
await disable.click();
|
||
const enable = workflowCard().getByRole("switch", {
|
||
name: "Enable workflow",
|
||
});
|
||
await expect(enable).not.toBeChecked();
|
||
await enable.click();
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await expect(activationConfirmation).toBeVisible();
|
||
await activationConfirmation.getByRole("button", { name: "Turn on" }).click();
|
||
await expect(
|
||
workflowCard().getByRole("switch", { name: "Disable workflow" }),
|
||
).toBeChecked();
|
||
|
||
await workflowCard()
|
||
.getByRole("button", { name: "Workflow actions" })
|
||
.click();
|
||
await expect(
|
||
page.getByRole("menuitemcheckbox", { name: "Enable" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("rejects a stale card toggle without overwriting a newer edit", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `stale_toggle_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
const workflowCard = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
|
||
await page.evaluate(async (name) => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||
.reverse()
|
||
.find((call) => call.command === "create_workflow");
|
||
const channelId = (
|
||
createCall?.payload as { channelId?: string } | undefined
|
||
)?.channelId;
|
||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||
const workflows = (await invoke("get_channels_workflows", {
|
||
channelIds: [channelId],
|
||
})) as Array<{
|
||
id: string;
|
||
revision: string;
|
||
definition: Record<string, unknown>;
|
||
}>;
|
||
const workflow = workflows.find(
|
||
(candidate) => candidate.definition.name === name,
|
||
);
|
||
if (!workflow) throw new Error("created workflow unavailable");
|
||
await invoke("update_workflow", {
|
||
workflowId: workflow.id,
|
||
expectedRevision: workflow.revision,
|
||
yamlDefinition: `name: ${name} edited elsewhere\nenabled: true\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: post_message\n`,
|
||
});
|
||
}, workflowName);
|
||
|
||
await workflowCard.getByRole("switch", { name: "Disable workflow" }).click();
|
||
|
||
await expect(
|
||
page
|
||
.locator("[data-sonner-toast][data-removed='false']")
|
||
.filter({ hasText: "workflow changed since it was loaded" }),
|
||
).toBeVisible();
|
||
const authoritativeName = await page.evaluate(async () => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||
.reverse()
|
||
.find((call) => call.command === "create_workflow");
|
||
const channelId = (
|
||
createCall?.payload as { channelId?: string } | undefined
|
||
)?.channelId;
|
||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||
const workflows = (await invoke("get_channels_workflows", {
|
||
channelIds: [channelId],
|
||
})) as Array<{ name: string }>;
|
||
return workflows[0]?.name;
|
||
});
|
||
expect(authoritativeName).toBe(`${workflowName} edited elsewhere`);
|
||
});
|
||
|
||
test("reports a rejected workflow status change", async ({ page }) => {
|
||
const workflowName = `rejected_toggle_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.evaluate(() => {
|
||
window.__BUZZ_E2E__ ??= {};
|
||
window.__BUZZ_E2E__.mock ??= {};
|
||
window.__BUZZ_E2E__.mock.workflowUpdateError = "relay refused the update";
|
||
});
|
||
|
||
const workflowCard = page
|
||
.locator('[data-testid^="workflow-card-"]')
|
||
.filter({ hasText: workflowName })
|
||
.first();
|
||
await workflowCard.getByRole("switch", { name: "Disable workflow" }).click();
|
||
|
||
const errorToast = page
|
||
.locator("[data-sonner-toast][data-removed='false']")
|
||
.filter({ hasText: "Couldn’t change workflow status" });
|
||
await expect(errorToast).toContainText("relay refused the update");
|
||
await expect(
|
||
workflowCard.getByRole("switch", { name: "Disable workflow" }),
|
||
).toBeChecked();
|
||
});
|
||
|
||
test("shows the webhook secret dialog after saving a webhook workflow", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `webhook_workflow_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName, {
|
||
trigger: "webhook",
|
||
});
|
||
|
||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Copy URL" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Copy Secret" })).toBeVisible();
|
||
|
||
await expect(page.getByText(/•{24}/)).toBeVisible();
|
||
await page.goBack();
|
||
const confirmation = page.getByRole("alertdialog", {
|
||
name: "Continue without this secret?",
|
||
});
|
||
await expect(confirmation).toBeVisible();
|
||
await confirmation.getByRole("button", { name: "Go back" }).click();
|
||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||
|
||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||
await confirmation.getByRole("button", { name: "Continue" }).click();
|
||
await expect(page.getByText("Webhook ready")).not.toBeVisible();
|
||
});
|
||
|
||
test("edits an existing workflow", async ({ page }) => {
|
||
const originalName = `edit_test_${Date.now()}`;
|
||
const updatedName = `${originalName}_updated`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, originalName);
|
||
|
||
// Verify it exists
|
||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||
|
||
// Open the dropdown menu and click Edit
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||
|
||
// Dialog should open in edit mode
|
||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByText("Edit workflow")).toBeVisible();
|
||
|
||
// Change the name
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
const nameInput = dialog
|
||
.getByRole("textbox", { name: "Workflow name" })
|
||
.first();
|
||
await nameInput.clear();
|
||
await nameInput.fill(updatedName);
|
||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||
|
||
// Save
|
||
await page.getByRole("button", { name: "Save" }).click();
|
||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||
|
||
// Verify the updated name appears
|
||
await expect(page.getByTestId("workflows-view")).toContainText(updatedName);
|
||
});
|
||
|
||
test("duplicates a workflow", async ({ page }) => {
|
||
const originalName = `dup_test_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, originalName);
|
||
|
||
// Open the dropdown menu and click Duplicate
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Duplicate" }).click();
|
||
|
||
// Dialog should open in duplicate mode with "(copy)" suffix
|
||
await expect(page.getByRole("dialog")).toBeVisible();
|
||
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
|
||
|
||
// Submit the duplicate after deliberately choosing its destination channel.
|
||
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
|
||
await selectWorkflowChannel(page, dialog, { opensItself: false });
|
||
await dialog.getByRole("button", { name: "Create copy" }).click();
|
||
const activationConfirmation = page.getByRole("alertdialog", {
|
||
name: "This workflow may run often",
|
||
});
|
||
await expect(activationConfirmation).toBeVisible();
|
||
await activationConfirmation.getByRole("button", { name: "Back" }).click();
|
||
await expect(dialog).toBeVisible();
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(
|
||
() =>
|
||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||
(call) => call.command === "create_workflow",
|
||
).length,
|
||
),
|
||
)
|
||
.toBe(1);
|
||
await dialog.getByRole("button", { name: "Create copy" }).click();
|
||
await activationConfirmation
|
||
.getByRole("button", { name: "Keep off" })
|
||
.click();
|
||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||
|
||
const copiedYaml = await page.evaluate(() => {
|
||
const calls = (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||
(call) => call.command === "create_workflow",
|
||
);
|
||
return (calls.at(-1)?.payload as { yamlDefinition?: string } | undefined)
|
||
?.yamlDefinition;
|
||
});
|
||
expect(parseYaml(copiedYaml ?? "").enabled).toBe(false);
|
||
|
||
// Both the original and copy should exist
|
||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||
});
|
||
|
||
test("deletes a workflow with confirmation", async ({ page }) => {
|
||
const workflowName = `delete_test_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
// Verify it exists
|
||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||
|
||
// Open the dropdown menu and click Delete
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||
|
||
// Confirmation dialog should appear with workflow name
|
||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
|
||
|
||
// Confirm deletion
|
||
await page.getByRole("button", { name: "Delete" }).click();
|
||
await expect(page.getByRole("alertdialog")).not.toBeVisible();
|
||
|
||
// Verify workflow is gone — back to the empty create tile.
|
||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||
});
|
||
|
||
test("deleting the open workflow closes its editor", async ({ page }) => {
|
||
const workflowName = `delete_open_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||
const confirmation = page.getByRole("alertdialog");
|
||
await expect(confirmation).toContainText(workflowName);
|
||
await expect(confirmation).toContainText(
|
||
"Publishing its definition again, even an older version, can recreate it.",
|
||
);
|
||
await expect(confirmation).not.toContainText("permanently");
|
||
await confirmation.getByRole("button", { name: "Delete" }).click();
|
||
|
||
await expect(detailDialog).toHaveCount(0);
|
||
await expect(page).toHaveURL(/#\/workflows(?:\?|$)/);
|
||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||
});
|
||
|
||
test("rejected deletion keeps the confirmation, editor, and draft", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `delete_rejected_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||
const editor = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await editor.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = editor.getByRole("textbox", { name: "Workflow YAML" });
|
||
await yamlEditor.fill(
|
||
(await yamlEditor.inputValue()).replace(
|
||
workflowName,
|
||
`${workflowName}_draft`,
|
||
),
|
||
);
|
||
await page.evaluate(() => {
|
||
window.__BUZZ_E2E__ ??= {};
|
||
window.__BUZZ_E2E__.mock ??= {};
|
||
window.__BUZZ_E2E__.mock.workflowDeleteError = "relay refused deletion";
|
||
});
|
||
|
||
await editor.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||
const confirmation = page.getByRole("alertdialog", {
|
||
name: "Delete workflow?",
|
||
});
|
||
await confirmation.getByRole("button", { name: "Delete" }).click();
|
||
|
||
await expect(confirmation).toBeVisible();
|
||
await expect(confirmation.getByRole("alert")).toContainText(
|
||
"relay refused deletion",
|
||
);
|
||
await confirmation.getByRole("button", { name: "Cancel" }).click();
|
||
await expect(editor).toBeVisible();
|
||
await expect(yamlEditor).toContainText(`${workflowName}_draft`);
|
||
});
|
||
|
||
test("captures the built editor at desktop and narrow widths", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "editor_screenshot");
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
await dialog.getByLabel("Message text").fill("Notify the workflow channel");
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
|
||
for (const viewport of [
|
||
{ height: 900, name: "wide", width: 1440 },
|
||
{ height: 820, name: "narrow", width: 760 },
|
||
]) {
|
||
await page.setViewportSize(viewport);
|
||
await inspector.getByRole("button", { name: "Run controls" }).click();
|
||
await waitForAnimations(page);
|
||
await page.screenshot({
|
||
path: `test-results/workflow-editor-${viewport.name}-run-controls.png`,
|
||
});
|
||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||
await waitForAnimations(page);
|
||
await page.screenshot({
|
||
path: `test-results/workflow-editor-${viewport.name}-step-details.png`,
|
||
});
|
||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||
}
|
||
});
|
||
|
||
test("preserves final sequence affordances and responsive inspector behavior", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "sequence_parity_test");
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
|
||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||
const runControls = inspector.getByRole("button", { name: "Run controls" });
|
||
const stepDetails = inspector.getByRole("button", { name: "Step details" });
|
||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||
await expect(stepDetails).toHaveAttribute("aria-expanded", "false");
|
||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||
await expect(inspector.getByLabel("Name (optional)")).toHaveCount(0);
|
||
|
||
await runControls.click();
|
||
await expect(runControls).toHaveAttribute("aria-expanded", "true");
|
||
await inspector.getByLabel("Text to match").fill("deploy");
|
||
const timeoutField = inspector.getByRole("textbox", {
|
||
name: "Timeout",
|
||
exact: true,
|
||
});
|
||
const timeoutSlider = inspector.getByRole("slider", {
|
||
name: "Timeout slider",
|
||
});
|
||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "5m");
|
||
await timeoutField.fill("1h 2s");
|
||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "1h");
|
||
await timeoutField.fill("45s");
|
||
await expect(runControls).toContainText("Conditional · 45s");
|
||
|
||
await stepDetails.click();
|
||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||
await expect(stepDetails).toHaveAttribute("aria-expanded", "true");
|
||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||
await inspector.getByLabel("Name (optional)").fill("Notify deployers");
|
||
await expect(stepDetails).toContainText("Notify deployers");
|
||
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
|
||
|
||
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
|
||
const ingresses = dialog.getByTestId("workflow-node-ingress");
|
||
await expect(ingresses).toHaveCount(2);
|
||
await expect(
|
||
ingresses.first().locator("svg.lucide-arrow-down"),
|
||
).toBeVisible();
|
||
await expect(ingresses.last().locator("svg.lucide-arrow-down")).toHaveCount(
|
||
0,
|
||
);
|
||
await expect(
|
||
ingresses.last().getByRole("button", { name: "Add after Step 1" }),
|
||
).toBeVisible();
|
||
|
||
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
|
||
const removeStep = dialog.getByRole("button", { name: "Remove Step 1" });
|
||
await expect(removeStep).toHaveClass(/-right-8/);
|
||
await stepNode.hover();
|
||
await expect(removeStep).toBeVisible();
|
||
|
||
await page.setViewportSize({ width: 760, height: 820 });
|
||
await expect(
|
||
dialog.getByTestId("workflow-node-inspector-backdrop"),
|
||
).toBeVisible();
|
||
await dialog.getByTestId("workflow-node-inspector-backdrop").click({
|
||
position: { x: 20, y: 20 },
|
||
});
|
||
await expect(dialog.getByTestId("workflow-node-inspector")).not.toBeVisible();
|
||
});
|
||
|
||
test("pane routes use stable IDs and Form/YAML changes stay synchronized", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "pane_sync_test");
|
||
|
||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||
await dialog.getByLabel("Message text").fill("first message");
|
||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||
|
||
await dialog.getByRole("button", { name: "Add after Step 1" }).click();
|
||
await page.getByRole("menuitem", { name: "Delay" }).click();
|
||
await dialog.getByLabel("Duration").fill("5m");
|
||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||
|
||
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
|
||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||
await dialog
|
||
.getByTestId("workflow-node-inspector")
|
||
.getByRole("button", { name: "Remove step", exact: true })
|
||
.click();
|
||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||
|
||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||
await expect(yamlEditor).toContainText("id: step_2");
|
||
await expect(yamlEditor).not.toContainText("id: step_1");
|
||
const yaml = await yamlEditor.inputValue();
|
||
await yamlEditor.fill(yaml.replace("duration: 5m", "duration: 10m"));
|
||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||
await dialog.getByRole("button", { name: /^Step 1:/ }).click();
|
||
await expect(dialog.getByLabel("Duration")).toHaveValue("10m");
|
||
});
|
||
|
||
test("clean editor close respects in-app versus direct-entry provenance", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
await expect(page).toHaveURL(/view=create/);
|
||
await page
|
||
.getByRole("dialog", { name: "Create workflow" })
|
||
.getByRole("button", {
|
||
name: "Close",
|
||
exact: true,
|
||
})
|
||
.click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
|
||
await page.goto("/#/workflows?view=create");
|
||
await page
|
||
.getByRole("dialog", { name: "Create workflow" })
|
||
.getByRole("button", {
|
||
name: "Close",
|
||
exact: true,
|
||
})
|
||
.click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
await page.goBack();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
});
|
||
|
||
test("direct workflow routes survive refresh and invalid view opens detail", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `route_test_${Date.now()}`;
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?.*view=edit/);
|
||
const workflowId = new URL(page.url()).hash.match(/workflows\/([^?]+)/)?.[1];
|
||
expect(workflowId).toBeTruthy();
|
||
await page.goto(`/#/workflows/${workflowId}?view=invalid`);
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await expect(detailDialog).toContainText(workflowName);
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Run history" }),
|
||
).toHaveCount(0);
|
||
|
||
await page.goto("/#/workflows?view=create");
|
||
await page.reload();
|
||
await expect(
|
||
page.getByRole("dialog", { name: "Create workflow" }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("dirty create close is blocked and keep editing preserves the draft", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||
await selectWorkflowChannel(page, dialog);
|
||
await editWorkflowName(dialog, "preserved_dirty_draft");
|
||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||
|
||
const confirmation = page.getByRole("alertdialog", {
|
||
name: "Discard changes?",
|
||
});
|
||
await expect(confirmation).toBeVisible();
|
||
await confirmation.getByRole("button", { name: "Keep editing" }).click();
|
||
await expect(dialog).toContainText("preserved_dirty_draft");
|
||
});
|
||
|
||
test("stale editor save preserves the local draft and reports the conflict", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `stale_editor_${Date.now()}`;
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||
const nameInput = dialog
|
||
.getByRole("textbox", { name: "Workflow name" })
|
||
.first();
|
||
await nameInput.fill(`${workflowName}_local`);
|
||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||
|
||
await page.evaluate(async () => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const workflowId = new URL(window.location.href).hash.match(
|
||
/workflows\/([^?]+)/,
|
||
)?.[1];
|
||
if (!workflowId) throw new Error("workflow id unavailable");
|
||
const workflow = (await invoke("get_workflow", { workflowId })) as {
|
||
revision: string;
|
||
};
|
||
await invoke("update_workflow", {
|
||
expectedRevision: workflow.revision,
|
||
workflowId,
|
||
yamlDefinition:
|
||
"name: authoritative_remote\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: send_message\n text: remote\n",
|
||
});
|
||
});
|
||
|
||
await dialog.getByRole("button", { name: "Save changes" }).click();
|
||
await expect(dialog).toContainText("workflow changed since it was loaded");
|
||
await expect(dialog).toContainText(`${workflowName}_local`);
|
||
await expect(dialog).toBeVisible();
|
||
});
|
||
|
||
test("unsupported workflow opens canonical YAML without a fabricated sequence", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `unsupported_detail_${Date.now()}`;
|
||
await navigateToWorkflows(page);
|
||
const workflowId = await page.evaluate(async (name) => {
|
||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||
const workflow = (await invoke("create_workflow", {
|
||
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||
yamlDefinition: `name: ${name}\ntrigger:\n on: message_posted\n legacy_filter:\n author: alice\nsteps:\n - id: legacy_step\n action: send_message\n text: preserve me\n`,
|
||
})) as { id: string };
|
||
return workflow.id;
|
||
}, workflowName);
|
||
|
||
await page.goto(`/#/workflows/${workflowId}`);
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await expect(detailDialog).toContainText(workflowName);
|
||
await expect(detailDialog.getByRole("tab", { name: "YAML" })).toHaveAttribute(
|
||
"aria-selected",
|
||
"true",
|
||
);
|
||
const yamlEditor = detailDialog.getByRole("textbox", {
|
||
name: "Workflow YAML",
|
||
});
|
||
await expect(yamlEditor).toContainText("legacy_filter:");
|
||
await expect(yamlEditor).toContainText("author: alice");
|
||
await expect(yamlEditor).toContainText("id: legacy_step");
|
||
await expect(yamlEditor).toContainText("text: preserve me");
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Step 1: Send Message" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("card click opens the animated trigger inspector, triggers a run, and hides unsupported run history", async ({
|
||
page,
|
||
}) => {
|
||
const workflowName = `card_editor_${Date.now()}`;
|
||
|
||
await navigateToWorkflows(page);
|
||
await createWorkflow(page, workflowName);
|
||
|
||
const inspectorEntryAnimation = page.evaluate(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
const timeoutId = window.setTimeout(() => {
|
||
observer.disconnect();
|
||
resolve(false);
|
||
}, 1_000);
|
||
const observer = new MutationObserver(() => {
|
||
const inspector = document.querySelector<HTMLElement>(
|
||
'[data-testid="workflow-node-inspector"]',
|
||
);
|
||
if (!inspector) return;
|
||
|
||
observer.disconnect();
|
||
window.clearTimeout(timeoutId);
|
||
const initialRect = inspector.getBoundingClientRect();
|
||
const initialStyles = getComputedStyle(inspector);
|
||
const initial = {
|
||
opacity: initialStyles.opacity,
|
||
transform: initialStyles.transform,
|
||
width: initialRect.width,
|
||
};
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
const nextRect = inspector.getBoundingClientRect();
|
||
const nextStyles = getComputedStyle(inspector);
|
||
resolve(
|
||
nextRect.width !== initial.width ||
|
||
nextStyles.opacity !== initial.opacity ||
|
||
nextStyles.transform !== initial.transform,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
}),
|
||
);
|
||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||
|
||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?pane=trigger$/);
|
||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||
await expect(detailDialog).toBeVisible();
|
||
await expect(detailDialog).toContainText(workflowName);
|
||
await expect(inspectorEntryAnimation).resolves.toBe(true);
|
||
await expect(page.getByTestId("workflow-node-inspector")).toBeVisible();
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||
).toHaveAttribute("aria-pressed", "true");
|
||
const triggerCallsBefore = await page.evaluate(
|
||
() =>
|
||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||
(call) => call.command === "trigger_workflow",
|
||
).length ?? 0,
|
||
);
|
||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Trigger" }).click();
|
||
await expect
|
||
.poll(async () =>
|
||
page.evaluate(
|
||
() =>
|
||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||
(call) => call.command === "trigger_workflow",
|
||
).length ?? 0,
|
||
),
|
||
)
|
||
.toBe(triggerCallsBefore + 1);
|
||
await expect(page.getByTestId("workflow-detail-panel")).toHaveCount(0);
|
||
await expect(
|
||
detailDialog.getByRole("button", { name: "Run history" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("missing workflow routes show an unavailable modal with close and retry", async ({
|
||
page,
|
||
}) => {
|
||
await navigateToWorkflows(page);
|
||
await page.goto("/#/workflows/missing-workflow?view=edit");
|
||
|
||
const unavailable = page.getByRole("dialog", {
|
||
name: "Workflow unavailable",
|
||
});
|
||
await expect(unavailable).toBeVisible();
|
||
await expect(
|
||
unavailable.getByRole("button", { name: "Retry" }),
|
||
).toBeVisible();
|
||
await unavailable.getByRole("button", { name: "Close" }).click();
|
||
await expect(page).toHaveURL(/#\/workflows$/);
|
||
});
|