diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index 9822243d5fe..486ffee47fd 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -1415,20 +1415,52 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { }) .await; - // Now send cancel and release the round-2 gate. Cancel is enqueued before - // round 2 can respond, so the turn exits with stopReason: cancelled. - let c_id = h.send("session/cancel", json!({"sessionId": sid})).await; - let _ = gate_tx.send(()); // unblock round 2 - + // Now send cancel and wait for its acknowledgement *before* releasing the + // round-2 gate. The agent processes session/cancel in the sequential + // read_loop while round-2's HTTP request is still blocked, so it can write + // the cancel ACK to stdout before round-2 ever responds. Releasing the + // gate only after the ACK guarantees the cancel token is set before the + // turn sees round-2's response, making stopReason: "cancelled" deterministic. let mut saw_usage_before_prompt_response = false; let mut saw_usage = false; - let mut saw_cancel_ok = false; let mut saw_prompt_response = false; - for _ in 0..40 { + let c_id = h.send("session/cancel", json!({"sessionId": sid})).await; + + // Drain stdout until we see the cancel ACK; buffer other messages so they + // can be processed in the main loop below. + let mut buffered: Vec = Vec::new(); + loop { let v = h.recv().await; if v["id"] == json!(c_id) { - saw_cancel_ok = true; - } else if is_usage_update(&v) { + break; // cancel ACK received — cancel token is now set + } + buffered.push(v); + } + let saw_cancel_ok = true; // loop only exits via break after the ACK + let _ = gate_tx.send(()); // safe: cancel is fully processed and ACKed + + // Replay buffered messages through the same classification logic. + for v in buffered { + if is_usage_update(&v) { + saw_usage = true; + if !saw_prompt_response { + saw_usage_before_prompt_response = true; + } + } else if v["id"] == json!(p_id) { + saw_prompt_response = true; + assert_eq!( + v["result"]["stopReason"], "cancelled", + "turn must end with stopReason: cancelled" + ); + } + } + + for _ in 0..40 { + if saw_usage && saw_prompt_response { + break; + } + let v = h.recv().await; + if is_usage_update(&v) { saw_usage = true; if !saw_prompt_response { saw_usage_before_prompt_response = true; @@ -1441,7 +1473,7 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { "turn must end with stopReason: cancelled" ); } - if saw_usage && saw_prompt_response && saw_cancel_ok { + if saw_usage && saw_prompt_response { break; } } diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css index 2c858474a47..a4de8f85bff 100644 --- a/desktop/src/shared/styles/globals/utilities.css +++ b/desktop/src/shared/styles/globals/utilities.css @@ -115,6 +115,7 @@ * progressive enhancement. */ .avatar-sdr-clamp { + /* biome-ignore lint/correctness/noUnknownProperty: emerging CSS property (Chromium 133+, WebKit/Safari 26+); intentional progressive enhancement */ dynamic-range-limit: standard; } } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6ddc1111b03..7c14d9593fe 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -94,6 +94,7 @@ import { isValidLinkPreviewSnapshotCanonicalUrl, parseLinkPreviewSnapshots, } from "@/shared/lib/linkPreviewSnapshot"; +import { expireUserStatusQueries } from "@/features/user-status/hooks"; type TestIdentity = { privateKey: string; @@ -1280,6 +1281,12 @@ declare global { expiresAt?: number; createdAt?: number; }) => RelayEvent; + /** + * Force-expire all tracked user-status entries by running the expiry sweep + * with a future timestamp. Eliminates the wall-clock wait in tests that + * verify UI behaviour after a status expires. + */ + __BUZZ_E2E_EXPIRE_USER_STATUS_QUERIES__?: () => void; /** Explicit presence evidence; independent of managed-agent runtime state. */ __BUZZ_E2E_EMIT_MOCK_PRESENCE__?: (input: { pubkey: string; @@ -11562,6 +11569,15 @@ export function maybeInstallE2eTauriMocks() { return event; }; window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory; + window.__BUZZ_E2E_EXPIRE_USER_STATUS_QUERIES__ = () => { + const qc = (window as { __BUZZ_E2E_QUERY_CLIENT__?: QueryClient }) + .__BUZZ_E2E_QUERY_CLIENT__; + if (qc) { + // Sweep with a far-future nowSeconds so every entry with an expiresAt is + // considered expired — deterministically removes wall-clock dependency. + expireUserStatusQueries(qc, Math.floor(Date.now() / 1_000) + 86_400); + } + }; window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, createdAt, diff --git a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts index 09060ec39ee..272c7a68bf8 100644 --- a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts +++ b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts @@ -207,9 +207,15 @@ test("keeps an open status draft when the saved status expires", async ({ await page.getByTestId("profile-popover-set-status").click(); const dialog = page.getByTestId("set-status-dialog"); await dialog.getByTestId("set-status-input").fill("Unsaved draft"); - await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0, { - timeout: 5_000, + // Advance Date.now() past the 2-second expiresAt, then sweep the query cache. + // The sweep triggers a re-render of the dialog (via its parent's status props), + // which re-evaluates expirationIsFuture with the new Date.now() value — fully + // deterministic; no wall-clock wait on the 2-second timer. + await page.clock.install({ time: Date.now() + 3_000 }); + await page.evaluate(() => { + window.__BUZZ_E2E_EXPIRE_USER_STATUS_QUERIES__?.(); }); + await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0); await expect(dialog.getByTestId("set-status-input")).toHaveValue( "Unsaved draft", diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..8175c828add 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -26,9 +26,16 @@ async function openCreateWorkflow( await page.getByRole("button", { name: "Create Workflow" }).click(); const dialog = page.getByRole("dialog", { name: "Create workflow" }); const channelList = page.getByTestId("channel-combobox-list"); - if (!(await channelList.isVisible())) { - await dialog.getByRole("combobox", { name: "Channel" }).click(); - } + // The combobox list auto-opens on dialog mount, but wait for it to stabilize + // before deciding whether a manual click is needed: avoids a one-shot + // isVisible() read during the mount transition. + await expect + .poll(async () => { + if (await channelList.isVisible()) return true; + await dialog.getByRole("combobox", { name: "Channel" }).click(); + return false; + }) + .toBe(true); await channelList .getByRole("option", { name: "agents", exact: true }) .click(); @@ -51,10 +58,18 @@ async function openTriggerInspector( dialog: import("@playwright/test").Locator, ) { const menu = dialog.getByRole("button", { name: "Trigger event" }); - if (!(await menu.isVisible())) { - await dialog.getByRole("button", { name: /^Trigger:/ }).click(); - } - await expect(menu).toBeVisible(); + // The inspector may already be open (e.g. immediately after openCreateWorkflow) + // or collapsed (e.g. after reopenWorkflow). Poll so the DOM stabilizes before + // we decide whether a click is needed — avoids a one-shot isVisible() race + // during tab-switch or dialog-mount transitions. + await expect + .poll(async () => { + if (await menu.isVisible()) return true; + const trigger = dialog.getByRole("button", { name: /^Trigger:/ }); + if (await trigger.isVisible()) await trigger.click(); + return false; + }) + .toBe(true); } async function addMessageStep( @@ -74,18 +89,39 @@ async function createEnabled( const confirmation = page.getByRole("alertdialog", { name: "This workflow may run often", }); - if (await confirmation.isVisible()) { - await confirmation.getByRole("button", { name: "Turn on" }).click(); - } + // Wide triggers (message_posted) always show the activation dialog. + // Narrow triggers (schedule, reaction_added, webhook) skip it and close the + // create dialog directly. Poll so we wait for whichever state arrives first + // rather than reading a one-shot isVisible() during the animation frame. + await expect + .poll(async () => { + if (await confirmation.isVisible()) { + await confirmation.getByRole("button", { name: "Turn on" }).click(); + return true; + } + return !(await dialog.isVisible()); + }) + .toBe(true); + // Await dialog closure regardless of path taken. + await expect(dialog).not.toBeVisible(); } async function reopenWorkflow( page: import("@playwright/test").Page, name: string, ) { - const card = page - .locator('[data-testid^="workflow-card-"]') - .filter({ hasText: name }); + // Filter by the child workflow-card-name element to avoid a strict-mode + // violation: `[data-testid^="workflow-card-"]` prefix-matches the container + // div AND the inner

