From 95be452b6c90dac51dab02f0d852fa4f432aa625 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Wed, 16 Sep 2026 11:34:17 -0400 Subject: [PATCH 1/3] Migrate policy browser cases to owner tests Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a149-b278-7550-acc4-356168b0c4b5 Co-authored-by: Amp --- tests/browser/live-status.spec.mjs | 147 +------------------------- tests/browser/notifications.spec.mjs | 128 ---------------------- tests/browser/sidebar-unread.spec.mjs | 54 ---------- tests/browser/unread.spec.mjs | 70 ------------ 4 files changed, 1 insertion(+), 398 deletions(-) diff --git a/tests/browser/live-status.spec.mjs b/tests/browser/live-status.spec.mjs index 76b43b8e..41d1629e 100644 --- a/tests/browser/live-status.spec.mjs +++ b/tests/browser/live-status.spec.mjs @@ -1,147 +1,2 @@ -import { test, expect } from "./fixture.mjs"; +import { test } from "./fixture.mjs"; test.use({ productionBroker: true }); -test("clean pending setup stays in diagnostics and never flashes a warning during channel switches", async ({ - page, - app, -}) => { - app.relay.holdEose("beta"); - await page.goto(app.origin); - await page.evaluate(() => { - window.__bannerSeen = []; - window.__bannerObserver = new MutationObserver(() => { - for (const status of document.querySelectorAll('[role="status"]')) { - if (status.textContent.includes("Retained messages remain readable.")) - window.__bannerSeen.push(status.textContent); - } - }); - window.__bannerObserver.observe(document.body, { - subtree: true, - childList: true, - characterData: true, - }); - }); - - await page - .getByRole("button", { name: "Messages", exact: true }) - .first() - .click(); - await expect( - page.getByRole("textbox", { name: "Message #Alpha", exact: true }), - ).toBeVisible(); - const warning = page - .getByRole("status") - .filter({ hasText: "Retained messages remain readable." }); - await expect.poll(() => app.relay.hasRoute("primary", "beta")).toBe(true); - await expect(warning).toHaveCount(0); - const streams = () => - app.report.brokerRequests.filter((r) => r.url.endsWith("/stream")).length; - const before = streams(); - await page.getByRole("button", { name: "Beta", exact: true }).click(); - await expect( - page.getByRole("textbox", { name: "Message #Beta", exact: true }), - ).toBeVisible(); - await expect(warning).toHaveCount(0); - await page.getByLabel("Conversation options", { exact: true }).click(); - await page.getByText("Diagnostics", { exact: true }).click(); - await expect( - page.getByText("Live updates: connecting", { exact: true }), - ).toBeVisible(); - await page.getByLabel("Conversation options", { exact: true }).click(); - expect(app.relay.rejected).toHaveLength(0); - expect(app.report.wireFrames.filter((f) => f[0] === "CLOSED")).toHaveLength( - 0, - ); - expect(streams()).toBe(before); - app.relay.releaseEose("beta"); - await expect(warning).toHaveCount(0); - for (const channel of ["Alpha", "Beta", "Alpha", "Beta"]) { - await page.getByRole("button", { name: channel, exact: true }).click(); - await expect( - page.getByRole("textbox", { name: `Message #${channel}`, exact: true }), - ).toBeVisible(); - } - const warm = await page.evaluate(() => { - window.__bannerObserver.disconnect(); - return window.__bannerSeen; - }); - expect(warm).toEqual([]); - expect(streams()).toBe(before); - app.report.measurements.push({ - rejected: app.relay.rejected, - warmSwitchWarnings: warm, - streamsBefore: before, - streamsAfter: streams(), - }); -}); - -for (const target of ["alpha", "profiles"]) { - test(`quota recovery for ${target} stays quiet until attempts exhaust, and manual recovery waits for EOSE`, async ({ - page, - app, - }) => { - await page.goto(app.origin); - await page - .getByRole("button", { name: "Messages", exact: true }) - .first() - .click(); - await expect( - page.getByRole("textbox", { name: "Message #Alpha", exact: true }), - ).toBeVisible(); - await expect.poll(() => app.relay.hasRoute("primary", target)).toBe(true); - const warning = page - .getByRole("status") - .filter({ hasText: "Retained messages remain readable." }); - await expect(warning).toHaveCount(0); - await page.getByLabel("Conversation options", { exact: true }).click(); - await page.getByText("Diagnostics", { exact: true }).click(); - const recovery = page.getByText( - "Live updates: recovering automatically after rate limiting; awaiting confirmation", - { exact: true }, - ); - const rejected = "rate-limited: quota exceeded; retry in 0s"; - app.relay.holdEose(target); - const requests = () => - app.relay.requests.filter( - (r) => r.community === "primary" && r.route === target, - ); - const streams = () => - app.report.brokerRequests.filter((r) => r.url.endsWith("/stream")).length; - const streamCount = streams(); - const sockets = app.relay.sockets.length; - for (let attempt = 0; attempt < 4; attempt++) { - const before = requests().length; - app.relay.failRoute("primary", target, rejected); - if (attempt < 3) { - await expect(recovery).toBeVisible(); - await expect(warning).toHaveCount(0); - await expect.poll(() => requests().length).toBeGreaterThan(before); - // The new REQ exists, but fixture holds EOSE: no false healthy status. - await expect(recovery).toBeVisible(); - await expect(warning).toHaveCount(0); - } else { - await expect(warning).toBeVisible(); - await expect(warning).toContainText("recovery needs attention"); - await expect(warning).not.toContainText("retry in 0s"); - } - } - const beforeManual = requests().length; - await page.getByLabel("Conversation options", { exact: true }).click(); - await page - .getByRole("button", { name: "Retry live updates", exact: true }) - .click(); - await page.getByLabel("Conversation options", { exact: true }).click(); - // The inner Diagnostics details retains its open state when its parent closes. - await expect(recovery).toBeVisible(); - await expect(warning).toHaveCount(0); - await expect.poll(() => requests().length).toBeGreaterThan(beforeManual); - await expect(recovery).toBeVisible(); - app.relay.releaseEose(target); - await expect(recovery).toHaveCount(0); - await expect( - page.getByText("Live updates: stream established", { exact: true }), - ).toBeVisible(); - await expect(warning).toHaveCount(0); - expect(streams()).toBe(streamCount); - expect(app.relay.sockets).toHaveLength(sockets); - }); -} diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs index 323f83f8..3a36a1e7 100644 --- a/tests/browser/notifications.spec.mjs +++ b/tests/browser/notifications.spec.mjs @@ -109,67 +109,6 @@ async function observed(page, id) { await page.waitForTimeout(150); } -test("real live traffic alerts once; replay/reload stay quiet and choices persist", async ({ - page, - app, -}) => { - await ready(page, app); - expect(await systemCount(page)).toBe(0); - const row = liveMessage(app, "Fresh mention"); - await expect.poll(() => systemCount(page)).toBe(1); - app.relay.publish("primary", row); - await observed(page, row.id); - expect(await systemCount(page)).toBe(1); - await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck(); - const muted = liveMessage(app, "Muted mention"); - await observed(page, muted.id); - expect(await systemCount(page)).toBe(1); - await page.reload(); - await settings(page); - await expect( - page.getByRole("switch", { name: "Mentions", exact: true }), - ).not.toBeChecked(); - expect(await systemCount(page)).toBe(0); - expect(await page.evaluate(() => window.notificationRequests)).toBe(0); - await expect( - page.getByRole("heading", { name: "Recent notifications" }), - ).toHaveCount(0); -}); - -test("explicit Allow releases the first fresh alert; master off preserves categories", async ({ - page, - app, -}) => { - await ready(page, app); - await page.evaluate(() => { - window.Notification.permission = "default"; - }); - await page - .getByRole("button", { name: "Check permission", exact: true }) - .click(); - const row = liveMessage(app, "Permission wait"); - await observed(page, row.id); - expect(await systemCount(page)).toBe(0); - expect(await page.evaluate(() => window.notificationRequests)).toBe(0); - await page - .getByRole("button", { name: "Allow notifications", exact: true }) - .click(); - await expect.poll(() => systemCount(page)).toBe(1); - await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck(); - await page - .getByRole("switch", { name: "Desktop alerts", exact: true }) - .uncheck(); - await page - .getByRole("switch", { name: "Desktop alerts", exact: true }) - .check(); - await expect( - page.getByRole("switch", { name: "Mentions", exact: true }), - ).not.toBeChecked(); - const muted = liveMessage(app, "Disabled category"); - await observed(page, muted.id); - expect(await systemCount(page)).toBe(1); -}); - test("a fully visible incoming row stays quiet without publishing read intent", async ({ page, app, @@ -345,70 +284,3 @@ for (const kind of ["mention", "thread reply"]) { .toBe(false); }); } - -test("an installed producer shares policy and OS click navigation, including after producer disable", async ({ - page, - app, -}) => { - await ready(page, app); - const input = { - sourceKey: "plugin-event", - target: { version: 1, kind: "settings", section: "appearance" }, - }; - expect( - await page.evaluate((input) => window.fixtureNotify(input), input), - ).toBe(true); - await expect.poll(() => systemCount(page)).toBe(1); - await page.evaluate(() => - window.fixtureNavigation.open({ - version: 1, - kind: "settings", - section: "plugins", - }), - ); - await page - .getByRole("switch", { name: "Enable Notification fixture", exact: true }) - .click(); - expect( - await page.evaluate((input) => window.fixtureNotify(input), { - ...input, - sourceKey: "stale", - }), - ).toBe(false); - await page.evaluate(() => window.notificationEvents[0].onclick()); - await expect( - page.getByRole("heading", { name: "Appearance", exact: true }), - ).toBeVisible(); - expect( - await page.evaluate(() => window.fixtureNavigation.snapshot().status), - ).toBe("opened"); -}); - -test("asynchronous browser display failure reaches Settings once without redelivery", async ({ - page, - app, -}) => { - await ready(page, app); - liveMessage(app, "Browser display error"); - await expect.poll(() => systemCount(page)).toBe(1); - await page.evaluate(() => window.notificationEvents[0].onerror?.()); - await expect(page.getByRole("alert")).toHaveText( - "The browser could not display a notification.", - ); - expect( - await page.evaluate(() => { - const item = window.notificationEvents[0]; - return { - closed: item.closed, - click: item.onclick, - error: item.onerror, - close: item.onclose, - }; - }), - ).toEqual({ closed: true, click: null, error: null, close: null }); - await page - .getByRole("button", { name: "Check permission", exact: true }) - .click(); - await page.waitForTimeout(150); - expect(await systemCount(page)).toBe(1); -}); diff --git a/tests/browser/sidebar-unread.spec.mjs b/tests/browser/sidebar-unread.spec.mjs index e53cc60f..9be40985 100644 --- a/tests/browser/sidebar-unread.spec.mjs +++ b/tests/browser/sidebar-unread.spec.mjs @@ -34,60 +34,6 @@ const heads = (app) => ({ filter }) => filter.kinds?.includes(9) && filter["#h"]?.length === 1, ); -test("channel establishment preserves an in-flight unread batch and its sidebar badges", async ({ - page, - app, -}) => { - const alphaHeads = () => - app.report.queries.filter( - ({ community, filter }) => - community === "primary" && - filter["#h"]?.[0] === "alpha" && - filter.top_level === true && - filter.until === undefined, - ); - const evidence = () => - app.report.queries.filter( - ({ community, filter }) => - community === "primary" && - filter.kinds?.includes(9) && - filter.top_level === undefined && - filter.depth_limit === undefined && - filter.until === undefined, - ); - app.relay.holdEose("alpha"); - app.relay.holdUnread(); - try { - await open(page, app); - await expect.poll(() => app.report.unreadHolds.length).toBe(1); - expect(evidence()[0].filter["#h"]).toContain("alpha"); - expect(evidence()[0].filter["#h"]).toContain("dm-090"); - expect(alphaHeads()).toHaveLength(1); - await expect(row(page, "dm-090").getByRole("img")).toHaveCount(0); - - // Establish only after unread is in flight; observe the real finite catch-up - // before releasing evidence. No sleep or scheduler luck creates the overlap. - app.relay.releaseEose("alpha"); - await expect.poll(() => alphaHeads().length).toBe(2); - expect(app.report.unreadHolds).toEqual([{ pending: true, aborted: false }]); - app.relay.releaseUnread(); - await expect(row(page, "dm-030").getByRole("img")).toHaveCount(1); - await expect(row(page, "dm-090").getByRole("img")).toHaveCount(1); - await expect(cue(page, "below")).toBeVisible(); - await expect.poll(() => evidence().length).toBe(2); // 130 IDs, not retries. - expect(evidence().map(({ filter }) => filter["#h"].length)).toEqual([ - 128, 2, - ]); - expect(app.report.unreadHolds).toEqual([ - { pending: false, aborted: false }, - ]); - expect(app.report.readPublications).toEqual([]); - } finally { - app.relay.releaseEose("alpha"); - app.relay.releaseUnread(); - } -}); - test("edge pills follow scroll and reveal the nearest unread without selection or reads; focus retains existing preparation", async ({ page, app, diff --git a/tests/browser/unread.spec.mjs b/tests/browser/unread.spec.mjs index 5336c90a..e6b63e9a 100644 --- a/tests/browser/unread.spec.mjs +++ b/tests/browser/unread.spec.mjs @@ -218,73 +218,3 @@ test("a surviving window publishes a closed window's durable read intent", async await survivor.close(); } }); - -test.describe("explicit mark-through with membership activity", () => { - test.use({ membershipActivity: true }); - - for (const activityOnly of [false, true]) { - test( - activityOnly - ? "activity-only history explains the missing message without clearing manual unread" - : "chat followed by membership activity clears manual unread through the newest chat", - async ({ page, app }) => { - // Model only upstream signed history; the app must load and verify it. - const loaded = app.histories.get("primary/alpha").slice(-4); - app.histories.set( - "primary/alpha", - activityOnly - ? loaded.filter((event) => event.kind === 40099) - : loaded, - ); - const lastChat = loaded.findLast((event) => event.kind === 9); - await open(page, app); - await composer(page).focus(); - await expect( - history(page).locator("[data-membership-row]"), - ).toHaveCount(1); - if (!activityOnly) { - await expect(alpha(page).getByRole("img")).toHaveAttribute( - "aria-label", - /^2 observed unread messages/, - ); - } - await options(page); - await page - .getByRole("button", { - name: "Mark unread on this device", - exact: true, - }) - .click(); - await expect(alpha(page).getByRole("img")).toHaveAttribute( - "aria-label", - "Marked unread on this device only", - ); - const before = await journal(page); - await page - .getByRole("button", { - name: "Mark read through loaded messages", - exact: true, - }) - .click(); - if (activityOnly) { - await expect(page.getByRole("alert")).toHaveText( - "Load a verified message before marking through it.", - ); - const after = await journal(page); - expect(after.localUnread.alpha).toBe(before.localUnread.alpha); - expect(after.state.frontiers).toEqual(before.state.frontiers); - expect(after.revision).toBe(before.revision); - } else { - await expect - .poll(async () => (await journal(page)).state.frontiers.alpha) - .toBe(lastChat.created_at); - expect((await journal(page)).localUnread.alpha).toBeUndefined(); - await expect(alpha(page).getByRole("img")).toHaveCount(0); - await expect(page.getByRole("alert")).toHaveCount(0); - } - // Do not let the shared fixture's legacy WebKit exception mask this path. - expect(app.report.errors).toEqual([]); - }, - ); - } -}); From eca162207949367a5ee98238f7ebcd487f01be1f Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Wed, 16 Sep 2026 12:05:37 -0400 Subject: [PATCH 2/3] Add mounted workflow journey coverage Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a149-b278-7550-acc4-356168b0c4b5 Co-authored-by: Amp --- .../workflows/WorkflowChannel.test.tsx | 308 +++++++++++++++++ src/bundled/workflows/workflows.journey.mjs | 321 ------------------ tests/browser/live-status.spec.mjs | 147 +++++++- tests/browser/notifications.spec.mjs | 128 +++++++ tests/browser/sidebar-unread.spec.mjs | 54 +++ tests/browser/unread.spec.mjs | 70 ++++ 6 files changed, 706 insertions(+), 322 deletions(-) create mode 100644 src/bundled/workflows/WorkflowChannel.test.tsx diff --git a/src/bundled/workflows/WorkflowChannel.test.tsx b/src/bundled/workflows/WorkflowChannel.test.tsx new file mode 100644 index 00000000..d92ddccf --- /dev/null +++ b/src/bundled/workflows/WorkflowChannel.test.tsx @@ -0,0 +1,308 @@ +// @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?", + }); + 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); + 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.getAttribute("value") ?? (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(timeout).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"); + await user.click(button("Save workflow")); + expect(parseYaml(fixture.input()?.yaml ?? "").steps[0].timeout_secs).toBe( + 300, + ); + act(() => fixture.finish("succeeded")); + 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(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, }) => { diff --git a/tests/browser/live-status.spec.mjs b/tests/browser/live-status.spec.mjs index 41d1629e..76b43b8e 100644 --- a/tests/browser/live-status.spec.mjs +++ b/tests/browser/live-status.spec.mjs @@ -1,2 +1,147 @@ -import { test } from "./fixture.mjs"; +import { test, expect } from "./fixture.mjs"; test.use({ productionBroker: true }); +test("clean pending setup stays in diagnostics and never flashes a warning during channel switches", async ({ + page, + app, +}) => { + app.relay.holdEose("beta"); + await page.goto(app.origin); + await page.evaluate(() => { + window.__bannerSeen = []; + window.__bannerObserver = new MutationObserver(() => { + for (const status of document.querySelectorAll('[role="status"]')) { + if (status.textContent.includes("Retained messages remain readable.")) + window.__bannerSeen.push(status.textContent); + } + }); + window.__bannerObserver.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + }); + }); + + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + const warning = page + .getByRole("status") + .filter({ hasText: "Retained messages remain readable." }); + await expect.poll(() => app.relay.hasRoute("primary", "beta")).toBe(true); + await expect(warning).toHaveCount(0); + const streams = () => + app.report.brokerRequests.filter((r) => r.url.endsWith("/stream")).length; + const before = streams(); + await page.getByRole("button", { name: "Beta", exact: true }).click(); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toBeVisible(); + await expect(warning).toHaveCount(0); + await page.getByLabel("Conversation options", { exact: true }).click(); + await page.getByText("Diagnostics", { exact: true }).click(); + await expect( + page.getByText("Live updates: connecting", { exact: true }), + ).toBeVisible(); + await page.getByLabel("Conversation options", { exact: true }).click(); + expect(app.relay.rejected).toHaveLength(0); + expect(app.report.wireFrames.filter((f) => f[0] === "CLOSED")).toHaveLength( + 0, + ); + expect(streams()).toBe(before); + app.relay.releaseEose("beta"); + await expect(warning).toHaveCount(0); + for (const channel of ["Alpha", "Beta", "Alpha", "Beta"]) { + await page.getByRole("button", { name: channel, exact: true }).click(); + await expect( + page.getByRole("textbox", { name: `Message #${channel}`, exact: true }), + ).toBeVisible(); + } + const warm = await page.evaluate(() => { + window.__bannerObserver.disconnect(); + return window.__bannerSeen; + }); + expect(warm).toEqual([]); + expect(streams()).toBe(before); + app.report.measurements.push({ + rejected: app.relay.rejected, + warmSwitchWarnings: warm, + streamsBefore: before, + streamsAfter: streams(), + }); +}); + +for (const target of ["alpha", "profiles"]) { + test(`quota recovery for ${target} stays quiet until attempts exhaust, and manual recovery waits for EOSE`, async ({ + page, + app, + }) => { + await page.goto(app.origin); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + await expect.poll(() => app.relay.hasRoute("primary", target)).toBe(true); + const warning = page + .getByRole("status") + .filter({ hasText: "Retained messages remain readable." }); + await expect(warning).toHaveCount(0); + await page.getByLabel("Conversation options", { exact: true }).click(); + await page.getByText("Diagnostics", { exact: true }).click(); + const recovery = page.getByText( + "Live updates: recovering automatically after rate limiting; awaiting confirmation", + { exact: true }, + ); + const rejected = "rate-limited: quota exceeded; retry in 0s"; + app.relay.holdEose(target); + const requests = () => + app.relay.requests.filter( + (r) => r.community === "primary" && r.route === target, + ); + const streams = () => + app.report.brokerRequests.filter((r) => r.url.endsWith("/stream")).length; + const streamCount = streams(); + const sockets = app.relay.sockets.length; + for (let attempt = 0; attempt < 4; attempt++) { + const before = requests().length; + app.relay.failRoute("primary", target, rejected); + if (attempt < 3) { + await expect(recovery).toBeVisible(); + await expect(warning).toHaveCount(0); + await expect.poll(() => requests().length).toBeGreaterThan(before); + // The new REQ exists, but fixture holds EOSE: no false healthy status. + await expect(recovery).toBeVisible(); + await expect(warning).toHaveCount(0); + } else { + await expect(warning).toBeVisible(); + await expect(warning).toContainText("recovery needs attention"); + await expect(warning).not.toContainText("retry in 0s"); + } + } + const beforeManual = requests().length; + await page.getByLabel("Conversation options", { exact: true }).click(); + await page + .getByRole("button", { name: "Retry live updates", exact: true }) + .click(); + await page.getByLabel("Conversation options", { exact: true }).click(); + // The inner Diagnostics details retains its open state when its parent closes. + await expect(recovery).toBeVisible(); + await expect(warning).toHaveCount(0); + await expect.poll(() => requests().length).toBeGreaterThan(beforeManual); + await expect(recovery).toBeVisible(); + app.relay.releaseEose(target); + await expect(recovery).toHaveCount(0); + await expect( + page.getByText("Live updates: stream established", { exact: true }), + ).toBeVisible(); + await expect(warning).toHaveCount(0); + expect(streams()).toBe(streamCount); + expect(app.relay.sockets).toHaveLength(sockets); + }); +} diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs index 3a36a1e7..323f83f8 100644 --- a/tests/browser/notifications.spec.mjs +++ b/tests/browser/notifications.spec.mjs @@ -109,6 +109,67 @@ async function observed(page, id) { await page.waitForTimeout(150); } +test("real live traffic alerts once; replay/reload stay quiet and choices persist", async ({ + page, + app, +}) => { + await ready(page, app); + expect(await systemCount(page)).toBe(0); + const row = liveMessage(app, "Fresh mention"); + await expect.poll(() => systemCount(page)).toBe(1); + app.relay.publish("primary", row); + await observed(page, row.id); + expect(await systemCount(page)).toBe(1); + await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck(); + const muted = liveMessage(app, "Muted mention"); + await observed(page, muted.id); + expect(await systemCount(page)).toBe(1); + await page.reload(); + await settings(page); + await expect( + page.getByRole("switch", { name: "Mentions", exact: true }), + ).not.toBeChecked(); + expect(await systemCount(page)).toBe(0); + expect(await page.evaluate(() => window.notificationRequests)).toBe(0); + await expect( + page.getByRole("heading", { name: "Recent notifications" }), + ).toHaveCount(0); +}); + +test("explicit Allow releases the first fresh alert; master off preserves categories", async ({ + page, + app, +}) => { + await ready(page, app); + await page.evaluate(() => { + window.Notification.permission = "default"; + }); + await page + .getByRole("button", { name: "Check permission", exact: true }) + .click(); + const row = liveMessage(app, "Permission wait"); + await observed(page, row.id); + expect(await systemCount(page)).toBe(0); + expect(await page.evaluate(() => window.notificationRequests)).toBe(0); + await page + .getByRole("button", { name: "Allow notifications", exact: true }) + .click(); + await expect.poll(() => systemCount(page)).toBe(1); + await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck(); + await page + .getByRole("switch", { name: "Desktop alerts", exact: true }) + .uncheck(); + await page + .getByRole("switch", { name: "Desktop alerts", exact: true }) + .check(); + await expect( + page.getByRole("switch", { name: "Mentions", exact: true }), + ).not.toBeChecked(); + const muted = liveMessage(app, "Disabled category"); + await observed(page, muted.id); + expect(await systemCount(page)).toBe(1); +}); + test("a fully visible incoming row stays quiet without publishing read intent", async ({ page, app, @@ -284,3 +345,70 @@ for (const kind of ["mention", "thread reply"]) { .toBe(false); }); } + +test("an installed producer shares policy and OS click navigation, including after producer disable", async ({ + page, + app, +}) => { + await ready(page, app); + const input = { + sourceKey: "plugin-event", + target: { version: 1, kind: "settings", section: "appearance" }, + }; + expect( + await page.evaluate((input) => window.fixtureNotify(input), input), + ).toBe(true); + await expect.poll(() => systemCount(page)).toBe(1); + await page.evaluate(() => + window.fixtureNavigation.open({ + version: 1, + kind: "settings", + section: "plugins", + }), + ); + await page + .getByRole("switch", { name: "Enable Notification fixture", exact: true }) + .click(); + expect( + await page.evaluate((input) => window.fixtureNotify(input), { + ...input, + sourceKey: "stale", + }), + ).toBe(false); + await page.evaluate(() => window.notificationEvents[0].onclick()); + await expect( + page.getByRole("heading", { name: "Appearance", exact: true }), + ).toBeVisible(); + expect( + await page.evaluate(() => window.fixtureNavigation.snapshot().status), + ).toBe("opened"); +}); + +test("asynchronous browser display failure reaches Settings once without redelivery", async ({ + page, + app, +}) => { + await ready(page, app); + liveMessage(app, "Browser display error"); + await expect.poll(() => systemCount(page)).toBe(1); + await page.evaluate(() => window.notificationEvents[0].onerror?.()); + await expect(page.getByRole("alert")).toHaveText( + "The browser could not display a notification.", + ); + expect( + await page.evaluate(() => { + const item = window.notificationEvents[0]; + return { + closed: item.closed, + click: item.onclick, + error: item.onerror, + close: item.onclose, + }; + }), + ).toEqual({ closed: true, click: null, error: null, close: null }); + await page + .getByRole("button", { name: "Check permission", exact: true }) + .click(); + await page.waitForTimeout(150); + expect(await systemCount(page)).toBe(1); +}); diff --git a/tests/browser/sidebar-unread.spec.mjs b/tests/browser/sidebar-unread.spec.mjs index 9be40985..e53cc60f 100644 --- a/tests/browser/sidebar-unread.spec.mjs +++ b/tests/browser/sidebar-unread.spec.mjs @@ -34,6 +34,60 @@ const heads = (app) => ({ filter }) => filter.kinds?.includes(9) && filter["#h"]?.length === 1, ); +test("channel establishment preserves an in-flight unread batch and its sidebar badges", async ({ + page, + app, +}) => { + const alphaHeads = () => + app.report.queries.filter( + ({ community, filter }) => + community === "primary" && + filter["#h"]?.[0] === "alpha" && + filter.top_level === true && + filter.until === undefined, + ); + const evidence = () => + app.report.queries.filter( + ({ community, filter }) => + community === "primary" && + filter.kinds?.includes(9) && + filter.top_level === undefined && + filter.depth_limit === undefined && + filter.until === undefined, + ); + app.relay.holdEose("alpha"); + app.relay.holdUnread(); + try { + await open(page, app); + await expect.poll(() => app.report.unreadHolds.length).toBe(1); + expect(evidence()[0].filter["#h"]).toContain("alpha"); + expect(evidence()[0].filter["#h"]).toContain("dm-090"); + expect(alphaHeads()).toHaveLength(1); + await expect(row(page, "dm-090").getByRole("img")).toHaveCount(0); + + // Establish only after unread is in flight; observe the real finite catch-up + // before releasing evidence. No sleep or scheduler luck creates the overlap. + app.relay.releaseEose("alpha"); + await expect.poll(() => alphaHeads().length).toBe(2); + expect(app.report.unreadHolds).toEqual([{ pending: true, aborted: false }]); + app.relay.releaseUnread(); + await expect(row(page, "dm-030").getByRole("img")).toHaveCount(1); + await expect(row(page, "dm-090").getByRole("img")).toHaveCount(1); + await expect(cue(page, "below")).toBeVisible(); + await expect.poll(() => evidence().length).toBe(2); // 130 IDs, not retries. + expect(evidence().map(({ filter }) => filter["#h"].length)).toEqual([ + 128, 2, + ]); + expect(app.report.unreadHolds).toEqual([ + { pending: false, aborted: false }, + ]); + expect(app.report.readPublications).toEqual([]); + } finally { + app.relay.releaseEose("alpha"); + app.relay.releaseUnread(); + } +}); + test("edge pills follow scroll and reveal the nearest unread without selection or reads; focus retains existing preparation", async ({ page, app, diff --git a/tests/browser/unread.spec.mjs b/tests/browser/unread.spec.mjs index e6b63e9a..5336c90a 100644 --- a/tests/browser/unread.spec.mjs +++ b/tests/browser/unread.spec.mjs @@ -218,3 +218,73 @@ test("a surviving window publishes a closed window's durable read intent", async await survivor.close(); } }); + +test.describe("explicit mark-through with membership activity", () => { + test.use({ membershipActivity: true }); + + for (const activityOnly of [false, true]) { + test( + activityOnly + ? "activity-only history explains the missing message without clearing manual unread" + : "chat followed by membership activity clears manual unread through the newest chat", + async ({ page, app }) => { + // Model only upstream signed history; the app must load and verify it. + const loaded = app.histories.get("primary/alpha").slice(-4); + app.histories.set( + "primary/alpha", + activityOnly + ? loaded.filter((event) => event.kind === 40099) + : loaded, + ); + const lastChat = loaded.findLast((event) => event.kind === 9); + await open(page, app); + await composer(page).focus(); + await expect( + history(page).locator("[data-membership-row]"), + ).toHaveCount(1); + if (!activityOnly) { + await expect(alpha(page).getByRole("img")).toHaveAttribute( + "aria-label", + /^2 observed unread messages/, + ); + } + await options(page); + await page + .getByRole("button", { + name: "Mark unread on this device", + exact: true, + }) + .click(); + await expect(alpha(page).getByRole("img")).toHaveAttribute( + "aria-label", + "Marked unread on this device only", + ); + const before = await journal(page); + await page + .getByRole("button", { + name: "Mark read through loaded messages", + exact: true, + }) + .click(); + if (activityOnly) { + await expect(page.getByRole("alert")).toHaveText( + "Load a verified message before marking through it.", + ); + const after = await journal(page); + expect(after.localUnread.alpha).toBe(before.localUnread.alpha); + expect(after.state.frontiers).toEqual(before.state.frontiers); + expect(after.revision).toBe(before.revision); + } else { + await expect + .poll(async () => (await journal(page)).state.frontiers.alpha) + .toBe(lastChat.created_at); + expect((await journal(page)).localUnread.alpha).toBeUndefined(); + await expect(alpha(page).getByRole("img")).toHaveCount(0); + await expect(page.getByRole("alert")).toHaveCount(0); + } + // Do not let the shared fixture's legacy WebKit exception mask this path. + expect(app.report.errors).toEqual([]); + }, + ); + } +}); From 78d382817ed26dde1e9cbccce02c14a8c8d6dfb0 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Wed, 16 Sep 2026 13:04:58 -0400 Subject: [PATCH 3/3] Preserve mounted workflow assertions across mode changes Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a149-b278-7550-acc4-356168b0c4b5 Co-authored-by: Amp --- .../workflows/WorkflowChannel.test.tsx | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/src/bundled/workflows/WorkflowChannel.test.tsx b/src/bundled/workflows/WorkflowChannel.test.tsx index d92ddccf..a2a44b14 100644 --- a/src/bundled/workflows/WorkflowChannel.test.tsx +++ b/src/bundled/workflows/WorkflowChannel.test.tsx @@ -158,29 +158,32 @@ it("keeps optimistic dismissal modal and ownership locked until settlement", asy const dialog = screen.getByRole("alertdialog", { name: "Dismiss this notice?", }); - 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); - await act(async () => fixture.releaseDismiss()); + 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, @@ -241,14 +244,14 @@ it("retains invalid timeout boundaries in both modes and saves exact valid YAML" await user.click(screen.getByRole("tab", { name: "YAML" })); const yaml = screen.getByRole("textbox", { name: "Workflow YAML" }); expect( - parseYaml( - yaml.getAttribute("value") ?? (yaml as HTMLTextAreaElement).value, - ).steps[0].timeout_secs, + 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(timeout).toHaveValue(input); + expect( + screen.getByRole("textbox", { name: "Step timeout (optional)" }), + ).toHaveValue(input); } await user.click(button("Close editor")); expect( @@ -261,11 +264,14 @@ it("retains invalid timeout boundaries in both modes and saves exact valid YAML" 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", { @@ -274,6 +280,7 @@ it("retains invalid timeout boundaries in both modes and saves exact valid YAML" 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", );