Files
buzz/desktop/tests/e2e/agent-snapshot-recipient.spec.ts
klopez4212 a35771fc44 feat(desktop): refine agent catalog sharing (#2439)
## Summary

- add custom-agent catalog sharing and hide built-ins from discovery
- let owners publish later catalog updates from Share or while saving
edits — the save always persists locally, and the publish reports
`published` or `queued` (flushed automatically once the relay is
reachable again)
- preserve agent type, model, and runtime across snapshot import/export
- simplify agent and team entry points and tighten catalog layout
- migrate the legacy global retention queue into the owner's active
scope so pending catalog publishes survive the upgrade
- keep the agent list and edits usable in recovery mode by degrading to
unshared projections when scope resolution or the retention DB fails
- track catalog provenance on copied personas, so adding an
already-added foreign agent resolves to the existing copy instead of
creating a duplicate
- scope inbound persona events to the community relay they arrived on
- page the catalog read past the relay's 1,000-row query clamp
- unify the share dialog's memory-level choice into a single "What's
included" selector that drives both DM-send and copy-link delivery (all
six combinations preserved), group the delivery rows above the option
rows, and label the catalog toggle "Not shared" / "Shared"
- keep emoji avatars on catalog entries — they persist as inline
percent-encoded SVG, which the catalog projection's http(s)-only URL
guard used to drop, so a shared agent showed initials instead of its
avatar
- drop the "Active in communities" card from Agents settings, superseded
by the per-channel runtime controls in the members sidebar

## Screenshots

### Agent actions

