mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Why The Inbox mixed overlapping feed categories with personal work queues, so **All** was not actually comprehensive and several filters did not make it clear why an item appeared. Threads and DMs could produce one row per event instead of one row per conversation, drafts were hidden until selected, and reminders appeared through multiple competing presentations. This refactor makes the **Inbox** a focused, conversation-oriented place to catch up on work relevant to you. It is intentionally not a mirror of every unread event in every channel. ## What changed - Keep the destination named **Inbox** and use the standard Lucide bell icon. - Refocus **All** on DMs, mentions, thread replies, needs-action items, replies from agents the user owns or controls, due reminders, and active drafts. - Exclude generic top-level channel traffic and updates from agents the user does not own or control. - Group each thread or DM into one row, sorted by latest activity. - Resume an unread conversation at its oldest unread message while opening the full thread or DM in the detail pane. - Reuse the existing **New** divider at the unread boundary. - Make the detail title a direct link to the canonical conversation. - Give Reminders and Drafts the same list/detail interaction and location metadata as conversation rows. - Separate Reminders and Drafts from message filters with a subtle divider, without adding another labeled section. - Put reminder and draft counts beside their corresponding filter labels instead of on the generic filter button. - Preserve the selected conversation when switching filters if it remains valid; otherwise select a valid replacement without flashing stale detail. - Use filter-specific empty states and rename the options toggle to **Show unread only**. - Ship the focused behavior directly. The earlier experiment gate, Custom view, and default-view controls have been removed from this PR to keep the first pass focused. ## Filter model | Filter | What appears | | --- | --- | | **All** | One row per personally relevant conversation, plus due reminders and active drafts. Includes DMs, mentions, thread replies, explicit needs-action items, and replies from agents the current user owns or controls. Excludes generic top-level channel traffic, other agents' updates, and reminders that are not due yet. | | **Mentions** | Conversations containing a direct mention. Each conversation appears once and opens with full context. | | **Threads** | Conventional threaded replies, grouped to one row per thread. Broadcast replies are not treated as conventional thread replies. | | **Needs action** | Feed items explicitly classified as requiring action. | | **Agents** | Conversations whose representative response was authored by an agent the current user owns or controls, including top-level DM responses. If a human replies afterward, the conversation leaves this filter until an owned agent responds again. | | **Reminders** | All pending reminders, including upcoming reminders that stay out of **All** until they are due. | | **Drafts** | Active drafts, ordered by their last real edit time. | ## Grouping, ordering, and state - A thread or DM creates one Inbox row rather than one row per event. - An unread conversation resumes at its oldest unread message so intervening context is not skipped. - Conversation rows still sort by their latest activity. - The detail pane opens the full available conversation and shows the shared **New** divider before the first unread message. - Upcoming reminders appear only in **Reminders**. - When a reminder becomes due, it enters **All** at its trigger time. If its source conversation is already represented, the reminder state merges into that row instead of creating a duplicate; otherwise it appears as a standalone reminder row. - A due reminder can enrich a row in another relative filter when that conversation already qualifies for the filter. Reminder lifecycle remains separate from message read state. - Drafts appear in **All** by their last real edit time. Opening an unchanged draft does not move it to the top. - Reminder and draft rows show their location as `In #channel` or `In DM with <name>`. - **Show unread only** hides reminder and draft work queues because they do not share message unread semantics. ## Removed or narrowed - **Remove the old Activity filter.** It overlapped with All while still omitting items All now includes. - **Narrow Agents.** It no longer gathers every agent participating in a shared thread or subsequent human follow-ups. - **Remove duplicate reminder presentations.** The aggregate pending-reminders jump and duplicate generic feed rows are replaced by one list/detail model. - **Remove Custom and default-view settings from this pass.** They added considerable state and UI before the core model had been validated. - **Do not add section labels for Reminders and Drafts.** A divider communicates the distinction without creating another hierarchy in the menu. ## Risk assessment Medium implementation risk because this changes composition, grouping, ordering, read behavior, and personal queues in a primary desktop view. The implementation is scoped to the desktop UI and its local feed projection; it does not change relay schemas or public APIs. ## Testing - Desktop formatting, lint, file-size, text-size, and TypeScript checks passed. - Desktop unit suite: **3,663 passed, 0 failed**. - Desktop E2E production build passed. - Playwright smoke coverage across every spec touching this surface (`channels`, `smoke`, `profile`, `project-inbox`, `community-rail`, `integration`, `drafts-screenshots`): **118 passed, 0 failed**. - Full Playwright smoke project: **732 passed, 1 skipped**. Three local failures were investigated and cleared — `community-rail` keyboard reorder passed on re-run (flaky), while `relay-reconnect:97` and `video-attachment:223` are untouched by this commit (the only change to shared `tests/helpers/bridge.ts` is a comment) and pass in CI. - Unit coverage includes focused All matching, owned-agent filtering, conversation grouping, oldest-unread selection, selection stability, chronological reminder/draft composition, trigger-time reminder ordering, and duplicate reminder suppression. ## Update: July 27, 2026 The naming decision is settled: the surface stays **Inbox**. An earlier pass in this branch had renamed it to **Activity**; that rename has been reverted in `9c00d2d6e`, which is naming-only and changes no behavior. The revert covers file names, component/hook/type/constant identifiers, the sidebar label and tooltip, the `Inbox options` and `Filter inbox:` aria-labels, and the corresponding test names, test ids, and fixture ids. Three things were deliberately left as `activity`: - **The feed API contract** — the `activity` / `agent_activity` categories, the `feed.activity` and `feed.agentActivity` keys, and the `types=` query parameter. These are the server's names, not the surface's. - **Plain-noun usage** — empty states such as "No activity yet", plus `latestActivityAt` and `PROJECT_ACTIVITY_KINDS`. - **Pre-existing agent, project, and profile activity code**, which refers to a different concept entirely. The earlier experiment-gate approach has also been dropped, so `tests/helpers/bridge.ts` no longer claims that an Activity preview feature exists — `preview-features.json` has no such entry and the seed helper enables every desktop feature. Generated with Codex --------- Signed-off-by: Clay Delk <clay.delk@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1548 lines
52 KiB
TypeScript
1548 lines
52 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
import {
|
|
createMockAgentMemoryListing,
|
|
installMockBridge,
|
|
TEST_IDENTITIES,
|
|
} from "../helpers/bridge";
|
|
import { openProfileMenu, openSettings } from "../helpers/settings";
|
|
|
|
async function expectHomeView(page: import("@playwright/test").Page) {
|
|
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
|
|
}
|
|
|
|
async function expandIdentity(page: import("@playwright/test").Page) {
|
|
const identity = page.getByTestId("profile-identity-card");
|
|
const isOpen = await identity.evaluate(
|
|
(element) => element instanceof HTMLDetailsElement && element.open,
|
|
);
|
|
if (!isOpen) {
|
|
await page.getByTestId("profile-identity-toggle").click();
|
|
}
|
|
}
|
|
|
|
async function selectFirstEmojiFromPicker(page: Page) {
|
|
const picker = page.locator("em-emoji-picker");
|
|
await expect(picker).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
picker.evaluate((element) =>
|
|
Boolean(element.shadowRoot?.querySelector(".scroll button")),
|
|
),
|
|
)
|
|
.toBe(true);
|
|
await picker.evaluate((element) => {
|
|
const button = element.shadowRoot?.querySelector(".scroll button");
|
|
if (!(button instanceof HTMLElement)) {
|
|
throw new Error("Emoji picker did not render an emoji button.");
|
|
}
|
|
button.click();
|
|
});
|
|
}
|
|
|
|
async function waitForAvatarEditorToClose(page: Page) {
|
|
await expect(page.getByTestId("profile-avatar-editor-shell")).toHaveCount(0);
|
|
}
|
|
|
|
async function waitForReactEffects(page: Page) {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => resolve());
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
function getHashSearchParam(page: Page, name: string) {
|
|
const hash = new URL(page.url()).hash.replace(/^#/, "");
|
|
const queryStart = hash.indexOf("?");
|
|
if (queryStart === -1) {
|
|
return null;
|
|
}
|
|
return new URLSearchParams(hash.slice(queryStart + 1)).get(name);
|
|
}
|
|
|
|
async function expectHashSearchParam(
|
|
page: Page,
|
|
name: string,
|
|
value: string | null,
|
|
) {
|
|
await expect.poll(() => getHashSearchParam(page, name)).toBe(value);
|
|
}
|
|
|
|
async function addGenericAgent(
|
|
page: Page,
|
|
channelName: string,
|
|
agentName: string,
|
|
systemPrompt = "Watch the channel and help when asked.",
|
|
): Promise<string> {
|
|
await page.getByTestId(`channel-${channelName}`).click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
|
const channelId = await page
|
|
.getByTestId(`channel-${channelName}`)
|
|
.getAttribute("data-channel-id");
|
|
if (!channelId) {
|
|
throw new Error(`Channel ${channelName} is missing a data-channel-id.`);
|
|
}
|
|
|
|
await page.waitForFunction(() => {
|
|
return Boolean(
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
|
}
|
|
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__,
|
|
);
|
|
});
|
|
return page.evaluate(
|
|
async ({ agentName, channelId, systemPrompt }) => {
|
|
const invoke = (
|
|
window as Window & {
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
payload?: Record<string, unknown>,
|
|
) => Promise<{ agent?: { pubkey: string } }>;
|
|
}
|
|
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
|
if (!invoke) {
|
|
throw new Error("Mock bridge is not installed.");
|
|
}
|
|
|
|
const created = await invoke("create_managed_agent", {
|
|
input: {
|
|
name: agentName,
|
|
spawnAfterCreate: true,
|
|
systemPrompt,
|
|
},
|
|
});
|
|
const pubkey = created.agent?.pubkey;
|
|
if (!pubkey) {
|
|
throw new Error("Mock managed agent creation did not return a pubkey.");
|
|
}
|
|
|
|
await invoke("add_channel_members", {
|
|
channelId,
|
|
pubkeys: [pubkey],
|
|
role: "bot",
|
|
});
|
|
|
|
await (
|
|
window as Window & {
|
|
__BUZZ_E2E_QUERY_CLIENT__?: {
|
|
invalidateQueries: () => Promise<void>;
|
|
};
|
|
}
|
|
).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries();
|
|
|
|
return pubkey;
|
|
},
|
|
{ agentName, channelId, systemPrompt },
|
|
);
|
|
}
|
|
|
|
async function waitForMockLiveSubscription(page: Page, channelName: string) {
|
|
await expect
|
|
.poll(async () => {
|
|
return page.evaluate((channelName) => {
|
|
return (
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
}) => boolean;
|
|
}
|
|
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName }) ?? false
|
|
);
|
|
}, channelName);
|
|
})
|
|
.toBe(true);
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await installMockBridge(page);
|
|
});
|
|
|
|
test("keeps the saved profile description after a community round trip", async ({
|
|
page,
|
|
}) => {
|
|
const communities = [
|
|
{
|
|
id: "profile-community-a",
|
|
name: "Alpha",
|
|
relayUrl: "ws://localhost:3000",
|
|
addedAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
{
|
|
id: "profile-community-b",
|
|
name: "Bravo",
|
|
relayUrl: "ws://localhost:3001",
|
|
addedAt: "2026-01-02T00:00:00.000Z",
|
|
},
|
|
];
|
|
await page.addInitScript((seed) => {
|
|
window.localStorage.setItem("buzz-communities", JSON.stringify(seed));
|
|
window.localStorage.setItem("buzz-active-community-id", seed[0].id);
|
|
}, communities);
|
|
await page.goto("/");
|
|
|
|
const description = "Description that should survive switching";
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await page.getByTestId("profile-about").fill(description);
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await expect(page.getByTestId("profile-about-value")).toHaveText(description);
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
|
|
const communityA = page.getByTestId(
|
|
"community-rail-button-profile-community-a",
|
|
);
|
|
const communityB = page.getByTestId(
|
|
"community-rail-button-profile-community-b",
|
|
);
|
|
await communityB.click();
|
|
await expect(communityB).toHaveAttribute("aria-current", "true");
|
|
await communityA.click();
|
|
await expect(communityA).toHaveAttribute("aria-current", "true");
|
|
|
|
await openSettings(page, "profile");
|
|
await expect(page.getByTestId("profile-about-value")).toHaveText(description);
|
|
});
|
|
|
|
test("updates the relay-backed profile from settings", async ({ page }) => {
|
|
const stamp = Date.now();
|
|
const displayName = `Tyler QA ${stamp}`;
|
|
const avatarUrl = `https://example.com/avatar-${stamp}.png`;
|
|
const about = `Coordinating relay profile setup ${stamp}`;
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await expect(
|
|
page.getByTestId("settings-profile").getByRole("heading", {
|
|
exact: true,
|
|
name: "Profile",
|
|
}),
|
|
).toBeVisible();
|
|
|
|
await expect(page.getByTestId("profile-identity-details")).toBeHidden();
|
|
await expandIdentity(page);
|
|
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
|
|
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
|
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await expect(page.getByTestId("profile-metadata-edit")).toHaveText("Done");
|
|
await expect(page.getByTestId("profile-about")).toBeVisible();
|
|
await page.getByTestId("profile-display-name").fill(displayName);
|
|
await page.getByTestId("profile-about").fill(about);
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
|
|
await expect(page.getByTestId("profile-display-name-value")).toHaveText(
|
|
displayName,
|
|
);
|
|
await expect(page.getByTestId("profile-about-value")).toHaveText(about);
|
|
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await waitForAvatarEditorToClose(page);
|
|
|
|
await expect(page.getByTestId("profile-display-name-value")).toHaveText(
|
|
displayName,
|
|
);
|
|
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await expandIdentity(page);
|
|
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
await expectHomeView(page);
|
|
await expect(page.getByTestId("open-settings")).toBeVisible();
|
|
|
|
await openSettings(page, "profile");
|
|
await expect(page.getByTestId("profile-display-name-value")).toHaveText(
|
|
displayName,
|
|
);
|
|
await expandIdentity(page);
|
|
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
|
|
await expect(page.getByTestId("profile-about-value")).toHaveText(about);
|
|
});
|
|
|
|
test("saves profile metadata from the block Done button", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await expect(page.getByTestId("profile-display-name-value")).toHaveText(
|
|
"npub1mock...",
|
|
);
|
|
await expect(page.getByTestId("profile-save")).toHaveCount(0);
|
|
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await expect(page.getByTestId("profile-metadata-edit")).toHaveText("Done");
|
|
await expect(page.getByTestId("profile-about")).toBeVisible();
|
|
await page.getByTestId("profile-display-name").fill("Save Button QA");
|
|
await page.getByTestId("profile-about").fill("Temporary profile note");
|
|
await expect(page.getByTestId("profile-save")).toHaveCount(0);
|
|
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await waitForReactEffects(page);
|
|
await expect(page.getByTestId("profile-display-name")).toHaveCount(0);
|
|
await expect(page.getByTestId("profile-display-name-value")).toHaveText(
|
|
"Save Button QA",
|
|
);
|
|
await expect(page.getByTestId("profile-about-value")).toHaveText(
|
|
"Temporary profile note",
|
|
);
|
|
await expect(page.getByTestId("profile-metadata-edit")).toHaveText("Edit");
|
|
await expect(page.getByTestId("profile-save")).toHaveCount(0);
|
|
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await page.getByTestId("profile-about").fill("");
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await waitForReactEffects(page);
|
|
await expect(page.getByTestId("profile-about-value")).toHaveText("Not set");
|
|
await expect(page.getByTestId("profile-save")).toHaveCount(0);
|
|
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await page.getByTestId("profile-display-name").fill("");
|
|
await expect(
|
|
page.getByText("Clearing existing profile fields is not supported yet."),
|
|
).toBeVisible();
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await waitForReactEffects(page);
|
|
await expect(page.getByTestId("profile-display-name")).toHaveCount(0);
|
|
await expect(page.getByTestId("profile-display-name-value")).toHaveText(
|
|
"Save Button QA",
|
|
);
|
|
await expect(page.getByTestId("profile-metadata-edit")).toHaveText("Edit");
|
|
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await page.getByTestId("profile-display-name").fill("npub1mock...");
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await expect(page.getByTestId("profile-save")).toHaveCount(0);
|
|
});
|
|
|
|
test("shows profile save feedback as a toast", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
await page.getByTestId("profile-display-name").fill("Toast QA");
|
|
await page.getByTestId("profile-metadata-edit").click();
|
|
|
|
await expect(
|
|
page.locator("[data-sonner-toast]").filter({ hasText: "Profile saved" }),
|
|
).toBeVisible();
|
|
await expect(page.getByText("Profile saved.", { exact: true })).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("nests the avatar edit button in a clipped notch", async ({ page }) => {
|
|
// Under the Buzz default theme the settings nav overrides `--sidebar-active`
|
|
// (white pill on the gradient) while the avatar edit button deliberately
|
|
// keeps the root accent-driven token, so the shared-token comparison below
|
|
// only holds outside the Buzz theme.
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("buzz-theme", "github-light");
|
|
});
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
|
|
await expect(page.getByTestId("profile-avatar-preview-clip")).toHaveCSS(
|
|
"clip-path",
|
|
/polygon/,
|
|
);
|
|
const editShell = page.getByTestId("profile-avatar-edit-shell");
|
|
await expect(editShell).toHaveCSS("height", "54px");
|
|
await expect(editShell).toHaveCSS("width", "54px");
|
|
|
|
const editButton = page.getByTestId("profile-avatar-edit");
|
|
await expect(editButton).toHaveCSS("opacity", "1");
|
|
|
|
await expect(editButton).toHaveCSS(
|
|
"background-color",
|
|
await page
|
|
.getByTestId("settings-nav-profile")
|
|
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
);
|
|
const transitionProperty = await editButton.evaluate(
|
|
(element) => getComputedStyle(element).transitionProperty,
|
|
);
|
|
expect(transitionProperty).toContain("opacity");
|
|
expect(transitionProperty).toContain("scale");
|
|
});
|
|
|
|
test("swaps the avatar preview and mode tabs while editing", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
|
|
const previewFrame = page.getByTestId("profile-avatar-clip-frame");
|
|
const closedPreviewBox = await previewFrame.boundingBox();
|
|
if (!closedPreviewBox) {
|
|
throw new Error("Profile avatar preview did not render bounds.");
|
|
}
|
|
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
const tabList = page.getByRole("tablist", { name: "Avatar type" });
|
|
await expect(tabList).toBeVisible();
|
|
await expect(page.getByTestId("profile-avatar-mode-tabs-slot")).toBeVisible();
|
|
await page.waitForTimeout(350);
|
|
|
|
const openPreviewBox = await previewFrame.boundingBox();
|
|
const tabListBox = await tabList.boundingBox();
|
|
if (!openPreviewBox || !tabListBox) {
|
|
throw new Error("Profile avatar edit layout did not render bounds.");
|
|
}
|
|
|
|
const closedPreviewCenterY = closedPreviewBox.y + closedPreviewBox.height / 2;
|
|
const tabListCenterY = tabListBox.y + tabListBox.height / 2;
|
|
expect(Math.abs(tabListCenterY - closedPreviewCenterY)).toBeLessThan(16);
|
|
const tabListBottomY = tabListBox.y + tabListBox.height;
|
|
const segmentToPreviewGap = openPreviewBox.y - tabListBottomY;
|
|
expect(segmentToPreviewGap).toBeGreaterThan(48);
|
|
expect(segmentToPreviewGap).toBeLessThan(72);
|
|
expect(openPreviewBox.y).toBeGreaterThan(closedPreviewCenterY + 72);
|
|
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await waitForAvatarEditorToClose(page);
|
|
await expect(tabList).toHaveCount(0);
|
|
|
|
const restoredPreviewBox = await previewFrame.boundingBox();
|
|
if (!restoredPreviewBox) {
|
|
throw new Error("Profile avatar preview did not restore bounds.");
|
|
}
|
|
expect(Math.abs(restoredPreviewBox.y - closedPreviewBox.y)).toBeLessThan(8);
|
|
});
|
|
|
|
test("highlights the avatar drop target while dragging an image", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
|
|
const uploadTarget = page.getByTestId("profile-avatar-upload");
|
|
await uploadTarget.evaluate((element) => {
|
|
const dataTransfer = new DataTransfer();
|
|
dataTransfer.items.add(
|
|
new File(["avatar"], "avatar.png", { type: "image/png" }),
|
|
);
|
|
|
|
element.dispatchEvent(
|
|
new DragEvent("dragenter", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
dataTransfer,
|
|
}),
|
|
);
|
|
});
|
|
|
|
await expect(uploadTarget).toHaveAttribute("data-dragging", "true");
|
|
await expect(uploadTarget).toContainText("Drop image here");
|
|
|
|
await uploadTarget.evaluate((element) => {
|
|
const dataTransfer = new DataTransfer();
|
|
dataTransfer.items.add(
|
|
new File(["avatar"], "avatar.png", { type: "image/png" }),
|
|
);
|
|
|
|
element.dispatchEvent(
|
|
new DragEvent("dragleave", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
dataTransfer,
|
|
}),
|
|
);
|
|
});
|
|
|
|
await expect(uploadTarget).not.toHaveAttribute("data-dragging", "true");
|
|
|
|
await uploadTarget.evaluate((element) => {
|
|
const dataTransfer = new DataTransfer();
|
|
dataTransfer.items.add(
|
|
new File(["avatar"], "avatar.png", { type: "image/png" }),
|
|
);
|
|
|
|
element.dispatchEvent(
|
|
new DragEvent("dragenter", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
dataTransfer,
|
|
}),
|
|
);
|
|
});
|
|
|
|
await expect(uploadTarget).toHaveAttribute("data-dragging", "true");
|
|
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(
|
|
new DragEvent("dragleave", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clientX: -1,
|
|
clientY: 40,
|
|
}),
|
|
);
|
|
});
|
|
|
|
await expect(uploadTarget).not.toHaveAttribute("data-dragging", "true");
|
|
});
|
|
|
|
test("uploads local profile avatar files before saving", async ({ page }) => {
|
|
const uploadedAvatarUrl = "https://mock.relay/media/avatar-profile.png";
|
|
await installMockBridge(page, {
|
|
uploadDescriptors: [
|
|
{
|
|
filename: "avatar-profile.png",
|
|
sha256: "b".repeat(64),
|
|
size: 553432,
|
|
type: "image/png",
|
|
uploaded: 1_779_900_000,
|
|
url: uploadedAvatarUrl,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await page.getByTestId("profile-avatar-input").setInputFiles({
|
|
buffer: Buffer.from("large-avatar-bytes"),
|
|
mimeType: "image/png",
|
|
name: "avatar-profile.png",
|
|
});
|
|
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await waitForAvatarEditorToClose(page);
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
|
|
|
|
const pastedAvatarUrl = await page.evaluate(
|
|
() => new URL("/buzz.svg", window.location.href).href,
|
|
);
|
|
await page.getByTestId("profile-avatar-url").click();
|
|
await page.keyboard.insertText(pastedAvatarUrl);
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(
|
|
pastedAvatarUrl,
|
|
);
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await waitForAvatarEditorToClose(page);
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
|
|
await page.getByTestId("profile-avatar-url").fill("");
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await expect(
|
|
page.getByTestId("profile-avatar-preview").locator("img"),
|
|
).toHaveCount(1);
|
|
await waitForAvatarEditorToClose(page);
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
|
|
.__BUZZ_E2E_COMMANDS__ ?? [],
|
|
),
|
|
)
|
|
.toEqual(expect.arrayContaining(["upload_media_bytes", "update_profile"]));
|
|
});
|
|
|
|
test("renders emoji avatars with a static background layer", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await page.getByRole("tab", { name: "Emoji" }).click();
|
|
await selectFirstEmojiFromPicker(page);
|
|
await page.getByRole("button", { name: "Use #FFE75C background" }).click();
|
|
|
|
const avatarPreview = page.getByTestId("profile-avatar-preview");
|
|
await expect(avatarPreview).toHaveCSS(
|
|
"background-color",
|
|
"rgb(255, 231, 92)",
|
|
);
|
|
await expect(avatarPreview).not.toHaveClass(/buzz-avatar-squish/);
|
|
await expect(page.getByTestId("profile-avatar-preview-emoji")).toHaveText(
|
|
"😀",
|
|
);
|
|
await expect(page.getByTestId("profile-avatar-preview-emoji")).toHaveCSS(
|
|
"font-size",
|
|
"96px",
|
|
);
|
|
});
|
|
|
|
test("reveals emoji background colors only after choosing an emoji", async ({
|
|
page,
|
|
}) => {
|
|
const imageAvatarUrl = `https://example.com/avatar-color-controls-${Date.now()}.png`;
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await page.getByTestId("profile-avatar-url").fill(imageAvatarUrl);
|
|
await page.getByTestId("profile-avatar-done").click();
|
|
await waitForAvatarEditorToClose(page);
|
|
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
|
|
await page.getByRole("tab", { name: "Emoji" }).click();
|
|
|
|
const colorGridShell = page.getByTestId("profile-avatar-color-grid-shell");
|
|
const doneButton = page.getByTestId("profile-avatar-done");
|
|
await expect(colorGridShell).toHaveAttribute("aria-hidden", "true");
|
|
|
|
const doneBeforeEmoji = await doneButton.boundingBox();
|
|
if (!doneBeforeEmoji) {
|
|
throw new Error("Avatar Done button did not render bounds.");
|
|
}
|
|
|
|
await selectFirstEmojiFromPicker(page);
|
|
|
|
await expect(colorGridShell).toHaveAttribute("aria-hidden", "false");
|
|
await expect(page.getByTestId("profile-avatar-color-grid")).toBeVisible();
|
|
await colorGridShell.evaluate((element) =>
|
|
Promise.all(
|
|
element
|
|
.getAnimations()
|
|
.map((animation) => animation.finished.catch(() => undefined)),
|
|
),
|
|
);
|
|
|
|
const doneAfterEmoji = await doneButton.boundingBox();
|
|
if (!doneAfterEmoji) {
|
|
throw new Error("Avatar Done button did not render bounds.");
|
|
}
|
|
expect(doneAfterEmoji.y).toBeGreaterThan(doneBeforeEmoji.y + 8);
|
|
});
|
|
|
|
test("snaps custom avatar colors to the dot grid", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "profile");
|
|
await page.getByTestId("profile-avatar-edit").click();
|
|
await page.getByRole("tab", { name: "Emoji" }).click();
|
|
await selectFirstEmojiFromPicker(page);
|
|
|
|
const customColorSwatch = page.getByTestId("profile-avatar-custom-color");
|
|
await customColorSwatch.click();
|
|
|
|
const spectrum = page.getByTestId("profile-avatar-custom-color-spectrum");
|
|
await expect(spectrum).toBeVisible();
|
|
await expect(page.getByTestId("profile-avatar-done")).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("profile-avatar-custom-color-done"),
|
|
).toBeVisible();
|
|
|
|
const hueSlider = page.getByTestId("profile-avatar-custom-color-hue");
|
|
await hueSlider.press("Home");
|
|
await expect(hueSlider).toHaveAttribute("aria-valuenow", "0");
|
|
|
|
const spectrumBox = await spectrum.boundingBox();
|
|
if (!spectrumBox) {
|
|
throw new Error("Custom color spectrum did not render bounds.");
|
|
}
|
|
|
|
await spectrum.click({
|
|
position: {
|
|
x: 24 + (spectrumBox.width - 48) * 0.33,
|
|
y: 24 + (spectrumBox.height - 48) * 0.44,
|
|
},
|
|
});
|
|
await expect(page.getByTestId("profile-avatar-preview")).toHaveCSS(
|
|
"background-color",
|
|
"rgb(145, 93, 93)",
|
|
);
|
|
await page.getByTestId("profile-avatar-custom-color-done").click();
|
|
|
|
await expect(customColorSwatch).toHaveAttribute("aria-pressed", "true");
|
|
await expect(customColorSwatch).toHaveCSS(
|
|
"background-color",
|
|
"rgb(145, 93, 93)",
|
|
);
|
|
await expect(page.getByTestId("profile-avatar-done")).toBeVisible();
|
|
});
|
|
|
|
test("opens Send feedback from the profile menu", async ({ page }) => {
|
|
await page.goto("/");
|
|
await openProfileMenu(page);
|
|
await page.getByTestId("profile-popover-send-feedback").click();
|
|
await expect(page.getByTestId("send-feedback-dialog")).toBeVisible();
|
|
await expect(page.getByTestId("feedback-privacy-disclosure")).toContainText(
|
|
"not posted to a channel",
|
|
);
|
|
});
|
|
|
|
test("keeps Send disabled when a stale attachment attempt finishes", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
uploadDelayMs: 1_200,
|
|
uploadDescriptors: [
|
|
{
|
|
url: `https://mock.relay/media/${"b".repeat(64)}.png`,
|
|
sha256: "b".repeat(64),
|
|
size: 42,
|
|
type: "image/png",
|
|
uploaded: 42,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await openProfileMenu(page);
|
|
await page.getByTestId("profile-popover-send-feedback").click();
|
|
await page.getByTestId("feedback-message").fill("Attachment race");
|
|
await page.getByTestId("feedback-attach-image").click();
|
|
await expect(page.getByTestId("feedback-attach-image")).toContainText(
|
|
"Attaching…",
|
|
);
|
|
|
|
await page.waitForTimeout(450);
|
|
await page.getByRole("button", { name: "Cancel" }).click();
|
|
await openProfileMenu(page);
|
|
await page.getByTestId("profile-popover-send-feedback").click();
|
|
await page.getByTestId("feedback-message").fill("Second attachment");
|
|
await page.getByTestId("feedback-attach-image").click();
|
|
|
|
const submit = page.getByTestId("feedback-submit");
|
|
await expect(submit).toBeDisabled();
|
|
await page.waitForTimeout(900);
|
|
await expect(page.getByTestId("feedback-attach-image")).toContainText(
|
|
"Attaching…",
|
|
);
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await expect(page.getByTestId("feedback-attachment-thumb")).toBeVisible();
|
|
await expect(submit).toBeEnabled();
|
|
});
|
|
|
|
test("proxies feedback attachment previews", async ({ page }) => {
|
|
const sha256 = "c".repeat(64);
|
|
const proxyUrl = `http://127.0.0.1:54321/media/${sha256}.png`;
|
|
await installMockBridge(page, {
|
|
uploadDescriptors: [
|
|
{
|
|
url: `http://localhost:3000/media/${sha256}.png`,
|
|
sha256,
|
|
size: 42,
|
|
type: "image/png",
|
|
uploaded: 42,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await openProfileMenu(page);
|
|
await page.getByTestId("profile-popover-send-feedback").click();
|
|
await page.getByTestId("feedback-attach-image").click();
|
|
|
|
const thumbnail = page.getByTestId("feedback-attachment-thumb");
|
|
await expect(thumbnail.locator("img")).toHaveAttribute("src", proxyUrl);
|
|
await thumbnail.click();
|
|
|
|
const preview = page.getByTestId("feedback-attachment-preview");
|
|
await expect(preview).toBeVisible();
|
|
await expect(preview.locator("img")).toHaveAttribute("src", proxyUrl);
|
|
});
|
|
|
|
test("updates presence from the profile menu", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openProfileMenu(page);
|
|
await expect(
|
|
page.getByTestId("profile-popover-presence-trigger"),
|
|
).toContainText("Online");
|
|
|
|
await page.getByTestId("profile-popover-presence-trigger").click();
|
|
await page.getByTestId("profile-popover-status-away").click();
|
|
await openProfileMenu(page);
|
|
await expect(
|
|
page.getByTestId("profile-popover-presence-trigger"),
|
|
).toContainText("Away");
|
|
|
|
await page.getByTestId("profile-popover-presence-trigger").click();
|
|
await page.getByTestId("profile-popover-status-offline").click();
|
|
await openProfileMenu(page);
|
|
await expect(
|
|
page.getByTestId("profile-popover-presence-trigger"),
|
|
).toContainText("Offline");
|
|
});
|
|
|
|
test("renders agent profile ingress subviews from the Playwright mock bridge", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
agentMemory: createMockAgentMemoryListing(),
|
|
});
|
|
await page.goto("/");
|
|
|
|
const longAgentInstruction = [
|
|
"Watch the channel and help when asked.",
|
|
"Summarize active decisions, call out risks plainly, and keep the tone concise.",
|
|
"Prefer concrete next steps over broad commentary, and cite the relevant thread context when responding.",
|
|
"Avoid catchphrases, theatrical roleplay, and unsupported guesses.",
|
|
"When uncertainty remains, say exactly what evidence would resolve it.",
|
|
].join("\n\n");
|
|
const agentPubkey = await addGenericAgent(
|
|
page,
|
|
"general",
|
|
"Memory Bot",
|
|
longAgentInstruction,
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
const emit = (
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
pubkey: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) {
|
|
throw new Error("Mock message emitter is unavailable.");
|
|
}
|
|
emit({
|
|
channelName: "general",
|
|
content: "Memory bot check-in",
|
|
pubkey,
|
|
});
|
|
},
|
|
{ pubkey: agentPubkey },
|
|
);
|
|
|
|
const messageRow = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Memory bot check-in" });
|
|
await expect(messageRow).toBeVisible();
|
|
await messageRow.locator("button").first().click();
|
|
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
await expectHashSearchParam(page, "profileTab", null);
|
|
|
|
await expect(page.getByTestId("user-profile-tab-info")).toBeVisible();
|
|
await expect(page.getByTestId("user-profile-runtime-status")).toHaveAttribute(
|
|
"data-status",
|
|
"running",
|
|
);
|
|
await page.getByTestId("user-profile-tab-runtime").click();
|
|
await expectHashSearchParam(page, "profileTab", "runtime");
|
|
const instructionPane = page.getByTestId("user-profile-agent-instruction");
|
|
await expect(instructionPane).toContainText(
|
|
"Watch the channel and help when asked.",
|
|
);
|
|
await expect(instructionPane).toHaveClass(/line-clamp-2/);
|
|
await page.getByTestId("user-profile-agent-instruction-row").click();
|
|
await expectHashSearchParam(page, "profileView", "instructions");
|
|
await expect(
|
|
page.getByRole("heading", { level: 2, name: "Instructions" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("user-profile-agent-instructions-view"),
|
|
).toContainText("When uncertainty remains");
|
|
await page.getByTestId("user-profile-panel-back").click();
|
|
await expectHashSearchParam(page, "profileView", null);
|
|
await expectHashSearchParam(page, "profileTab", "runtime");
|
|
await expect(
|
|
page.getByRole("heading", { level: 2, name: "Profile" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByTestId("user-profile-tab-runtime").click();
|
|
await expectHashSearchParam(page, "profileTab", "runtime");
|
|
// The dedicated Model row was consolidated into the runtime config panel;
|
|
// Model now renders as a normalized config row with real provenance.
|
|
await expect(
|
|
page
|
|
.getByTestId("user-profile-panel")
|
|
.getByText("Model", { exact: true })
|
|
.first(),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("user-profile-respond-to")).toBeVisible();
|
|
|
|
await page.getByTestId("user-profile-settings-menu-trigger").click();
|
|
await expect(
|
|
page.getByTestId(`user-profile-agent-auto-start-${agentPubkey}`),
|
|
).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
|
|
await page.getByTestId("user-profile-diagnostics-ingress").click();
|
|
await expectHashSearchParam(page, "profileView", "diagnostics");
|
|
await expect(
|
|
page.getByRole("heading", { level: 2, name: "Harness Log" }),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("user-profile-agent-status")).toHaveCount(0);
|
|
await expect(page.getByTestId("managed-agent-log-content")).toBeVisible();
|
|
await page.getByTestId("user-profile-panel-back").click();
|
|
await expectHashSearchParam(page, "profileView", null);
|
|
await expectHashSearchParam(page, "profileTab", "runtime");
|
|
await expect(
|
|
page.getByRole("heading", { level: 2, name: "Profile" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByTestId("user-profile-tab-info").click();
|
|
await expectHashSearchParam(page, "profileTab", null);
|
|
await page.getByTestId(`user-profile-view-activity-${agentPubkey}`).click();
|
|
await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
|
|
await page.getByTestId("agent-session-back").click();
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { level: 2, name: "Profile" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByTestId("user-profile-tab-channels").click();
|
|
await expectHashSearchParam(page, "profileTab", "channels");
|
|
await expect(page.getByTestId("user-profile-channels-list")).toContainText(
|
|
"#general",
|
|
);
|
|
|
|
await page.getByTestId("user-profile-tab-memories").click();
|
|
await expectHashSearchParam(page, "profileTab", "memories");
|
|
await expect(page.getByTestId("agent-memory-section")).toBeVisible();
|
|
await expect(page.getByTestId("agent-memory-list")).toContainText(
|
|
"ui-density",
|
|
);
|
|
await page.goBack();
|
|
await expectHashSearchParam(page, "profileTab", "channels");
|
|
await expect(page.getByTestId("user-profile-channels-list")).toContainText(
|
|
"#general",
|
|
);
|
|
await page.goForward();
|
|
await expectHashSearchParam(page, "profileTab", "memories");
|
|
await expect(page.getByTestId("agent-memory-section")).toBeVisible();
|
|
await expect(page.getByTestId("agent-memory-truncated")).toContainText(
|
|
"View all (9)",
|
|
);
|
|
await page.getByTestId("agent-memory-truncated").click();
|
|
await expect(page.getByTestId("agent-memory-list")).toContainText("orphan");
|
|
});
|
|
|
|
test("restored Inbox deep link hides the back arrow", async ({ page }) => {
|
|
// Charlie is a `bot` member of #agents and authors a seeded message there;
|
|
// seeding a managed agent with the same pubkey makes that message's avatar
|
|
// open a managed-agent profile panel with the Inbox ingress. Unlike an
|
|
// agent created at runtime through the bridge, this seed survives
|
|
// `page.reload()` because init scripts re-run on navigation.
|
|
const agentPubkey = TEST_IDENTITIES.charlie.pubkey;
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
channelNames: ["agents"],
|
|
name: "Charlie",
|
|
pubkey: agentPubkey,
|
|
status: "running",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("channel-agents").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
|
|
|
const messageRow = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Indexing the channel catalog now." });
|
|
await expect(messageRow).toBeVisible();
|
|
await messageRow.locator("button").first().click();
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
|
|
// Opened from the profile panel: a return target was captured, so the
|
|
// header shows the back arrow.
|
|
await page.getByTestId(`user-profile-view-activity-${agentPubkey}`).click();
|
|
await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
|
|
await expect(page.getByTestId("agent-session-back")).toBeVisible();
|
|
|
|
// A reload keeps the `agentSession` URL param but drops the in-memory
|
|
// return target, so the restored panel hides the back arrow and close is
|
|
// the only affordance — never a blind history pop.
|
|
await page.reload();
|
|
await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
|
|
await expect(page.getByTestId("agent-session-back")).toHaveCount(0);
|
|
await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible();
|
|
await page.getByTestId("auxiliary-panel-close").click();
|
|
await expect(page.getByTestId("agent-session-thread-panel")).toHaveCount(0);
|
|
});
|
|
|
|
test("declared owner sees runtime tab for a remote relay agent", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayAgents: [
|
|
{
|
|
pubkey:
|
|
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
|
name: "nadia",
|
|
agentType: "goose",
|
|
capabilities: ["search", "summaries"],
|
|
channelNames: ["agents"],
|
|
respondTo: "anyone",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("channel-agents").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
|
|
|
const messageRow = page.getByTestId("message-row").filter({
|
|
has: page.getByText("Indexing remotely for my owner."),
|
|
});
|
|
await expect(messageRow.first()).toBeVisible({ timeout: 5_000 });
|
|
await messageRow.first().getByRole("button").first().click();
|
|
|
|
const panel = page.getByTestId("user-profile-panel");
|
|
await expect(panel).toBeVisible({ timeout: 10_000 });
|
|
await expect(panel.getByRole("tab", { name: "Runtime" })).toBeVisible();
|
|
await panel.getByRole("tab", { name: "Runtime" }).click();
|
|
|
|
await expect(panel.getByTestId("user-profile-runtime")).toContainText(
|
|
"Runtime",
|
|
);
|
|
await expect(panel.getByTestId("user-profile-runtime")).toContainText(
|
|
"Goose",
|
|
);
|
|
await expect(panel.getByTestId("user-profile-respond-to")).toContainText(
|
|
"anyone",
|
|
);
|
|
|
|
// Declared ownership grants read visibility only; local-management write UI
|
|
// stays hidden because this relay agent is not in the managed-agents list.
|
|
await expect(panel.getByText("Model")).toHaveCount(0);
|
|
await expect(
|
|
panel.getByRole("button", { name: /Start|Stop|Deploy/ }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("declared owner sees runtime tab without a relay-agent record", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("channel-agents").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
|
|
|
const messageRow = page.getByTestId("message-row").filter({
|
|
has: page.getByText("Indexing remotely for my owner."),
|
|
});
|
|
await expect(messageRow.first()).toBeVisible({ timeout: 5_000 });
|
|
await messageRow.first().getByRole("button").first().click();
|
|
|
|
const panel = page.getByTestId("user-profile-panel");
|
|
await expect(panel).toBeVisible({ timeout: 10_000 });
|
|
await expect(panel.getByTestId("user-profile-agent-type")).toHaveCount(0);
|
|
await expect(panel.getByTestId("user-profile-capabilities")).toHaveCount(0);
|
|
await expect(panel.getByRole("tab", { name: "Runtime" })).toBeVisible();
|
|
await panel.getByRole("tab", { name: "Runtime" }).click();
|
|
|
|
await expect(panel.getByTestId("user-profile-agent-profile")).toContainText(
|
|
"Agent profile",
|
|
);
|
|
await expect(panel.getByTestId("user-profile-agent-profile")).toContainText(
|
|
"Declared owner verified",
|
|
);
|
|
await expect(panel.getByTestId("user-profile-runtime")).toHaveCount(0);
|
|
await expect(panel.getByTestId("user-profile-respond-to")).toHaveCount(0);
|
|
|
|
// No relay/managed runtime record means no write or management affordance —
|
|
// only the truthful NIP-OA profile signal is rendered in Runtime.
|
|
await expect(panel.getByText("Model")).toHaveCount(0);
|
|
await expect(
|
|
panel.getByRole("button", { name: /Start|Stop|Deploy/ }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("owned agent absent from relay/managed lists still renders agent framing", async ({
|
|
page,
|
|
}) => {
|
|
// Regression: bot-detection used to rely solely on the relay-agents registry
|
|
// + the local managed-agents list. An owned agent deployed elsewhere can miss
|
|
// BOTH lists, so the panel rendered it as a human (wrong archive framing).
|
|
// The fix ORs in the kind:0 NIP-OA agent flag (same signal the archive gate
|
|
// trusts), surfaced via the users-batch summary's `isAgent`.
|
|
const ednaPubkey =
|
|
"16aaadcf39011edbd887e4abefe5837170621db277e234f3f6c220d38ba75ecf";
|
|
await installMockBridge(page, {
|
|
// Seeded as an agent (kind:0 NIP-OA owner) but NOT as a managed agent and
|
|
// NOT in the relay-agents registry — exactly the bug scenario.
|
|
searchProfiles: [
|
|
{ pubkey: ednaPubkey, displayName: "Edna", isAgent: true },
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "general");
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
const emit = (
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
pubkey: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) {
|
|
throw new Error("Mock message emitter is unavailable.");
|
|
}
|
|
emit({ channelName: "general", content: "Edna check-in", pubkey });
|
|
},
|
|
{ pubkey: ednaPubkey },
|
|
);
|
|
|
|
const messageRow = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Edna check-in" });
|
|
await expect(messageRow).toBeVisible();
|
|
await messageRow.locator("button").first().click();
|
|
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
// The bot indicator only renders when isBot resolves true — the assertion
|
|
// that the OA-owner signal now drives agent framing.
|
|
await expect(page.getByTestId("profile-bot-indicator")).toBeVisible();
|
|
});
|
|
|
|
test("renders settings in the app shell with a back button", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const inboxNavButton = page
|
|
.getByTestId("app-sidebar")
|
|
.getByRole("button", { name: "Inbox" });
|
|
await expect(inboxNavButton).toBeVisible();
|
|
|
|
await openSettings(page);
|
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
|
await expect(page.getByTestId("settings-back-to-app")).toBeVisible();
|
|
await expect(page.getByPlaceholder("Search everything")).toHaveCount(0);
|
|
await expect(page.getByText("Personal", { exact: true })).toBeVisible();
|
|
await expect(page.getByTestId("settings-nav-profile")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(page.getByText("Communities", { exact: true })).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("settings-nav-channel-templates"),
|
|
).toBeVisible();
|
|
await expect(page.getByText("App", { exact: true })).toBeVisible();
|
|
await expect(page.getByTestId("settings-nav-agents")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("settings-profile").getByRole("heading", {
|
|
exact: true,
|
|
name: "Profile",
|
|
}),
|
|
).toBeVisible();
|
|
await page.getByTestId("settings-nav-appearance").click();
|
|
await expect(
|
|
page.getByTestId("settings-theme").getByRole("heading", {
|
|
name: "Appearance",
|
|
}),
|
|
).toBeVisible();
|
|
await expect(inboxNavButton).toHaveCount(0);
|
|
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
await expectHomeView(page);
|
|
await expect(inboxNavButton).toBeVisible();
|
|
});
|
|
|
|
test("notification settings drive the Inbox badge and desktop alerts", async ({
|
|
page,
|
|
}) => {
|
|
async function getAppBadgeCount() {
|
|
return page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_APP_BADGE_COUNT__?: number;
|
|
};
|
|
|
|
return win.__BUZZ_E2E_APP_BADGE_COUNT__ ?? 0;
|
|
});
|
|
}
|
|
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
|
|
|
|
await openSettings(page, "notifications");
|
|
await expect(page.getByTestId("settings-notifications")).toBeVisible();
|
|
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
|
|
"On",
|
|
);
|
|
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// The dock badge sums unreadChannelIds.size + homeBadgeCount. Seeded test
|
|
// channels may start with unreads, so capture the baseline after navigating
|
|
// to general (which marks it read) but before injecting the mock mention.
|
|
const baseline = await getAppBadgeCount();
|
|
|
|
await page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
|
|
category: "mention" | "needs_action" | "activity" | "agent_activity";
|
|
channel_id: string | null;
|
|
channel_name: string;
|
|
content: string;
|
|
created_at: number;
|
|
id: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
tags: string[][];
|
|
}) => unknown;
|
|
};
|
|
|
|
win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
|
|
category: "mention",
|
|
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
channel_name: "engineering",
|
|
content: "Please review the rollout checklist.",
|
|
created_at: Math.floor(Date.now() / 1000) + 5,
|
|
id: `mock-feed-notification-${Date.now()}`,
|
|
kind: 9,
|
|
pubkey:
|
|
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
|
tags: [
|
|
["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"],
|
|
[
|
|
"p",
|
|
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
|
],
|
|
],
|
|
});
|
|
});
|
|
|
|
await expect(page.getByTestId("sidebar-home-count")).toHaveText("1");
|
|
await expect.poll(getAppBadgeCount).toBe(baseline + 1);
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
|
|
body: string | null;
|
|
title: string;
|
|
}>;
|
|
};
|
|
|
|
return win.__BUZZ_E2E_NOTIFICATIONS__?.length ?? 0;
|
|
}),
|
|
)
|
|
.toBe(1);
|
|
|
|
const notifications = await page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
|
|
body: string | null;
|
|
title: string;
|
|
}>;
|
|
};
|
|
|
|
return win.__BUZZ_E2E_NOTIFICATIONS__ ?? [];
|
|
});
|
|
|
|
expect(notifications).toEqual([
|
|
{
|
|
body: "Please review the rollout checklist.",
|
|
title: "bob mentioned you in #engineering",
|
|
},
|
|
]);
|
|
|
|
const clickedNotification = await page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean;
|
|
};
|
|
|
|
return win.__BUZZ_E2E_CLICK_NOTIFICATION__?.(0) ?? false;
|
|
});
|
|
expect(clickedNotification).toBe(true);
|
|
|
|
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
|
await expect(page.getByTestId("message-timeline")).toContainText(
|
|
"Please review the rollout checklist.",
|
|
);
|
|
|
|
await openSettings(page, "notifications");
|
|
await page.getByTestId("notifications-home-badge-toggle").click();
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
|
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
|
|
await expect.poll(getAppBadgeCount).toBe(baseline);
|
|
|
|
await openSettings(page, "notifications");
|
|
await page.getByTestId("notifications-home-badge-toggle").click();
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
await expect(page.getByTestId("sidebar-home-count")).toHaveText("1");
|
|
await expect.poll(getAppBadgeCount).toBe(baseline + 1);
|
|
|
|
await page
|
|
.getByTestId("app-sidebar")
|
|
.getByRole("button", { name: "Inbox" })
|
|
.click();
|
|
await expectHomeView(page);
|
|
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
|
|
await expect.poll(getAppBadgeCount).toBe(baseline);
|
|
});
|
|
|
|
test("desktop notification clicks open the matching forum thread", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "notifications");
|
|
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
|
|
"On",
|
|
);
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
await expectHomeView(page);
|
|
|
|
await page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
|
|
category: "mention" | "needs_action" | "activity" | "agent_activity";
|
|
channel_id: string | null;
|
|
channel_name: string;
|
|
content: string;
|
|
created_at: number;
|
|
id: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
tags: string[][];
|
|
}) => unknown;
|
|
};
|
|
|
|
win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
|
|
category: "mention",
|
|
channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
|
channel_name: "watercooler",
|
|
content: "Release checklist: async feedback thread.",
|
|
created_at: Math.floor(Date.now() / 1000) + 5,
|
|
id: "mock-forum-release-thread",
|
|
kind: 45001,
|
|
pubkey:
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
|
tags: [["h", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"]],
|
|
});
|
|
});
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
|
|
body: string | null;
|
|
title: string;
|
|
}>;
|
|
};
|
|
|
|
return win.__BUZZ_E2E_NOTIFICATIONS__?.length ?? 0;
|
|
}),
|
|
)
|
|
.toBe(1);
|
|
|
|
const clickedNotification = await page.evaluate(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean;
|
|
};
|
|
|
|
return win.__BUZZ_E2E_CLICK_NOTIFICATION__?.(0) ?? false;
|
|
});
|
|
expect(clickedNotification).toBe(true);
|
|
|
|
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
|
await expect(
|
|
page.getByRole("button", { name: "Back to posts" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText("Release checklist: async feedback thread."),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("opens settings with the keyboard shortcut and updates theme", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expectHomeView(page);
|
|
|
|
await page.keyboard.press(
|
|
process.platform === "darwin" ? "Meta+," : "Control+,",
|
|
);
|
|
|
|
await expect(page.getByTestId("settings-view")).toBeVisible();
|
|
await expect(page.getByTestId("settings-nav-profile")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(
|
|
page.getByTestId("settings-profile").getByRole("heading", {
|
|
exact: true,
|
|
name: "Profile",
|
|
}),
|
|
).toBeVisible();
|
|
await page.getByTestId("settings-nav-appearance").click();
|
|
|
|
// Default is Buzz in System mode; Playwright's default color scheme is
|
|
// light, so the app boots with the light Buzz theme.
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => document.documentElement.classList.contains("light")),
|
|
)
|
|
.toBe(true);
|
|
|
|
// Switch to Light mode tab to reveal light themes. Target the testid — in
|
|
// the default System mode the "Light" paired-theme tile shares the same
|
|
// accessible name as the mode button.
|
|
await page.getByTestId("appearance-mode-light").click();
|
|
|
|
// Switch to a light theme — verifies dark→light transition
|
|
await page.getByTestId("theme-option-github-light").click();
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => document.documentElement.classList.contains("light")),
|
|
)
|
|
.toBe(true);
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => document.documentElement.classList.contains("dark")),
|
|
)
|
|
.toBe(false);
|
|
|
|
// CSS variables are set on the root element (the real theming mechanism)
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
document.documentElement.style.getPropertyValue("--background").trim(),
|
|
),
|
|
)
|
|
.toBeTruthy();
|
|
|
|
// Theme name persists in localStorage
|
|
await expect
|
|
.poll(() => page.evaluate(() => localStorage.getItem("buzz-theme")))
|
|
.toBe("github-light");
|
|
|
|
// Switch to Dark mode tab to reveal dark themes
|
|
await page.getByTestId("appearance-mode-dark").click();
|
|
|
|
// Switch back to a dark theme — verifies light→dark transition
|
|
await page.getByTestId("theme-option-dracula").click();
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => document.documentElement.classList.contains("dark")),
|
|
)
|
|
.toBe(true);
|
|
|
|
await expect
|
|
.poll(() => page.evaluate(() => localStorage.getItem("buzz-theme")))
|
|
.toBe("dracula");
|
|
|
|
// Close settings with keyboard shortcut
|
|
await page.keyboard.press(
|
|
process.platform === "darwin" ? "Meta+," : "Control+,",
|
|
);
|
|
await expect(page.getByTestId("settings-view")).toHaveCount(0);
|
|
await expectHomeView(page);
|
|
});
|
|
|
|
test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
|
await page.goto("/");
|
|
await expectHomeView(page);
|
|
|
|
const getTextScaleState = () =>
|
|
page.evaluate(() => ({
|
|
fontSize: getComputedStyle(document.documentElement).fontSize,
|
|
storedScale: localStorage.getItem("buzz:text-scale"),
|
|
webviewZoom: (window as Window & { __BUZZ_E2E_WEBVIEW_ZOOM__?: number })
|
|
.__BUZZ_E2E_WEBVIEW_ZOOM__,
|
|
}));
|
|
const dispatchPrimaryShortcut = (
|
|
key: string,
|
|
code: string,
|
|
shiftKey = false,
|
|
) =>
|
|
page.evaluate(
|
|
({ code, key, shiftKey }) => {
|
|
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
code,
|
|
ctrlKey: !isMac,
|
|
key,
|
|
metaKey: isMac,
|
|
shiftKey,
|
|
}),
|
|
);
|
|
},
|
|
{ code, key, shiftKey },
|
|
);
|
|
|
|
await dispatchPrimaryShortcut("+", "Equal", true);
|
|
|
|
await expect.poll(getTextScaleState).toEqual({
|
|
fontSize: "17.6px",
|
|
storedScale: "1.1",
|
|
webviewZoom: 1,
|
|
});
|
|
|
|
await dispatchPrimaryShortcut("-", "Minus");
|
|
|
|
await expect.poll(getTextScaleState).toEqual({
|
|
fontSize: "16px",
|
|
storedScale: null,
|
|
webviewZoom: 1,
|
|
});
|
|
|
|
await dispatchPrimaryShortcut("+", "Equal", true);
|
|
await dispatchPrimaryShortcut("+", "Equal", true);
|
|
|
|
await expect.poll(getTextScaleState).toEqual({
|
|
fontSize: "19.2px",
|
|
storedScale: "1.2",
|
|
webviewZoom: 1,
|
|
});
|
|
|
|
await dispatchPrimaryShortcut("0", "Digit0");
|
|
|
|
await expect.poll(getTextScaleState).toEqual({
|
|
fontSize: "16px",
|
|
storedScale: null,
|
|
webviewZoom: 1,
|
|
});
|
|
});
|
|
|
|
test("shows agent runtimes in agent settings", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
await openSettings(page, "agents");
|
|
|
|
await expect(page.getByTestId("settings-harnesses")).toBeVisible();
|
|
await expect(page.getByTestId("doctor-runtime-goose")).toContainText("Goose");
|
|
});
|