, so a bare hasText + // filter resolves to 2 elements. Scoping with `has` ensures only the + // container (which contains the name child) is selected. + const card = page.locator('[data-testid^="workflow-card-"]').filter({ + has: page.getByTestId("workflow-card-name").filter({ hasText: name }), + }); + // Await the card before addressing its action: createEnabled() returns only + // after dialog closure, but the card render is async and may not be in the + // DOM yet when execution reaches here. + await expect(card).toBeVisible(); await card.getByRole("button", { name: "Workflow actions" }).click(); await page.getByRole("menuitem", { name: "Edit" }).click(); return page.getByRole("dialog", { name: "Edit workflow" }); @@ -288,18 +324,28 @@ test("round-trips and reopens structured message-text conditions", async ({ await openTriggerInspector(dialog); const matchControls = dialog.getByRole("group", { name: "Match" }); const operatorButtons = matchControls.getByRole("button"); - const firstOperatorBox = await operatorButtons.nth(0).boundingBox(); - const secondOperatorBox = await operatorButtons.nth(1).boundingBox(); - const thirdOperatorBox = await operatorButtons.nth(2).boundingBox(); - expect(firstOperatorBox).not.toBeNull(); - expect(secondOperatorBox).not.toBeNull(); - expect(thirdOperatorBox).not.toBeNull(); - expect(secondOperatorBox?.x).toBeGreaterThan(firstOperatorBox?.x ?? 0); - expect( - Math.abs((secondOperatorBox?.y ?? 0) - (firstOperatorBox?.y ?? 0)), - ).toBeLessThan(1); - expect(thirdOperatorBox?.y).toBeGreaterThan(firstOperatorBox?.y ?? 0); + // Await all three operator buttons before reading geometry: ensures the + // inspector has fully rendered after the tab switch and openTriggerInspector + // before any bounding-box reads (one-shot reads during transitions were the + // source of intermittent null / reversed-coordinate failures). + await expect(operatorButtons.nth(0)).toBeVisible(); + await expect(operatorButtons.nth(1)).toBeVisible(); + await expect(operatorButtons.nth(2)).toBeVisible(); await waitForAnimations(page); + // Use poll-based layout assertions so a render frame between measurement and + // assertion cannot produce a stale reading. + await expect + .poll(async () => { + const b0 = await operatorButtons.nth(0).boundingBox(); + const b1 = await operatorButtons.nth(1).boundingBox(); + const b2 = await operatorButtons.nth(2).boundingBox(); + if (!b0 || !b1 || !b2) return null; + return { + row: Math.abs(b1.y - b0.y) < 1 && b1.x > b0.x, + col: b2.y > b0.y, + }; + }) + .toEqual({ row: true, col: true }); await matchControls.screenshot({ path: "test-results/workflow-message-condition-operators.png", }); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 16844a2f3e2..563d5300c2a 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -79,6 +79,9 @@ async function createWorkflow( await dialog .getByRole("button", { name: "Trigger: Message Posted" }) .click(); + await expect( + dialog.getByRole("button", { name: "Trigger event" }), + ).toBeVisible(); await dialog.getByRole("button", { name: "Trigger event" }).click(); await page .getByRole("menuitem", {