Files
buzz/desktop/tests/e2e/persona-sync.spec.ts
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

225 lines
7.0 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { hexToBytes } from "@noble/hashes/utils.js";
import { finalizeEvent } from "nostr-tools/pure";
import { installMockBridge } from "../helpers/bridge";
// Tyler's test identity — from TEST_IDENTITIES.tyler in tests/helpers/bridge.ts
const TYLER_PRIVATE_KEY = hexToBytes(
"3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03",
);
const TYLER_PUBKEY =
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34";
const D_TAG = "sync-test-persona";
const KIND_PERSONA = 30175;
const KIND_DELETION = 5;
// The command scopes an inbound event to the community it arrived on. Under the
// mock bridge the app subscribes on e2eBridge's DEFAULT_RELAY_WS_URL, so that is
// the arrival relay these direct invocations stand in for.
const ARRIVAL_RELAY_URL = "ws://localhost:3000";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function gotoApp(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
}
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
}
async function invokeTauri<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<T> {
await waitForInvokeBridge(page);
return page.evaluate(
async ({ command: c, payload: p }) => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
c: string,
p?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (c: string, p?: Record<string, unknown>) => Promise<unknown>;
};
};
const invoke =
w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
return (await invoke(c, p)) as T;
},
{ command, payload },
);
}
/**
* Register a one-shot `agents-data-changed` listener BEFORE the reconcile call
* and wait up to 500 ms for it to fire (covers the 200 ms debounce coalesce).
* Returns true if the event fired, false on timeout.
*
* Must be called before the invokeTauri that triggers the emit so the listener
* is registered before the event fires — no race.
*/
async function listenForAgentsDataChanged(
page: import("@playwright/test").Page,
): Promise<() => Promise<boolean>> {
// Inject a Promise into the page that resolves when the event fires.
await page.evaluate(() => {
(
window as Window & { __agentsDataChangedFired?: Promise<boolean> }
).__agentsDataChangedFired = new Promise<boolean>((resolve) => {
const internals = (
window as Window & {
__TAURI_INTERNALS__?: {
listen?: (event: string, cb: () => void) => Promise<() => void>;
};
}
).__TAURI_INTERNALS__;
if (!internals?.listen) {
resolve(false);
return;
}
void internals.listen("agents-data-changed", () => resolve(true));
// Timeout guard: 500 ms covers the 200 ms debounce coalesce with margin.
setTimeout(() => resolve(false), 500);
});
});
// Return a thunk the caller invokes after the reconcile to await the result.
return () =>
page.evaluate(
() =>
(window as Window & { __agentsDataChangedFired?: Promise<boolean> })
.__agentsDataChangedFired ?? Promise.resolve(false),
);
}
test("upsert round-trip: reconcile_inbound_persona_event writes record and emits agents-data-changed", async ({
page,
}) => {
await gotoApp(page);
const createdAt = Math.floor(Date.now() / 1000);
// Build + sign the kind:30175 persona event using nostr-tools.
const personaEvent = finalizeEvent(
{
kind: KIND_PERSONA,
content: JSON.stringify({
display_name: "Sync Test Persona",
system_prompt: "You are a sync test.",
}),
tags: [["d", D_TAG]],
created_at: createdAt,
},
TYLER_PRIVATE_KEY,
);
// Register the listener BEFORE the reconcile call — no race.
const awaitFired = await listenForAgentsDataChanged(page);
// Drive the inbound reconcile path.
await invokeTauri(page, "reconcile_inbound_persona_event", {
eventJson: JSON.stringify(personaEvent),
arrivalRelayUrl: ARRIVAL_RELAY_URL,
});
// Assert the record landed on disk.
const personas = await invokeTauri<
Array<{ id: string; display_name: string }>
>(page, "list_personas");
const record = personas.find((p) => p.id === D_TAG);
expect(record?.display_name).toBe("Sync Test Persona");
// Assert the emit fired (debounce settles within 500 ms timeout guard).
const fired = await awaitFired();
expect(fired).toBe(true);
});
test("tombstone round-trip: reconcile_inbound_persona_event removes record and emits agents-data-changed", async ({
page,
}) => {
await gotoApp(page);
const upsertCreatedAt = Math.floor(Date.now() / 1000);
// Step 1: upsert the persona first.
const personaEvent = finalizeEvent(
{
kind: KIND_PERSONA,
content: JSON.stringify({
display_name: "Sync Test Persona",
system_prompt: "You are a sync test.",
}),
tags: [["d", D_TAG]],
created_at: upsertCreatedAt,
},
TYLER_PRIVATE_KEY,
);
await invokeTauri(page, "reconcile_inbound_persona_event", {
eventJson: JSON.stringify(personaEvent),
arrivalRelayUrl: ARRIVAL_RELAY_URL,
});
// Step 2: confirm it landed.
const afterUpsert = await invokeTauri<Array<{ id: string }>>(
page,
"list_personas",
);
expect(afterUpsert.some((p) => p.id === D_TAG)).toBe(true);
// Step 3: build + sign a kind:5 deletion event. created_at must be strictly
// after the upsert so the retention db does not skip it.
const tombstoneEvent = finalizeEvent(
{
kind: KIND_DELETION,
content: "",
tags: [["a", `${KIND_PERSONA}:${TYLER_PUBKEY}:${D_TAG}`]],
created_at: upsertCreatedAt + 1,
},
TYLER_PRIVATE_KEY,
);
// Register the listener BEFORE the tombstone reconcile call — no race.
const awaitFired = await listenForAgentsDataChanged(page);
await invokeTauri(page, "reconcile_inbound_persona_event", {
eventJson: JSON.stringify(tombstoneEvent),
arrivalRelayUrl: ARRIVAL_RELAY_URL,
});
// Step 4: assert the record is gone.
const afterTombstone = await invokeTauri<Array<{ id: string }>>(
page,
"list_personas",
);
expect(afterTombstone.some((p) => p.id === D_TAG)).toBe(false);
// Step 5: assert the emit fired for the tombstone path too.
const fired = await awaitFired();
expect(fired).toBe(true);
});