Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions crates/buzz-agent/tests/fake_llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> = 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;
Expand All @@ -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;
}
}
Expand Down
1 change: 1 addition & 0 deletions desktop/src/shared/styles/globals/utilities.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
16 changes: 16 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
isValidLinkPreviewSnapshotCanonicalUrl,
parseLinkPreviewSnapshots,
} from "@/shared/lib/linkPreviewSnapshot";
import { expireUserStatusQueries } from "@/features/user-status/hooks";

type TestIdentity = {
privateKey: string;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions desktop/tests/e2e/profile-custom-emoji-status.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 70 additions & 24 deletions desktop/tests/e2e/workflow-local-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand All @@ -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 <p data-testid="workflow-card-name">, 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" });
Expand Down Expand Up @@ -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",
});
Expand Down
3 changes: 3 additions & 0 deletions desktop/tests/e2e/workflows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Loading