diff --git a/src/bundled/workflows/WorkflowChannel.test.tsx b/src/bundled/workflows/WorkflowChannel.test.tsx new file mode 100644 index 00000000..a2a44b14 --- /dev/null +++ b/src/bundled/workflows/WorkflowChannel.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { StrictMode } from "react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { parse as parseYaml } from "yaml"; +import { afterEach, expect, it } from "vitest"; +import { WorkflowChannel } from "./WorkflowChannel"; +import { + createWorkflowFixture, + fixtureChannel, + fixtureCursor, + fixtureViewer, +} from "./fixtures"; + +afterEach(cleanup); + +function mount() { + const fixture = createWorkflowFixture(); + const user = userEvent.setup(); + render( + + + , + ); + return { fixture, user }; +} + +async function open(user: ReturnType) { + await user.click(screen.getByRole("button", { name: "Message helper" })); +} + +const button = (name: string) => screen.getByRole("button", { name }); +const nameInput = () => screen.getByRole("textbox", { name: "Workflow name" }); + +it("loads paged history lazily and acknowledges an unknown run without replay", async () => { + const { fixture, user } = mount(); + await open(user); + expect(fixture.calls.runs).toBe(0); + await user.click(button("Read runs")); + expect(await screen.findByText("Current step: 1")).toBeVisible(); + await user.click(button("Older runs")); + expect( + await screen.findByText("No runs returned on this page."), + ).toBeVisible(); + expect(fixture.runCursor()).toEqual(fixtureCursor); + expect(fixture.runViews.slice(0, -1).every((view) => view.disposed())).toBe( + true, + ); + await user.click(button("Hide runs")); + expect(fixture.runViews.every((view) => view.disposed())).toBe(true); + + await user.click(button("Run now")); + act(() => fixture.finish("unknown")); + const id = fixture.capability.operations.snapshot().at(-1)?.eventId; + expect(button("Run now")).toBeDisabled(); + expect(screen.getByText(/The run may have started/)).toBeVisible(); + await user.click(button("Close editor")); + await open(user); + expect(button("Run now")).toBeDisabled(); + expect(button("Save workflow")).toBeDisabled(); + expect(fixture.calls.trigger).toBe(1); + await user.click(button("Dismiss notice")); + expect(screen.getByRole("alertdialog")).toHaveTextContent( + "does not undo, cancel or repeat", + ); + await user.click(button("Dismiss notice and continue")); + expect(button("Run now")).toBeEnabled(); + expect(button("Save workflow")).toBeEnabled(); + expect(fixture.calls.dismiss).toEqual([id]); + expect(fixture.calls.trigger).toBe(1); +}); + +it("adopts an exact lost-save readback without resubmitting", async () => { + const { fixture, user } = mount(); + await open(user); + await user.clear(nameInput()); + await user.type(nameInput(), "Saved without response"); + await user.click(button("Save workflow")); + act(() => { + fixture.saveOnServer(); + fixture.finish("unknown"); + }); + expect(button("Save workflow")).toBeDisabled(); + await user.click(button("Check saved configuration")); + expect(button("Save workflow")).toBeEnabled(); + expect(nameInput()).toHaveValue("Saved without response"); + expect(fixture.calls.save).toBe(1); + await user.type( + screen.getByRole("textbox", { name: "Message text" }), + " edit", + ); + await user.click(button("Save workflow")); + expect(fixture.calls.save).toBe(2); +}); + +it("requires explicit different-head recovery and restores a failed dismissal", async () => { + const { fixture, user } = mount(); + await open(user); + await user.clear(nameInput()); + await user.type(nameInput(), "Retained local draft"); + await user.click(button("Save workflow")); + act(() => { + fixture.saveOnServer(false); + fixture.finish("unknown"); + }); + await user.click(button("Check saved configuration")); + expect(button("Save workflow")).toBeDisabled(); + expect(button("Review current configuration")).toBeVisible(); + await user.click(button("Dismiss notice")); + await user.keyboard("{Escape}"); + expect(fixture.calls.dismiss).toEqual([]); + expect(button("Save workflow")).toBeDisabled(); + + fixture.setDismissError("Fixture dismissal failed"); + await user.click(button("Dismiss notice")); + await user.click(button("Dismiss notice and continue")); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Fixture dismissal failed", + ); + await user.keyboard("{Escape}"); + expect(button("Save workflow")).toBeDisabled(); + fixture.setDismissError(); + await user.click(button("Dismiss notice")); + await user.click(button("Dismiss notice and continue")); + expect(button("Save workflow")).toBeEnabled(); + expect(nameInput()).toHaveValue("Retained local draft"); + expect(fixture.calls.save).toBe(1); + await user.click(button("Save workflow")); + expect(fixture.calls.save).toBe(2); +}); + +it("keeps optimistic dismissal modal and ownership locked until settlement", async () => { + const { fixture, user } = mount(); + await open(user); + await user.clear(nameInput()); + await user.type(nameInput(), "Kept draft"); + await user.click(button("Save workflow")); + act(() => fixture.finish("unknown")); + const operationId = fixture.capability.operations.snapshot()[0]?.eventId; + + for (const failure of ["Journal unavailable", undefined]) { + fixture.setDismissError(failure); + fixture.holdDismiss(); + await user.click(button("Dismiss notice")); + const dialog = screen.getByRole("alertdialog", { + name: "Dismiss this notice?", + }); + try { + await user.click( + within(dialog).getByRole("button", { + name: "Dismiss notice and continue", + }), + ); + expect(fixture.capability.operations.snapshot()).toHaveLength(0); + expect(dialog).toBeVisible(); + expect( + within(dialog).getByRole("button", { name: "Dismissing…" }), + ).toBeDisabled(); + expect( + within(dialog).getByRole("button", { name: "Keep editing" }), + ).toBeDisabled(); + await user.keyboard("{Escape}"); + expect(dialog).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Close editor" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "New workflow" }), + ).not.toBeInTheDocument(); + expect(fixture.calls.save).toBe(1); + } finally { + await act(async () => fixture.releaseDismiss()); + } + if (failure) { + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + failure, + ); + expect( + fixture.capability.operations.snapshot().map((op) => op.eventId), + ).toEqual([operationId]); + await user.keyboard("{Escape}"); + expect(button("Save workflow")).toBeDisabled(); + expect(nameInput()).toHaveValue("Kept draft"); + } else { + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(button("Save workflow")).toBeEnabled(); + expect(nameInput()).toHaveValue("Kept draft"); + } + } + expect(fixture.calls.dismiss).toEqual([operationId, operationId]); + expect(fixture.calls.save).toBe(1); + await user.click(button("Save workflow")); + expect(fixture.calls.save).toBe(2); +}); + +it("treats legacy deletion as a request rather than verified removal", async () => { + const { fixture, user } = mount(); + await open(user); + await user.click(button("Delete workflow")); + expect(screen.getByRole("alertdialog")).toHaveTextContent( + "does not confirm runtime deletion", + ); + await user.click(button("Request deletion")); + act(() => fixture.finish("succeeded")); + expect( + screen.getByText(/Deletion request accepted\. The saved configuration/), + ).toBeVisible(); + expect(button("Message helper")).toBeVisible(); + await user.click(button("Dismiss notice")); + await user.click(button("Dismiss notice and continue")); + expect(button("Save workflow")).toBeEnabled(); + expect(fixture.calls.delete).toBe(1); +}); + +it("retains invalid timeout boundaries in both modes and saves exact valid YAML", async () => { + const { fixture, user } = mount(); + await open(user); + await user.click(screen.getByText("Step options", { exact: true })); + for (const input of ["oops", "0s", "1.5", "9007199254740992"]) { + const timeout = screen.getByRole("textbox", { + name: "Step timeout (optional)", + }); + await user.clear(timeout); + await user.type(timeout, input); + expect(timeout).toHaveValue(input); + expect(button("Save workflow")).toBeDisabled(); + expect(screen.getByText(/positive whole number/)).toHaveAttribute( + "role", + "status", + ); + await user.click(screen.getByRole("tab", { name: "YAML" })); + const yaml = screen.getByRole("textbox", { name: "Workflow YAML" }); + expect( + parseYaml((yaml as HTMLTextAreaElement).value).steps[0].timeout_secs, + ).toBe(input); + expect(button("Save workflow")).toBeDisabled(); + await user.click(screen.getByRole("tab", { name: "Form" })); + await user.click(screen.getByText("Step options", { exact: true })); + expect( + screen.getByRole("textbox", { name: "Step timeout (optional)" }), + ).toHaveValue(input); + } + await user.click(button("Close editor")); + expect( + screen.getByRole("alertdialog", { name: "Leave this draft?" }), + ).toBeVisible(); + await user.click(button("Keep editing")); + const timeout = screen.getByRole("textbox", { + name: "Step timeout (optional)", + }); + expect(timeout).toHaveValue("9007199254740992"); + await user.clear(timeout); + await user.type(timeout, "5m"); + expect(button("Save workflow")).toBeEnabled(); + await user.click(button("Save workflow")); + expect(fixture.calls.save).toBe(1); + expect(parseYaml(fixture.input()?.yaml ?? "").steps[0].timeout_secs).toBe( + 300, + ); + act(() => fixture.finish("succeeded")); + expect(button("Save workflow")).toBeEnabled(); + if (!timeout.isConnected) + await user.click(screen.getByText("Step options", { exact: true })); + const currentTimeout = screen.getByRole("textbox", { + name: "Step timeout (optional)", + }); + await user.clear(currentTimeout); + await user.type(currentTimeout, " "); + await user.click(button("Save workflow")); + expect(fixture.calls.save).toBe(2); + expect(parseYaml(fixture.input()?.yaml ?? "").steps[0]).not.toHaveProperty( + "timeout_secs", + ); +}); + +it("allocates unused IDs after a parsed ID beyond the safe integer boundary", async () => { + const { user } = mount(); + await open(user); + await user.click(screen.getByRole("tab", { name: "YAML" })); + const yaml = screen.getByRole("textbox", { name: "Workflow YAML" }); + const definition = parseYaml((yaml as HTMLTextAreaElement).value); + definition.steps[0].id = "step_9007199254740992"; + fireEvent.change(yaml, { target: { value: JSON.stringify(definition) } }); + await user.click(screen.getByRole("tab", { name: "Form" })); + await user.click(button("Add Send Message")); + const messages = screen.getAllByRole("textbox", { name: "Message text" }); + expect(messages).toHaveLength(2); + const addedMessage = messages[1]; + if (!addedMessage) throw new Error("Expected the added message field"); + await user.type(addedMessage, "Another message"); + await user.click(button("Add Delay")); + await user.click(screen.getByRole("tab", { name: "YAML" })); + expect( + parseYaml( + ( + screen.getByRole("textbox", { + name: "Workflow YAML", + }) as HTMLTextAreaElement + ).value, + ).steps.map((step: { id: string }) => step.id), + ).toEqual(["step_9007199254740992", "step_1", "step_2"]); +}); diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs index c7d7890c..0d4a9ca9 100644 --- a/src/bundled/workflows/workflows.journey.mjs +++ b/src/bundled/workflows/workflows.journey.mjs @@ -274,66 +274,6 @@ test("keyboard switches feed enabled-save confirmation and disabled readback", a expect(errors).toEqual([]); }); -test("history stays lazy and paged; acknowledging an unknown run never repeats it", async ({ - page, -}) => { - await page.goto(url); - const button = (name) => page.getByRole("button", { name, exact: true }); - await button("Message helper").click(); - expect(await page.evaluate(() => window.workflowFixture.calls.runs)).toBe(0); - await button("Read runs").click(); - await expect(page.getByText("Current step: 1")).toBeVisible(); - await button("Older runs").click(); - await expect(page.getByText("No runs returned on this page.")).toBeVisible(); - expect(await page.evaluate(() => window.workflowFixture.runCursor())).toEqual( - { - before: "2026-09-12T12:00:00.123456Z", - beforeId: "77777777-7777-4777-8777-777777777777", - }, - ); - expect( - await page.evaluate(() => - window.workflowFixture.runViews - .slice(0, -1) - .every((view) => view.disposed()), - ), - ).toBe(true); - await button("Hide runs").click(); - expect( - await page.evaluate(() => - window.workflowFixture.runViews.every((view) => view.disposed()), - ), - ).toBe(true); - await button("Run now").click(); - await button("Unknown operation").click(); - await expect(button("Run now")).toBeDisabled(); - await expect(page.getByText(/The run may have started/)).toBeVisible(); - const id = await page.evaluate( - () => - window.workflowFixture.capability.operations.snapshot().at(-1).eventId, - ); - await button("Close editor").click(); - await button("Message helper").click(); - await expect(button("Run now")).toBeDisabled(); - await expect(button("Save workflow")).toBeDisabled(); - expect(await page.evaluate(() => window.workflowFixture.calls.trigger)).toBe( - 1, - ); - await button("Dismiss notice").click(); - await expect(page.getByRole("alertdialog")).toContainText( - "does not undo, cancel or repeat", - ); - await button("Dismiss notice and continue").click(); - await expect(button("Run now")).toBeEnabled(); - await expect(button("Save workflow")).toBeEnabled(); - expect( - await page.evaluate(() => window.workflowFixture.calls.dismiss), - ).toEqual([id]); - expect(await page.evaluate(() => window.workflowFixture.calls.trigger)).toBe( - 1, - ); -}); - test("real session page under StrictMode fences community changes, warns for dirty channel navigation and purges access", async ({ page, }) => { @@ -405,267 +345,6 @@ test("real session page under StrictMode fences community changes, warns for dir expect(errors).toEqual([]); }); -test("a lost save response can be checked and adopted without resubmitting", async ({ - page, -}) => { - await page.goto(url); - const button = (name) => page.getByRole("button", { name, exact: true }); - await button("Message helper").click(); - await page - .getByLabel("Workflow name", { exact: true }) - .fill("Saved without response"); - await button("Save workflow").click(); - await page.evaluate(() => { - window.workflowFixture.saveOnServer(); - window.workflowFixture.finish("unknown"); - }); - await expect(button("Save workflow")).toBeDisabled(); - await button("Check saved configuration").click(); - await expect(button("Save workflow")).toBeEnabled(); - await expect(page.getByLabel("Workflow name", { exact: true })).toHaveValue( - "Saved without response", - ); - expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe(1); - await page - .getByLabel("Message text", { exact: true }) - .fill("Edit after recovery"); - await button("Save workflow").click(); - await expect - .poll(() => page.evaluate(() => window.workflowFixture.calls.save)) - .toBe(2); -}); - -test("different-head recovery needs explicit review; failed dismissal keeps the draft locked", async ({ - page, -}) => { - await page.goto(url); - const button = (name) => page.getByRole("button", { name, exact: true }); - await button("Message helper").click(); - await page - .getByLabel("Workflow name", { exact: true }) - .fill("Retained local draft"); - await button("Save workflow").click(); - await page.evaluate(() => { - window.workflowFixture.saveOnServer(false); - window.workflowFixture.finish("unknown"); - }); - await button("Check saved configuration").click(); - await expect(button("Save workflow")).toBeDisabled(); - await expect(button("Review current configuration")).toBeVisible(); - await button("Dismiss notice").click(); - await page.keyboard.press("Escape"); - await expect(button("Save workflow")).toBeDisabled(); - expect( - await page.evaluate(() => window.workflowFixture.calls.dismiss), - ).toEqual([]); - await page.evaluate(() => - window.workflowFixture.setDismissError("Fixture dismissal failed"), - ); - await button("Dismiss notice").click(); - await button("Dismiss notice and continue").click(); - await expect(page.getByRole("alert")).toHaveText("Fixture dismissal failed"); - await page.keyboard.press("Escape"); - await expect(button("Save workflow")).toBeDisabled(); - await page.evaluate(() => window.workflowFixture.setDismissError()); - await button("Dismiss notice").click(); - await button("Dismiss notice and continue").click(); - await expect(button("Save workflow")).toBeEnabled(); - await expect(page.getByLabel("Workflow name", { exact: true })).toHaveValue( - "Retained local draft", - ); - expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe(1); - await button("Save workflow").click(); - await expect - .poll(() => page.evaluate(() => window.workflowFixture.calls.save)) - .toBe(2); -}); - -test("optimistic dismissal keeps confirmation mounted until persistence settles", async ({ - page, -}) => { - await page.goto(url); - const button = (name) => page.getByRole("button", { name, exact: true }); - await button("Message helper").click(); - await page.getByLabel("Workflow name", { exact: true }).fill("Kept draft"); - await button("Save workflow").click(); - await page.evaluate(() => window.workflowFixture.finish("unknown")); - const operationId = await page.evaluate( - () => window.workflowFixture.capability.operations.snapshot()[0].eventId, - ); - const dialog = page.getByRole("alertdialog", { - name: "Dismiss this notice?", - }); - for (const fail of [true, false]) { - await page.evaluate((fail) => { - window.workflowFixture.setDismissError( - fail ? "Journal unavailable" : undefined, - ); - window.workflowFixture.holdDismiss(); - }, fail); - await button("Dismiss notice").click(); - try { - await button("Dismiss notice and continue").click(); - await expect - .poll(() => - page.evaluate( - () => - window.workflowFixture.capability.operations.snapshot().length, - ), - ) - .toBe(0); - await expect(dialog).toBeVisible(); - await expect(button("Dismissing…")).toBeDisabled(); - await expect(button("Keep editing")).toBeDisabled(); - await page.keyboard.press("Escape"); - await expect(dialog).toBeVisible(); - // The modal must keep navigation/submission inaccessible during the gap. - await expect(button("Close editor")).toHaveCount(0); - await expect(button("New workflow")).toHaveCount(0); - expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe( - 1, - ); - } finally { - await page.evaluate(() => window.workflowFixture.releaseDismiss()); - } - if (fail) { - await expect(dialog.getByRole("alert")).toHaveText("Journal unavailable"); - await expect - .poll(() => - page.evaluate(() => - window.workflowFixture.capability.operations - .snapshot() - .map((op) => op.eventId), - ), - ) - .toEqual([operationId]); - await page.keyboard.press("Escape"); - await expect(button("Save workflow")).toBeDisabled(); - await expect( - page.getByLabel("Workflow name", { exact: true }), - ).toHaveValue("Kept draft"); - } else { - await expect(dialog).toHaveCount(0); - await expect(button("Save workflow")).toBeEnabled(); - await expect( - page.getByLabel("Workflow name", { exact: true }), - ).toHaveValue("Kept draft"); - } - } - expect( - await page.evaluate(() => window.workflowFixture.calls.dismiss), - ).toEqual([operationId, operationId]); - expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe(1); - await button("Save workflow").click(); - await expect - .poll(() => page.evaluate(() => window.workflowFixture.calls.save)) - .toBe(2); -}); - -test("legacy deletion is a request, not verified runtime removal", async ({ - page, -}) => { - await page.goto(url); - const button = (name) => page.getByRole("button", { name, exact: true }); - await button("Message helper").click(); - await button("Delete workflow").click(); - await expect(page.getByRole("alertdialog")).toContainText( - "does not confirm runtime deletion", - ); - await button("Request deletion").click(); - await page.evaluate(() => window.workflowFixture.finish("succeeded")); - await expect( - page.getByText(/Deletion request accepted\. The saved configuration/), - ).toBeVisible(); - await expect(button("Message helper")).toBeVisible(); - await button("Dismiss notice").click(); - await button("Dismiss notice and continue").click(); - await expect(button("Save workflow")).toBeEnabled(); - expect(await page.evaluate(() => window.workflowFixture.calls.delete)).toBe( - 1, - ); -}); - -test("invalid timeout text stays in the draft and blocks saves in both editor modes", async ({ - page, -}) => { - await page.goto(url); - const button = (name) => page.getByRole("button", { name, exact: true }); - await button("Message helper").click(); - await page.getByText("Step options", { exact: true }).click(); - const timeout = page.getByLabel("Step timeout (optional)", { exact: true }); - const yaml = page.getByLabel("Workflow YAML", { exact: true }); - for (const input of ["oops", "0s", "1.5", "9007199254740992"]) { - await timeout.fill(input); - await expect(timeout).toHaveValue(input); - await expect(button("Save workflow")).toBeDisabled(); - await expect( - page.getByRole("status").filter({ hasText: /timeout/ }), - ).toContainText("positive whole number"); - await page.getByRole("tab", { name: "YAML", exact: true }).click(); - expect(parseYaml(await yaml.inputValue()).steps[0].timeout_secs).toBe( - input, - ); - await expect(button("Save workflow")).toBeDisabled(); - await page.getByRole("tab", { name: "Form", exact: true }).click(); - await page.getByText("Step options", { exact: true }).click(); - await expect(timeout).toHaveValue(input); - } - await button("Close editor").click(); - await expect( - page.getByRole("alertdialog", { name: "Leave this draft?" }), - ).toBeVisible(); - await button("Keep editing").click(); - await expect(timeout).toHaveValue("9007199254740992"); - await timeout.fill("5m"); - await expect(button("Save workflow")).toBeEnabled(); - await button("Save workflow").click(); - await expect - .poll(() => page.evaluate(() => window.workflowFixture.calls.save)) - .toBe(1); - expect( - parseYaml(await page.evaluate(() => window.workflowFixture.input().yaml)) - .steps[0].timeout_secs, - ).toBe(300); - await page.evaluate(() => window.workflowFixture.finish("succeeded")); - await expect(button("Save workflow")).toBeEnabled(); - if (!(await timeout.isVisible())) - await page.getByText("Step options", { exact: true }).click(); - await timeout.fill(" "); - await button("Save workflow").click(); - await expect - .poll(() => page.evaluate(() => window.workflowFixture.calls.save)) - .toBe(2); - expect( - parseYaml(await page.evaluate(() => window.workflowFixture.input().yaml)) - .steps[0], - ).not.toHaveProperty("timeout_secs"); -}); - -test("both Add actions allocate unused IDs after a very large parsed ID", async ({ - page, -}) => { - await page.goto(url); - const button = (name) => page.getByRole("button", { name, exact: true }); - await button("Message helper").click(); - await page.getByRole("tab", { name: "YAML", exact: true }).click(); - const yaml = page.getByLabel("Workflow YAML", { exact: true }); - const definition = parseYaml(await yaml.inputValue()); - definition.steps[0].id = "step_9007199254740992"; - // JSON is YAML, and avoids testing a second serializer in this browser fixture. - await yaml.fill(JSON.stringify(definition)); - await page.getByRole("tab", { name: "Form", exact: true }).click(); - await button("Add Send Message").click(); - await page - .getByLabel("Message text", { exact: true }) - .nth(1) - .fill("Another message"); - await button("Add Delay").click(); - await page.getByRole("tab", { name: "YAML", exact: true }).click(); - expect( - parseYaml(await yaml.inputValue()).steps.map((step) => step.id), - ).toEqual(["step_9007199254740992", "step_1", "step_2"]); -}); - test("real session reconnect retains unsaved YAML and an in-flight returned run ID", async ({ page, }) => {