Files
buzz/desktop/tests/e2e/team-catalog-screenshots.spec.ts
Will Pfleger 2f66ee24a0 feat(desktop): add team sharing to community catalog (#3995)
Stack: [#5112](https://github.com/block/buzz/pull/5112) → this PR

Stacks on the team catalog backend PR (#5112). Contains the `desktop/**`
changes that turn the add-agent surface into a single Community Catalog
browsing both shared agents and shared teams.

## Owner catalog sync pipeline

`usePersonaSync.ts` gains a hydration pipeline in `startPersonaSync` so
a fresh device that comes online after another already published the
owner's 30178 catalog head reconstructs the complete state without
falsely retracting it. The owner's
persona/team/managed-agent/30178/deletion history is backfilled up front
(a live-only subscription gets no history — reconnect-replay's
since-cursor is undefined until the first live event), then a live
subscription takes over.

Four properties keep a fresh sync from purging the owner's valid shared
head:

- **Paged backfill with a safe termination guarantee.** The relay serves
each REQ newest-first and clamps `limit` to its advertised `max_limit`,
so a large owner's history overflows one page — a newer 30178/30176
could return in-page while an older required 30175 constituent falls
beyond it. `fetchOwnerHistoryToExhaustion` pages the full window with
the `until` time cursor (the only cursor the WS REQ filter exposes; the
DB `before_id` keyset is REST-only), deduping the inclusive-boundary
rows. A short page terminates normally. A full page whose oldest event
cannot advance the time-only cursor is a **dense boundary** — more than
one page of events share one `created_at` second, which the WS filter
has no `(created_at, id)` cursor to escape — and raises
`PersonaHistoryDenseBoundaryError` rather than silently completing as if
the history were exhausted and dropping the older constituents behind
it.
- **Constituents-before-catalog ordering.** Over the complete batch,
`orderCatalogHeadsLast` stably defers every 30178 head past its
30175/30176 constituents. Reconciling a 30178 head before its personas
hydrate makes the inbound team refresh fail member resolution and queue
a dominating false tombstone; deferring the heads guarantees the
constituents are all applied first, while newest-wins order within every
other coordinate is untouched.
- **Hydration boundary for concurrent live events.** The backfill fetch
and the live subscription start concurrently into one reconcile chain. A
live or replayed 30178 that arrives before the backfill reconciles its
constituents would reproduce the same false-tombstone purge, so live
events are buffered until the ordered backfill is dispatched and then
drained in arrival order. Steady-state live events (after hydration
completes) reconcile immediately.
- **Explicit backfill failure policy.** A transient history-fetch
rejection is retried with bounded backoff. When backfill cannot complete
— retries exhausted, or a deterministic dense boundary — the pipeline
enters a **degraded-live** state rather than leaving the subscription
permanently inert with live events accumulating in the buffer: the
hydration boundary still opens so buffered and future live events keep
reconciling, but the whole catalog dependency set is dropped — the 30178
head, its 30175/30176 constituents, and any kind-5 deletion carrying a
dependency-targeting `a` tag (classified by scanning *all* `a` tags,
matching the backend's deletion router, which `find_map`s across every
tag and routes the first signer-owned coordinate — so a malformed or
foreign first `a` tag ahead of an owned 30176 cannot slip a destructive
deletion through) — because backfill never fully hydrated the owner's
constituents. Dropping only the 30178 head is not enough: the backend
refreshes the catalog head after every team/persona save, and live
delivery is newest-first, so a 30176 edit that adds a *new* member would
reach the backend before that member's 30175 and falsely tombstone a
witness-holding device's valid team. Holding the prior hydrated run's
constituents on disk only proves the *old* revision is resolvable — it
says nothing about a new member — which is why the entire dependency set
is held rather than just the head. 30177 managed-agent runtime policy
stays live (it drives no catalog refresh). A degraded device stays stale
on team/persona edits until it self-heals on the next effect re-run
(restart, or an identity/community switch) — the correct trade against
destroying valid shared state.

## Data layer

Relay paging, signature verification, NIP-33 head selection, and
untrusted-content parsing for the kind `30178` team catalog live
natively in the `fetch_team_catalog` Tauri command (`team_catalog.rs`),
structurally mirroring `fetch_persona_catalog` (`persona_catalog.rs`). A
catalog refresh crosses IPC once and never verifies a signature on the
webview thread. `teamCatalogRelay.ts` is now a thin presentation and
local-linkage layer over the verified projection — it shapes entries for
display and links each to a local team, and never parses or verifies.

Parsing is all-or-nothing, identical to the add-time re-fetch in
`add_team_from_catalog`: a team with any invalid member fails to parse
and the publication is dropped from the catalog, matching persona
behavior. **Behavior delta:** the previously reviewed partial-render of
invalid-member teams — a warning banner on an entry that could never be
added — is removed. Invalid publications are dropped entirely rather
than surfaced as un-addable.

## Hooks

`useTeamCatalogRelay.ts` mirrors the persona catalog hook: a
community-keyed query over `fetch_team_catalog`, live invalidation on
kind `30178`, share/unshare, and add-from-catalog (which invalidates
both the teams and personas stores, since adopting a team copies its
members as local personas).

## CommunityCatalogDialog

Single unified surface replacing the former separate dialogs. Agents and
Teams appear as labeled sections with type-tagged selection and a
teams-preferred launch. `TeamsSection`'s discover entry and the
new-agent card both open this one dialog. `PersonaCatalogDialog.tsx` is
removed; persona browsing now lives inside the unified dialog.

## TeamShareDialog

Publishes and unshares team catalog entries via `set_team_shared`.

## e2e + screenshots

`team-catalog.spec.ts` covers the browse + adopt flow;
`team-catalog-screenshots.spec.ts` produces the pixel-regression set.
`e2eBridge.ts` gains `mockTeamCatalogPublications`, which mirrors the
native command's head selection and shared gate and performs only a
shallow `v`/`name`/`members`-array shape check; per-member validation
stays in the Rust command.

## Follow-ups

None. The shared agent-definition text-safety policy
(Unicode-control/bidi/zero-width rejection) already covers the team
surface: #5112's `validate_team_catalog_content` chokepoint gates every
field delivered verbatim to the harness on both the publish and adopt
paths, and this PR's parse layer consumes that verified projection
rather than re-validating on the webview thread.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-28 14:51:25 -04:00

303 lines
9.8 KiB
TypeScript

import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { finalizeEvent } from "nostr-tools/pure";
import type { RelayEvent } from "@/shared/api/types";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
function ownerPrivateKeyFor(pubkey: string): Uint8Array {
const privateKey = Object.values(TEST_IDENTITIES).find(
(identity) => identity.pubkey === pubkey,
)?.privateKey;
if (!privateKey) {
throw new Error(`No test private key for ${pubkey}`);
}
return hexToBytes(privateKey);
}
const SHOTS = "test-results/team-catalog";
type CatalogMember = {
memberKey: string;
displayName: string;
systemPrompt: string;
model?: string | null;
runtime?: string | null;
provider?: string | null;
};
/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. */
function createTeamCatalogEvent(input: {
ownerPubkey: string;
teamDTag: string;
name: string;
description?: string | null;
instructions?: string | null;
members: CatalogMember[];
}): RelayEvent {
return finalizeEvent(
{
created_at: 1_721_750_400,
kind: 30178,
tags: [
["d", input.teamDTag],
["shared", "true"],
],
content: JSON.stringify({
v: 1,
name: input.name,
description: input.description ?? null,
instructions: input.instructions ?? null,
members: input.members.map((member) => ({
member_key: member.memberKey,
display_name: member.displayName,
system_prompt: member.systemPrompt,
avatar_url: null,
runtime: member.runtime ?? null,
model: member.model ?? null,
provider: member.provider ?? null,
})),
}),
},
ownerPrivateKeyFor(input.ownerPubkey),
);
}
/** A kind:30175 head, shaped exactly as `persona_catalog_content` projects it. */
function createPersonaCatalogEvent(input: {
ownerPubkey: string;
sourcePersonaId: string;
displayName: string;
systemPrompt: string;
}): RelayEvent {
return finalizeEvent(
{
created_at: 1_721_750_400,
kind: 30175,
tags: [
["d", input.sourcePersonaId],
["shared", "true"],
],
content: JSON.stringify({
display_name: input.displayName,
system_prompt: input.systemPrompt,
avatar_url: null,
runtime: null,
model: null,
provider: null,
name_pool: [],
}),
},
ownerPrivateKeyFor(input.ownerPubkey),
);
}
const PERSONA_CATALOG_EVENTS: RelayEvent[] = [
createPersonaCatalogEvent({
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
sourcePersonaId: "code-reviewer",
displayName: "Code Reviewer",
systemPrompt: "Review pull requests for correctness and edge cases.",
}),
];
const CATALOG_EVENTS: RelayEvent[] = [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: "release-review",
name: "Release Review",
description:
"Reads the diff, drafts the release note, and files the follow-ups.",
instructions:
"Coordinate as a unit. The reviewer and scribe share findings before the triager acts.",
members: [
{
memberKey: "reviewer",
displayName: "Reviewer",
systemPrompt: "Review the diff for correctness and edge cases.",
model: "claude-sonnet-4-5",
runtime: "claude-code",
provider: "anthropic",
},
{
memberKey: "scribe",
displayName: "Scribe",
systemPrompt: "Write the release note from the merged changes.",
},
{
memberKey: "triager",
displayName: "Triager",
systemPrompt: "File follow-ups for anything the review deferred.",
model: "gpt-5-codex",
},
],
}),
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
teamDTag: "incident-desk",
name: "Incident Desk",
description: "Two agents that hold the timeline during an incident.",
members: [
{
memberKey: "commander",
displayName: "Commander",
systemPrompt: "Own the incident timeline and the comms cadence.",
},
{
memberKey: "investigator",
displayName: "Investigator",
systemPrompt: "Chase the root cause and report findings.",
},
],
}),
];
async function gotoAgentsView(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("open-agents-view").click();
}
async function openTeamCatalog(page: import("@playwright/test").Page) {
await page.getByTestId("new-team-card").click();
await page.getByTestId("team-catalog-open").click();
await expect(page.getByTestId("community-catalog-dialog")).toBeVisible();
await waitForAnimations(page);
}
test.describe("team catalog screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test("01 — catalog browse, add, and added states", async ({ page }) => {
test.setTimeout(60_000);
await installMockBridge(page, { teamCatalogEvents: CATALOG_EVENTS });
await gotoAgentsView(page);
await openTeamCatalog(page);
// 1. Browsing another member's publication: list, provenance, per-member
// model. Release Review is not the default selection, so click it.
const releaseReview = `community-catalog-team-${TEST_IDENTITIES.alice.pubkey}:release-review`;
await page.getByTestId(releaseReview).click();
await waitForAnimations(page);
await page.getByTestId("community-catalog-dialog").screenshot({
path: `${SHOTS}/catalog-browse.png`,
});
// 2. Expand the Reviewer member row to show metadata + instruction.
await page.getByTestId("community-catalog-member-expand-reviewer").click();
await waitForAnimations(page);
await page.getByTestId("community-catalog-dialog").screenshot({
path: `${SHOTS}/catalog-member-expanded.png`,
});
// 3. Team instructions section is visible (Release Review has instructions).
// Collapse the expanded member row first so the team-instructions state
// is visually distinct from the expanded-member screenshot above.
await page.getByTestId("community-catalog-member-expand-reviewer").click();
await waitForAnimations(page);
await page.getByTestId("community-catalog-dialog").screenshot({
path: `${SHOTS}/catalog-team-instructions.png`,
});
// 4. Adding closes the dialog and names the team in the notice.
await page.getByTestId("community-catalog-add-team").click();
await expect(
page.getByText("Added Release Review to your teams."),
).toBeVisible();
// 5. Reopened: the action reads "Added to my teams" and is inert, so a
// second copy of the same publication is not offered.
await openTeamCatalog(page);
await page.getByTestId(releaseReview).click();
await expect(page.getByTestId("community-catalog-add-team")).toHaveText(
"Added to my teams",
);
await waitForAnimations(page);
await page.getByTestId("community-catalog-dialog").screenshot({
path: `${SHOTS}/catalog-added.png`,
});
});
test("02 — empty catalog", async ({ page }) => {
await installMockBridge(page, { teamCatalogEvents: [] });
await gotoAgentsView(page);
await openTeamCatalog(page);
await page.getByTestId("community-catalog-dialog").screenshot({
path: `${SHOTS}/catalog-empty.png`,
});
});
test("03 — share dialog before and after publishing", async ({ page }) => {
test.setTimeout(60_000);
await installMockBridge(page, {
personas: [
{
id: "custom:release-analyst",
displayName: "Release Analyst",
systemPrompt: "Summarise the release.",
},
{
id: "custom:release-scribe",
displayName: "Release Scribe",
systemPrompt: "Write the release note.",
},
],
teams: [
{
id: "team-release-010",
name: "Release Crew",
description: "Ships the release notes.",
personaIds: ["custom:release-analyst", "custom:release-scribe"],
},
],
});
await gotoAgentsView(page);
await page.getByLabel("Release Crew team actions").click();
await page.getByRole("menuitem", { name: "Share" }).click();
await expect(page.getByTestId("team-share-dialog")).toBeVisible();
await waitForAnimations(page);
// 4. Catalog access sits alongside the existing snapshot-share controls,
// defaulting to unchecked (not shared).
await page.getByTestId("team-share-dialog").screenshot({
path: `${SHOTS}/share-not-shared.png`,
});
// 5. Published: toggle the Switch to share, the toast names the effect.
await page.getByTestId("team-share-catalog-access").click();
await expect(page.getByTestId("team-share-catalog-access")).toBeChecked();
await expect(
page.getByText("Published Release Crew to the community catalog."),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/share-published.png` });
});
test("04 — both sections populated (agents + teams)", async ({ page }) => {
await installMockBridge(page, {
personaCatalogEvents: PERSONA_CATALOG_EVENTS,
teamCatalogEvents: CATALOG_EVENTS,
});
await gotoAgentsView(page);
await openTeamCatalog(page);
// The Agents section header is visible alongside Teams in the sidebar.
await expect(
page.locator('[data-testid^="community-catalog-agent-"]'),
).toHaveCount(1);
await expect(
page.locator('[data-testid^="community-catalog-team-"]'),
).toHaveCount(2);
await waitForAnimations(page);
await page.getByTestId("community-catalog-dialog").screenshot({
path: `${SHOTS}/catalog-both-sections.png`,
});
});
});