Files
buzz/desktop/tests/e2e/team-catalog.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

607 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import { hexToBytes } from "@noble/hashes/utils.js";
import { finalizeEvent } from "nostr-tools/pure";
import type { RelayEvent } from "@/shared/api/types";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { seedActiveIdentity } from "../helpers/onboarding";
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.
*
* The catalog read path runs through the shared signature gate (the #4220
* hardening ported into `catalogRelay.ts`), so a discoverable head must carry
* a valid signature over its own contents. Sign with the owner's test key
* rather than stamping a placeholder `sig`, mirroring the persona fixtures in
* `agents.spec.ts`. The signed `id` is content-derived, so callers read it off
* the returned event instead of supplying one. */
function createTeamCatalogEvent(input: {
ownerPubkey: string;
teamDTag: string;
name: string;
description?: string | null;
instructions?: string | null;
members: CatalogMember[];
createdAt?: number;
shared?: boolean;
}): RelayEvent {
const ownerPrivateKey = Object.values(TEST_IDENTITIES).find(
(identity) => identity.pubkey === input.ownerPubkey,
)?.privateKey;
if (!ownerPrivateKey) {
throw new Error(`No test private key for ${input.ownerPubkey}`);
}
return finalizeEvent(
{
created_at: input.createdAt ?? 1_721_750_400,
kind: 30178,
tags: [
["d", input.teamDTag],
...(input.shared === false ? [] : [["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,
})),
}),
},
hexToBytes(ownerPrivateKey),
);
}
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();
}
async function openTeamShareDialog(
page: import("@playwright/test").Page,
teamName: string,
) {
await page.getByLabel(`${teamName} team actions`).click();
await page.getByRole("menuitem", { name: "Share" }).click();
await expect(page.getByTestId("team-share-dialog")).toBeVisible();
}
async function setTeamCatalogAccess(
page: import("@playwright/test").Page,
shared: boolean,
) {
const toggle = page.getByTestId("team-share-catalog-access");
const isChecked = await toggle.isChecked();
if (isChecked !== shared) {
await toggle.click();
}
}
async function listMockTeams(page: import("@playwright/test").Page) {
return page.evaluate(async () => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("Mock invoke bridge is not installed.");
return (await invoke("list_teams")) as Array<{
name: string;
persona_ids: string[];
catalog_source: { owner_pubkey: string; team_d_tag: string } | null;
}>;
});
}
const ALICE_TEAM_D_TAG = "alice-review-crew";
const ALICE_TEAM_MEMBERS: CatalogMember[] = [
{
memberKey: "reviewer",
displayName: "Alice’s Reviewer",
systemPrompt: "Review changes for the whole community.",
},
{
memberKey: "scribe",
displayName: "Alice’s Scribe",
systemPrompt: "Write the summary.",
},
];
test("an unshared kind 30178 head from another member is not offered", async ({
page,
}) => {
await installMockBridge(page, {
teamCatalogEvents: [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice’s Private Crew",
members: ALICE_TEAM_MEMBERS,
shared: false,
}),
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
await expect(
page.locator('[data-testid^="community-catalog-team-"]'),
).toHaveCount(0);
});
test("adding another member's team records its catalog provenance", async ({
page,
}) => {
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
await installMockBridge(page, {
teamCatalogEvents: [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice’s Review Crew",
description: "Two agents that review and summarise.",
members: ALICE_TEAM_MEMBERS,
}),
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
const entry = page.getByTestId(`community-catalog-team-${entryKey}`);
await expect(entry).toContainText("Alice’s Review Crew");
await entry.click();
const detail = page.getByTestId("community-catalog-detail-pane");
await expect(detail).toContainText("Added by alice");
await expect(detail).toContainText("2 members");
await expect(
page.getByTestId("community-catalog-member-reviewer"),
).toContainText("Alice’s Reviewer");
await expect(
page.getByTestId("community-catalog-member-scribe"),
).toContainText("Alice’s Scribe");
await page.getByTestId("community-catalog-add-team").click();
await expect(
page.getByText("Added Alice’s Review Crew to your teams."),
).toBeVisible();
// The copy carries a fresh local id, so only the stored coordinate links it
// back to Alice's publication — that link is what stops a second copy.
const teams = await listMockTeams(page);
const added = teams.find((team) => team.name === "Alice’s Review Crew");
expect(added).toMatchObject({
catalog_source: {
owner_pubkey: TEST_IDENTITIES.alice.pubkey,
team_d_tag: ALICE_TEAM_D_TAG,
},
});
expect(added?.persona_ids).toHaveLength(2);
await openTeamCatalog(page);
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
const addButton = page.getByTestId("community-catalog-add-team");
await expect(addButton).toHaveText("Added to my teams");
await expect(addButton).toBeDisabled();
expect(
(await listMockTeams(page)).filter(
(team) => team.name === "Alice’s Review Crew",
),
).toHaveLength(1);
});
test("a head that moved while the dialog was open is rejected", async ({
page,
}) => {
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
await installMockBridge(page, {
teamCatalogEvents: [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice’s Review Crew",
members: ALICE_TEAM_MEMBERS,
}),
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
// Republish the coordinate without notifying subscribers: the dialog keeps
// rendering — and keeps holding — the superseded event id.
await page.evaluate(
({ ownerPubkey, teamDTag }) => {
const replace = (
window as Window & {
__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (
event: unknown,
) => void;
}
).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__;
if (!replace) throw new Error("Team catalog head seam is not installed.");
replace({
id: "3".repeat(64),
pubkey: ownerPubkey,
created_at: 1_721_760_400,
kind: 30178,
tags: [
["d", teamDTag],
["shared", "true"],
],
content: JSON.stringify({
v: 1,
name: "Alice’s Review Crew",
description: null,
instructions: null,
members: [],
}),
sig: "2".repeat(128),
});
},
{ ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG },
);
await page.getByTestId("community-catalog-add-team").click();
await expect(
page.getByText(
"This team was updated since you opened the catalog. Reopen it and try again.",
),
).toBeVisible();
expect(
(await listMockTeams(page)).filter(
(team) =>
team.catalog_source !== null && team.catalog_source !== undefined,
),
).toHaveLength(0);
});
test("at an equal timestamp the lower-id head is canonical and a superseding head is rejected as stale", async ({
page,
}) => {
// Two signed events for the same coordinate at identical created_at. Both
// carry valid signatures (the read path verifies them), so the relay's
// tie-break decides: `created_at DESC, id ASC` makes the lower-id event
// canonical. Because the signed `id` is content-derived, we cannot pin it to
// a literal — we sign both, sort by id, and derive the expected canonical
// name from whichever id sorts first.
const SAME_TIMESTAMP = 1_721_760_000;
const crew = createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice's Review Crew",
members: ALICE_TEAM_MEMBERS,
createdAt: SAME_TIMESTAMP,
});
const crewV2 = createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice's Review Crew v2",
members: [],
createdAt: SAME_TIMESTAMP,
});
const [lower] = [crew, crewV2].sort((left, right) =>
left.id.localeCompare(right.id),
);
const canonicalName = JSON.parse(lower.content).name as string;
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
await installMockBridge(page, { teamCatalogEvents: [crew, crewV2] });
await gotoAgentsView(page);
await openTeamCatalog(page);
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
// The lower-id event was selected as canonical, so its name is what renders.
await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
canonicalName,
);
// Republish the coordinate with a strictly newer head, without notifying
// subscribers: the dialog keeps holding the superseded id. The add must be
// rejected as stale.
await page.evaluate(
({ ownerPubkey, teamDTag }) => {
const replace = (
window as Window & {
__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (
event: unknown,
) => void;
}
).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__;
if (!replace) throw new Error("Team catalog head seam is not installed.");
replace({
id: "9".repeat(64),
pubkey: ownerPubkey,
created_at: 1_721_760_400,
kind: 30178,
tags: [
["d", teamDTag],
["shared", "true"],
],
content: JSON.stringify({
v: 1,
name: "Alice's Review Crew v2",
description: null,
instructions: null,
members: [],
}),
sig: "2".repeat(128),
});
},
{ ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG },
);
await page.getByTestId("community-catalog-add-team").click();
await expect(
page.getByText(
"This team was updated since you opened the catalog. Reopen it and try again.",
),
).toBeVisible();
});
test("sharing a team publishes it to the catalog and unsharing retracts it", async ({
page,
}) => {
// The own head is published through the same signature-verified read path as
// foreign heads, so the viewer must hold a real key to sign it — seed one.
await seedActiveIdentity(page, TEST_IDENTITIES.tyler);
await installMockBridge(page, {
personas: [
{
id: "custom:release-analyst",
displayName: "Release Analyst",
systemPrompt: "Summarise the release.",
},
],
teams: [
{
id: "team-release-010",
name: "Release Crew",
description: "Ships the release notes.",
personaIds: ["custom:release-analyst"],
},
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
await page.keyboard.press("Escape");
await openTeamShareDialog(page, "Release Crew");
const catalogAccess = page.getByTestId("team-share-catalog-access");
await expect(catalogAccess).not.toBeChecked();
await setTeamCatalogAccess(page, true);
await expect(catalogAccess).toBeChecked();
await expect(
page.getByText("Published Release Crew to the community catalog."),
).toBeVisible();
await page
.getByTestId("team-share-dialog")
.getByRole("button", { name: "Close" })
.click();
await openTeamCatalog(page);
const ownEntry = page.locator('[data-testid^="community-catalog-team-"]');
await expect(ownEntry).toHaveCount(1);
await ownEntry.click();
await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Added by You",
);
// The publisher already has the team, so the catalog must not offer a copy.
await expect(page.getByTestId("community-catalog-add-team")).toBeDisabled();
await page.keyboard.press("Escape");
await openTeamShareDialog(page, "Release Crew");
await setTeamCatalogAccess(page, false);
await expect(
page.getByText(
"Release Crew is no longer discoverable in the community catalog.",
),
).toBeVisible();
await page
.getByTestId("team-share-dialog")
.getByRole("button", { name: "Close" })
.click();
await openTeamCatalog(page);
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
});
test("a queued team share is not presented as relay-published", async ({
page,
}) => {
await installMockBridge(page, {
personas: [
{
id: "custom:queued-analyst",
displayName: "Queued Analyst",
systemPrompt: "Wait for relay acceptance.",
},
],
teams: [
{
id: "team-queued-011",
name: "Queued Crew",
description: null,
personaIds: ["custom:queued-analyst"],
},
],
teamSharePublicationStatuses: ["queued"],
});
await gotoAgentsView(page);
await openTeamShareDialog(page, "Queued Crew");
await setTeamCatalogAccess(page, true);
await expect(
page.getByText(
"Sharing Queued Crew is queued. It will appear after the relay accepts the update.",
),
).toBeVisible();
await page
.getByTestId("team-share-dialog")
.getByRole("button", { name: "Close" })
.click();
await openTeamCatalog(page);
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
});
test("expanding a member row reveals its metadata and instruction", async ({
page,
}) => {
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
await installMockBridge(page, {
teamCatalogEvents: [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice's Review Crew",
members: [
{
memberKey: "reviewer",
displayName: "Alice's Reviewer",
systemPrompt: "Review changes for the whole community.",
model: "claude-sonnet-4-5",
runtime: "claude-code",
provider: "anthropic",
},
],
}),
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
const memberRow = page.getByTestId("community-catalog-member-reviewer");
await expect(memberRow).toBeVisible();
// Metadata card and instruction are hidden before expansion.
await expect(memberRow.getByTestId("agent-definition-metadata")).toBeHidden();
// Expand the row.
await page.getByTestId("community-catalog-member-expand-reviewer").click();
// aria-expanded transitions to true.
await expect(
page.getByTestId("community-catalog-member-expand-reviewer"),
).toHaveAttribute("aria-expanded", "true");
// Metadata card is now visible and contains model/runtime/provider.
const metadata = memberRow.getByTestId("agent-definition-metadata");
await expect(metadata).toBeVisible();
await expect(metadata).toContainText("claude-sonnet-4-5");
await expect(metadata).toContainText("claude-code");
await expect(metadata).toContainText("anthropic");
// Instruction text is visible.
await expect(memberRow).toContainText(
"Review changes for the whole community.",
);
});
test("expanding a member with no system prompt shows the no-instructions placeholder", async ({
page,
}) => {
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
await installMockBridge(page, {
teamCatalogEvents: [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice's Review Crew",
members: [
{
memberKey: "reviewer",
displayName: "Alice's Reviewer",
systemPrompt: "",
},
],
}),
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
await page.getByTestId("community-catalog-member-expand-reviewer").click();
await expect(
page.getByTestId("community-catalog-member-reviewer"),
).toContainText("No instructions");
});
test("team instructions section is visible when the team has instructions set", async ({
page,
}) => {
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
await installMockBridge(page, {
teamCatalogEvents: [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice's Review Crew",
instructions: "Always check for security issues first.",
members: ALICE_TEAM_MEMBERS,
}),
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
const detail = page.getByTestId("community-catalog-detail-pane");
await expect(detail).toContainText("Team instructions");
await expect(detail).toContainText("Always check for security issues first.");
});
test("team instructions section is absent when instructions are not set", async ({
page,
}) => {
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
await installMockBridge(page, {
teamCatalogEvents: [
createTeamCatalogEvent({
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
teamDTag: ALICE_TEAM_D_TAG,
name: "Alice's Review Crew",
members: ALICE_TEAM_MEMBERS,
}),
],
});
await gotoAgentsView(page);
await openTeamCatalog(page);
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
const detail = page.getByTestId("community-catalog-detail-pane");
await expect(detail).not.toContainText("Team instructions");
});