mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
🤖 ## Summary Building on #7488's npub foundation, this PR finishes the identity display change for the controls where you actually manage people and keys: profile, settings, agent access, and workflows. Everywhere in these surfaces, an identity key shows — and copies — as its canonical npub (npub is the human-readable encoding of a Nostr public key: the compact `npub1j57...fjmv` form where space is tight, the full npub where the whole key matters), and accepts npub as input. After this change: - Profile panel: the public-key row and the managed-by / declared-owner copies show the full npub. If a key can't be encoded, you see "Unavailable" with no copy button — never a raw or partial key. - Settings: the identity card shows and copies the npub. The hosted-communities account identity derives from the bound key (`pubkey_hex`) — the same authority as the mismatch gate and hosted operations — so the display can never disagree with what the app acts on; an unusable hex falls back to a neutral label instead of rendering the unverified server npub. The connected claim and a community's Connect action require that same usable bound key to match the local one — with no usable binding the card cannot claim connected or start Connect, while the community list, linking, and delete/rebind recovery stay available. - Hosted create/onboarding: the account and device identity rows in the create flow and owner onboarding derive from the same authoritative fields (bound key / local key), with the same neutral fallback; readiness requires a usable bound key that matches the local one. - Respond-to allowlist (controls who may respond to an agent): entries can be typed or pasted as hex or npub; both spellings of the same key are recognized as one entry and dedupe. Search results, chips, and remove buttons use the compact npub. - Workflow key fields: to/from keys display as npubs in the form and save back as canonical hex. Templates like `{{trigger.author}}`, roles, and free text pass through untouched; placeholders accept both spellings. - Recipient and agent dialogs: the verify popover is npub-only (the raw-hex line is gone); denied-membership screens never show a raw key. - The Rust-side truncated display name (used for native surfaces) shows the same compact npub, so those surfaces match the web UI. Internal representation is unchanged: keys are still stored, sent, and matched as canonical 64-character hex — npub is a display and input spelling, normalized to hex at the boundary, so existing data and integrations keep working. Bound-key usability and comparison use one normalized form (trimmed, lowercased, 64 hex characters; npub rejected), so padded or mixed-case spellings of the same key match. Display names, private keys, and event IDs are untouched. ## Details - `respondToAllowlist` / `RespondToField`: npub entries normalize to canonical hex; cross-form dedupe; compact npub in rows and chips; direct-add accepts npub and stores canonical hex. - `workflowFormTypes` / `WorkflowStepCard`: hex → npub for display, npub → canonical hex on save; templates, roles, and free text pass through in both directions (roundtrip-tested). - `UserProfilePanelFields`, `ProfileSettingsCard`, `HostedCommunitiesSettingsCard`, `MembershipDenied`, `SelectedRecipientChip`, `AddAgentToChannelDialog`: npub display and copy; invalid keys → "Unavailable" with no copy; hosted identity rows derive from the bound `pubkey_hex` (create/onboarding rows from the bound and local keys), never the unverified server npub; connected/readiness/Connect gates use the same usable-bound-key predicate, and the settings Connect invocation callback re-checks it before starting. - `src-tauri/src/commands/identity.rs`: `truncated_display_name` compacts to the first 8 + last 4 characters of the npub (above a 12-char threshold), mirroring `truncateNpub`. - e2e: profile key rows and clipboard polls assert npub forms and raw-hex suppression; the display-screenshots spec pins the npub-only popover; hosted specs drive the real settings card, create flow, and onboarding rows through their real providers, and the unlinked/npub-only identity cases assert no connected claim and no Connect action. ### Related issue - Fixes: N/A. No separate issue; the related work is the stack below. - Stack: builds on #7488 (shared npub foundation), now merged; this PR is rebased onto main and stands on its own. ### Testing At head `303c90ffa` (base: main `bfc38485`; 24 files, +1125/−146): - Focused unit suites (respondToAllowlist, workflowFormTypes, hostedCommunityApi bound-key helpers) green; mutation-checked — dropping allowlist canonicalization fails the dedupe case, and dropping bound-key normalization fails the npub-in-hex and padded same-key cases. - Full desktop unit suite 6,477/6,477, `desktop-typecheck`, `desktop-check` (formatting fixed narrowly with `biome check --write` on the touched files only), and a fresh E2E build at the current head; the add-community + hosted-communities-settings specs 18/18 and onboarding integration 69/69 on a fresh dedicated port, with focused new-case runs 4+4 covering padded same-key (ready, Connect kept — no false rebind) and npub-in-hex (neutral label, recovery, no Connect) across the settings card, create flow, and first-community onboarding, plus the unlinked-account settings regression asserting Connect cannot occur. - `cargo fmt`/clippy (both feature sets) and `cargo test identity` (71 pass) passed at the earlier full-change head; since then, the only production changes in this PR's delta are the hosted identity display authority and its fail-closed bound-key gating/normalization above (base-side fixes carry #7488's receipts) — every other change is test-only. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { npubEncode } from "nostr-tools/nip19";
|
|
|
|
import { truncateNpub } from "../../src/shared/lib/pubkey";
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import {
|
|
installMockBridge,
|
|
openNewMessagePage,
|
|
TEST_IDENTITIES,
|
|
} from "../helpers/bridge";
|
|
|
|
const SHOTS = "test-results/pubkey-display";
|
|
|
|
const AGENT_PUBKEY = "cafef00d".repeat(8);
|
|
|
|
// Screenshot evidence for the pubkey-display work: the canonical profile
|
|
// surfaces, plus the new-DM recipient identity-hover states retained by it.
|
|
|
|
test("profile panel Public key row reveals its copy action on hover", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
const messageRow = page.getByTestId("message-row").first();
|
|
await expect(messageRow).toBeVisible();
|
|
await messageRow.locator("button").first().click();
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
|
|
const pubkeyRow = page.getByTestId("user-profile-public-key");
|
|
const copyIndicator = page.getByTestId("user-profile-public-key-copy-status");
|
|
await expect(page.getByTestId("user-profile-copy-pubkey")).toBeVisible();
|
|
await expect(copyIndicator).toHaveCSS("opacity", "0");
|
|
await pubkeyRow.hover();
|
|
await expect(copyIndicator).toHaveCSS("opacity", "1");
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/profile-panel-pubkey-hover-copy.png`,
|
|
});
|
|
});
|
|
|
|
test("new-DM agent name swaps to its public key on name hover", async ({
|
|
page,
|
|
}) => {
|
|
// Agent rows only surface when the agent is mentionable, so seed a managed
|
|
// agent alongside its search profile.
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
name: "Pinky",
|
|
pubkey: AGENT_PUBKEY,
|
|
status: "running",
|
|
},
|
|
],
|
|
searchProfiles: [
|
|
{
|
|
displayName: "Pinky",
|
|
isAgent: true,
|
|
ownerPubkey: "deadbeef".repeat(8),
|
|
pubkey: AGENT_PUBKEY,
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
|
|
await openNewMessagePage(page);
|
|
await expect(page.getByTestId("new-message-page")).toBeVisible();
|
|
|
|
const agentResult = page.getByTestId(`new-dm-result-${AGENT_PUBKEY}`);
|
|
await expect(agentResult).toBeVisible();
|
|
await expect(page.getByTestId("new-dm-loading")).toBeHidden();
|
|
await expect
|
|
.poll(async () => {
|
|
const marker = crypto.randomUUID();
|
|
await agentResult.evaluate((element, value) => {
|
|
element.dataset.e2eSettleMarker = value;
|
|
}, marker);
|
|
await page.waitForTimeout(250);
|
|
return agentResult
|
|
.evaluate(
|
|
(element, value) => element.dataset.e2eSettleMarker === value,
|
|
marker,
|
|
)
|
|
.catch(() => false);
|
|
})
|
|
.toBe(true);
|
|
|
|
const agentName = agentResult.getByTestId(`new-dm-name-${AGENT_PUBKEY}`);
|
|
const agentNpub = agentResult.getByTestId(`new-dm-npub-${AGENT_PUBKEY}`);
|
|
await expect(agentResult).toContainText("managed by you");
|
|
await expect(agentName).toContainText("Pinky");
|
|
await expect(agentNpub).toHaveCSS("opacity", "0");
|
|
|
|
const agentNameBox = await agentName.boundingBox();
|
|
const agentResultBox = await agentResult.boundingBox();
|
|
expect(agentNameBox).not.toBeNull();
|
|
expect(agentResultBox).not.toBeNull();
|
|
if (!agentNameBox || !agentResultBox) return;
|
|
expect(agentNameBox.width).toBeLessThan(agentResultBox.width / 2);
|
|
await page.mouse.move(
|
|
agentResultBox.x + agentResultBox.width - 12,
|
|
agentNameBox.y + agentNameBox.height / 2,
|
|
);
|
|
await expect(agentNpub).toHaveCSS("opacity", "0");
|
|
|
|
// Acquire fresh locators after the directory queries settle: the result row
|
|
// can be replaced while the initial loading skeleton is transitioning out.
|
|
const settledAgentResult = page.getByTestId(`new-dm-result-${AGENT_PUBKEY}`);
|
|
const settledAgentName = settledAgentResult.getByTestId(
|
|
`new-dm-name-${AGENT_PUBKEY}`,
|
|
);
|
|
const settledAgentNpub = settledAgentResult.getByTestId(
|
|
`new-dm-npub-${AGENT_PUBKEY}`,
|
|
);
|
|
await settledAgentName.hover();
|
|
await expect
|
|
.poll(async () =>
|
|
settledAgentName.evaluate((element) => element.matches(":hover")),
|
|
)
|
|
.toBe(true);
|
|
await expect(settledAgentNpub).not.toHaveCSS("opacity", "0");
|
|
await expect(settledAgentNpub).toHaveText(truncateNpub(AGENT_PUBKEY));
|
|
await expect(
|
|
settledAgentName.getByText("Pinky", { exact: true }),
|
|
).not.toHaveCSS("opacity", "1");
|
|
await expect(settledAgentResult).toContainText("managed by you");
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("new-message-page").screenshot({
|
|
path: `${SHOTS}/new-dm-agent-name-hover.png`,
|
|
});
|
|
});
|
|
|
|
test("selected new-DM recipient can be verified again through search", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
|
|
await openNewMessagePage(page);
|
|
await expect(page.getByTestId("new-message-page")).toBeVisible();
|
|
|
|
const search = page.getByTestId("new-dm-search");
|
|
const charlieResult = page.getByTestId(
|
|
`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
const charlieName = page.getByTestId(
|
|
`new-dm-name-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
const charlieNpub = page.getByTestId(
|
|
`new-dm-npub-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
|
|
// The same name-to-key swap is available in the unfiltered directory,
|
|
// before the user has typed anything.
|
|
await expect(charlieResult).toBeVisible();
|
|
await expect(charlieNpub).toHaveCSS("opacity", "0");
|
|
await charlieName.hover();
|
|
await expect(charlieNpub).toHaveCSS("opacity", "1");
|
|
await expect(charlieNpub).toHaveText(
|
|
truncateNpub(TEST_IDENTITIES.charlie.pubkey),
|
|
);
|
|
await page.mouse.move(1_100, 500);
|
|
await expect(charlieNpub).toHaveCSS("opacity", "0");
|
|
|
|
await search.fill("charlie");
|
|
await expect(charlieResult).toBeVisible();
|
|
await page.keyboard.press("Enter");
|
|
|
|
const charlieChip = page.getByTestId(
|
|
`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
const charlieNameTrigger = page.getByTestId(
|
|
`new-dm-recipient-name-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
const charlieKeyPopover = page.getByTestId(
|
|
`new-dm-selected-key-popover-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
const charliePubkey = page.getByTestId(
|
|
`new-dm-selected-pubkey-${TEST_IDENTITIES.charlie.pubkey}`,
|
|
);
|
|
await expect(charlieChip).toBeVisible();
|
|
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
|
|
await expect(search).toHaveValue("");
|
|
await expect(charlieResult).toHaveCount(0);
|
|
await expect(charlieKeyPopover).toHaveCount(0);
|
|
await charlieChip.hover();
|
|
await expect(charlieKeyPopover).toHaveCount(0);
|
|
await charlieNameTrigger.click();
|
|
await expect(charlieKeyPopover).toBeVisible();
|
|
await expect(charliePubkey).toContainText("npub1");
|
|
// The verify popover is npub-only — the raw hex line is gone.
|
|
await expect(charlieKeyPopover).not.toContainText(
|
|
TEST_IDENTITIES.charlie.pubkey,
|
|
);
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("new-message-page").screenshot({
|
|
path: `${SHOTS}/new-dm-selected-recipient-key.png`,
|
|
});
|
|
|
|
// Full-variant clipboard regression (D1a): the nested copy affordances
|
|
// must write the recipient's complete canonical npub through the real
|
|
// bridge — never the legacy raw hex the popover also shows, and never a
|
|
// truncation. The evidence screenshot above is captured first, so this
|
|
// interaction leaves it untouched.
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
const copyPublicKeyTrigger = charliePubkey.getByRole("button", {
|
|
name: "Copy public key",
|
|
});
|
|
await copyPublicKeyTrigger.click();
|
|
const copyNpubButton = page.getByRole("button", { name: "Copy npub" });
|
|
await copyNpubButton.click();
|
|
await expect
|
|
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
|
.toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey));
|
|
await expect(
|
|
page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }),
|
|
).toBeVisible();
|
|
// Copying is not a dismissal: the nested affordance and the inspection
|
|
// popover it lives in both survive the copy.
|
|
await expect(copyNpubButton).toBeVisible();
|
|
await expect(charlieKeyPopover).toBeVisible();
|
|
|
|
// The inner Escape closes only the nested key popover — the inspection
|
|
// stays open.
|
|
await page.keyboard.press("Escape");
|
|
await expect(copyNpubButton).toHaveCount(0);
|
|
await expect(charlieKeyPopover).toBeVisible();
|
|
|
|
// Keyboard path: after the inner Escape focus returns naturally to the
|
|
// full-key trigger; Space reopens the popover, whose auto-focus lands on
|
|
// Copy npub, and Enter activates it. The sentinel proves this keyboard
|
|
// copy rewrites the clipboard rather than inheriting the value above.
|
|
await expect(copyPublicKeyTrigger).toBeFocused();
|
|
await page.evaluate(() =>
|
|
navigator.clipboard.writeText("keyboard-copy-sentinel"),
|
|
);
|
|
await page.keyboard.press("Space");
|
|
await expect(copyNpubButton).toBeVisible();
|
|
await expect(copyNpubButton).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
await expect
|
|
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
|
.toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey));
|
|
|
|
// Close the reopened nested popover so the inspection popover owns the
|
|
// final Escape; the recipient itself survives both dismissals.
|
|
await page.keyboard.press("Escape");
|
|
await expect(copyNpubButton).toHaveCount(0);
|
|
await page.keyboard.press("Escape");
|
|
await expect(charlieKeyPopover).toHaveCount(0);
|
|
await expect(charlieChip).toBeVisible();
|
|
|
|
await search.fill("charlie");
|
|
await expect(charlieResult).toBeVisible();
|
|
await expect(charlieResult).toHaveAttribute(
|
|
"aria-label",
|
|
"Already added charlie",
|
|
);
|
|
await charlieName.hover();
|
|
await expect(charlieNpub).toHaveCSS("opacity", "1");
|
|
await expect(charlieNpub).toHaveText(
|
|
truncateNpub(TEST_IDENTITIES.charlie.pubkey),
|
|
);
|
|
await expect(charlieName.getByText("charlie", { exact: true })).toHaveCSS(
|
|
"opacity",
|
|
"0",
|
|
);
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(search).toHaveValue("");
|
|
await expect(
|
|
page.locator("button[data-testid^='new-dm-selected-']"),
|
|
).toHaveCount(1);
|
|
await page.mouse.move(1_100, 500);
|
|
await expect(charlieNpub).toHaveCount(0);
|
|
await expect(charlieKeyPopover).toHaveCount(0);
|
|
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("new-message-page").screenshot({
|
|
path: `${SHOTS}/new-dm-selected-recipient.png`,
|
|
});
|
|
|
|
// The To-field guard ignores popover clicks that bubble into the field;
|
|
// a click on the label itself — a physical descendant of the field — must
|
|
// still focus the input and open the recipient picker.
|
|
await page
|
|
.getByTestId("new-message-to-field")
|
|
.getByText("To:", { exact: true })
|
|
.click();
|
|
await expect(search).toBeFocused();
|
|
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
|
|
});
|
|
|
|
test("member removal confirm shows the full npub inline", async ({ page }) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.getByTestId("channel-members-trigger").click();
|
|
await expect(page.getByTestId("members-sidebar")).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("members-sidebar").screenshot({
|
|
path: `${SHOTS}/members-sidebar.png`,
|
|
});
|
|
});
|