![Agent
actions](https://raw.githubusercontent.com/block/buzz/4643581cd0882d8b101b04e3d8be290ea7e39f08/pr-2439--01-agent-menu.png)

### Team avatar stack

![Team avatar
stack](https://raw.githubusercontent.com/block/buzz/4643581cd0882d8b101b04e3d8be290ea7e39f08/pr-2439--02-team-menu.png)

### Catalog sharing

![Catalog
sharing](https://raw.githubusercontent.com/block/buzz/648d6eaf6df7d4daa5b7375948ed221f723793d6/pr-2439--03-share-to-catalog.png)

### Publish while editing

![Publish while
editing](https://raw.githubusercontent.com/block/buzz/bd4eb473ff456f6e665173054dc5a0f764594d1e/pr-2439--01-edit-agent-publish-updates.png)

### Publish from Share

![Publish from
Share](https://raw.githubusercontent.com/block/buzz/648d6eaf6df7d4daa5b7375948ed221f723793d6/pr-2439--02-share-dialog-publish-updates.png)

### Catalog details

![Catalog
details](https://raw.githubusercontent.com/block/buzz/4643581cd0882d8b101b04e3d8be290ea7e39f08/pr-2439--04-agent-catalog.png)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-07-28 15:01:03 -04:00

358 lines
12 KiB
TypeScript

/**
* Recipient-native agent snapshot card/import E2E spec.
*
* These tests exercise the AgentSnapshotCard rendered in a message timeline
* when an .agent.json or .agent.png attachment is detected, and the full
* Add agent → preview → confirm flow.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
type CommandLogEntry = { command: string; payload: unknown };
function hasVisibleBoxShadow(boxShadow: string) {
if (boxShadow === "none") return false;
const colors = [...boxShadow.matchAll(/rgba?\(([^)]+)\)/gu)];
if (colors.length === 0) return true;
return colors.some(([, channels]) => {
const values = channels.split(",").map((value) => value.trim());
return values.length < 4 || Number(values[3]) > 0;
});
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: CommandLogEntry[];
}
).__BUZZ_E2E_COMMAND_LOG__ ?? [],
);
}
async function invokeMockCommand(
page: import("@playwright/test").Page,
command: string,
payload: Record<string, unknown>,
) {
// The app installs its dynamically imported bridge during bootstrap. Wait for
// that installation after navigation instead of racing the first invoke.
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
null,
{ timeout: 5_000 },
);
return page.evaluate(
async ({ command: cmd, payload: request }) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__: (
command: string,
payload: Record<string, unknown>,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
return invoke(cmd, request);
},
{ command, payload },
);
}
const ANALYST_PERSONA_ID = "test-analyst";
const ANALYST_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
const SHA256 = "a".repeat(64);
/** Full imeta descriptor for a .agent.json attachment. */
const SNAPSHOT_UPLOAD_DESCRIPTOR = {
url: `https://mock.relay/media/${SHA256}.json`,
sha256: SHA256,
size: 1234,
type: "application/json",
uploaded: Math.floor(Date.now() / 1000),
filename: "e2e-agent.agent.json",
};
// ── Helper: seed bridge, send snapshot to #general, navigate to timeline ─────
async function seedAndSendSnapshot(
page: import("@playwright/test").Page,
opts: { snapshotFetchError?: string } = {},
) {
await installMockBridge(page, {
personas: [
{
id: ANALYST_PERSONA_ID,
displayName: "Analyst",
systemPrompt: "You are an analyst.",
},
],
managedAgents: [
{
pubkey: ANALYST_PUBKEY,
name: "Analyst",
personaId: ANALYST_PERSONA_ID,
},
],
uploadDescriptors: [SNAPSHOT_UPLOAD_DESCRIPTOR],
...(opts.snapshotFetchError
? { snapshotFetchError: opts.snapshotFetchError }
: {}),
});
await page.goto("/");
// JSON snapshots can still arrive from saved files or older clients, even
// though new in-app shares always encode PNG. Seed one directly so this
// recipient suite retains JSON-card coverage independent of send behavior.
await invokeMockCommand(page, "send_channel_message", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
content: `[${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}](${SNAPSHOT_UPLOAD_DESCRIPTOR.url})`,
mediaTags: [
[
"imeta",
`url ${SNAPSHOT_UPLOAD_DESCRIPTOR.url}`,
`m ${SNAPSHOT_UPLOAD_DESCRIPTOR.type}`,
`x ${SNAPSHOT_UPLOAD_DESCRIPTOR.sha256}`,
`size ${SNAPSHOT_UPLOAD_DESCRIPTOR.size}`,
`filename ${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}`,
],
],
});
// Navigate to #general.
await page.getByTestId("channel-general").click();
}
// ── Timeline renders AgentSnapshotCard, not generic FileCard ──────────────────
test("recipient_timeline_renders_agent_snapshot_card_not_file_card", async ({
page,
}) => {
await seedAndSendSnapshot(page);
// The sent attachment must render as AgentSnapshotCard.
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await expect(card).toHaveAttribute("data-slot", "attachment");
// Show the agent name without the snapshot file extension.
const title = card.locator('[data-slot="attachment-title"]');
await expect(title).toBeVisible();
await expect(title).toHaveText("E2e Agent");
await expect(card).not.toContainText("e2e-agent.agent.json");
const mediaBox = await card
.locator('[data-slot="attachment-media"]')
.boundingBox();
expect(mediaBox?.width).toBe(mediaBox?.height);
// Metadata names the sharer and keeps the file size.
await expect(card).toContainText("Shared by npub1mock... · 1.2 KB");
await expect(card).not.toContainText("Agent snapshot");
// Both actions must be present.
await expect(card.getByTestId("agent-snapshot-card-import")).toBeVisible();
await expect(card.getByTestId("agent-snapshot-card-download")).toBeVisible();
await expect(card.locator('[data-slot="attachment-action"]')).toHaveCount(2);
const actions = card.locator('[data-slot="attachment-actions"] button');
await expect(actions.nth(0)).toHaveAccessibleName("Download E2e Agent");
await expect(actions.nth(0)).toHaveText("");
await expect(actions.nth(1)).toHaveText("Add agent");
const addAgentButton = actions.nth(1);
const restingButtonStyle = await addAgentButton.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
boxShadow: style.boxShadow,
color: style.color,
};
});
expect(restingButtonStyle.color).not.toBe(restingButtonStyle.backgroundColor);
expect(hasVisibleBoxShadow(restingButtonStyle.boxShadow)).toBe(false);
await addAgentButton.hover();
await expect
.poll(async () =>
hasVisibleBoxShadow(
await addAgentButton.evaluate(
(element) => getComputedStyle(element).boxShadow,
),
),
)
.toBe(false);
const [contentBox, downloadBox, addAgentBox] = await Promise.all([
card.locator('[data-slot="attachment-content"]').boundingBox(),
actions.nth(0).boundingBox(),
addAgentButton.boundingBox(),
]);
expect(
(downloadBox?.x ?? 0) - ((contentBox?.x ?? 0) + (contentBox?.width ?? 0)),
).toBeGreaterThanOrEqual(28);
expect(
(addAgentBox?.x ?? 0) - ((downloadBox?.x ?? 0) + (downloadBox?.width ?? 0)),
).toBeGreaterThanOrEqual(8);
const [titleElement, descriptionElement] = await Promise.all([
title.elementHandle(),
card.locator('[data-slot="attachment-description"]').elementHandle(),
]);
expect(
await titleElement?.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
).toBe(true);
expect(
await descriptionElement?.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
).toBe(true);
// Generic FileCard must NOT be present for this attachment.
await expect(page.getByTestId("file-card")).toHaveCount(0);
});
// ── Download still invokes download_file, not fetch_snapshot_bytes ───────────
test("recipient_download_invokes_download_file_only", async ({ page }) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await card.getByTestId("agent-snapshot-card-download").click();
const log = await readCommandLog(page);
const downloadCmds = log.filter((e) => e.command === "download_file");
const fetchSnapshotCmds = log.filter(
(e) => e.command === "fetch_snapshot_bytes",
);
expect(downloadCmds.length).toBeGreaterThanOrEqual(1);
expect(fetchSnapshotCmds.length).toBe(0);
});
// ── Add agent: fetch → navigate to agents → open preview ─────────────────────
test("recipient_import_navigates_to_agents_and_opens_preview", async ({
page,
}) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
// Click Add agent.
await card.getByTestId("agent-snapshot-card-import").click();
// fetch_snapshot_bytes must have been called.
await expect(async () => {
const log = await readCommandLog(page);
const fetchCmds = log.filter((e) => e.command === "fetch_snapshot_bytes");
expect(fetchCmds.length).toBeGreaterThanOrEqual(1);
}).toPass({ timeout: 5000 });
// download_file must NOT have been called for Add agent.
const log = await readCommandLog(page);
const downloadCmds = log.filter((e) => e.command === "download_file");
expect(downloadCmds.length).toBe(0);
// Preview dialog must open on the agents view.
const dialog = page.getByTestId("agent-snapshot-import-dialog");
await expect(dialog).toBeVisible({ timeout: 8000 });
// Decoded display name must appear.
await expect(dialog).toContainText("Imported Agent");
const metadata = dialog.getByTestId("agent-definition-metadata");
await expect(metadata).toContainText("Type");
await expect(metadata).toContainText("Built-in agent");
await expect(metadata).toContainText("Preferred model");
await expect(metadata).toContainText("claude-opus-4-5");
await expect(metadata).toContainText("Preferred runtime");
await expect(metadata).toContainText("goose");
});
// ── Confirm imports the agent ─────────────────────────────────────────────────
test("recipient_import_confirm_calls_confirm_once_and_shows_result", async ({
page,
}) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await card.getByTestId("agent-snapshot-card-import").click();
const dialog = page.getByTestId("agent-snapshot-import-dialog");
await expect(dialog).toBeVisible({ timeout: 8000 });
// Confirm the import.
await dialog.getByTestId("agent-snapshot-import-confirm").click();
// confirm_agent_snapshot_import must have been called exactly once.
await expect(async () => {
const log = await readCommandLog(page);
const confirmCmds = log.filter(
(e) => e.command === "confirm_agent_snapshot_import",
);
expect(confirmCmds.length).toBe(1);
}).toPass({ timeout: 5000 });
});
// ── Malformed/error candidate shows error, never opens preview ────────────────
test("recipient_fetch_error_shows_error_and_download_remains", async ({
page,
}) => {
await seedAndSendSnapshot(page, {
snapshotFetchError: "hash mismatch: fetched bytes do not match",
});
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await card.getByTestId("agent-snapshot-card-import").click();
// Error must appear; preview must NOT open.
const errorEl = card.getByTestId("agent-snapshot-card-error");
await expect(errorEl).toBeVisible({ timeout: 5000 });
await expect(errorEl).toContainText("hash mismatch");
await expect(
page.getByTestId("agent-snapshot-import-dialog"),
).not.toBeVisible();
// Download must remain available after error.
await expect(card.getByTestId("agent-snapshot-card-download")).toBeVisible();
});
// ── Double-click produces one fetch/preview flow ──────────────────────────────
test("recipient_double_click_import_opens_one_preview", async ({ page }) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
const importBtn = card.getByTestId("agent-snapshot-card-import");
// Click Add agent.
await importBtn.click();
// Import dialog must open exactly once (not duplicated by rapid clicks).
const dialog = page.getByTestId("agent-snapshot-import-dialog");
await expect(dialog).toHaveCount(1, { timeout: 5000 });
// Exactly one fetch_snapshot_bytes call.
const log = await readCommandLog(page);
const fetchCount = log.filter(
(e) => e.command === "fetch_snapshot_bytes",
).length;
expect(fetchCount).toBe(1);
});