Files
buzz/desktop/tests/e2e/pubkey-display-screenshots.spec.ts
Logan Johnson 2226b6f95b fix(desktop): npub identity controls across profile, agents, and workflows (#7489)
🤖
## 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>
2026-09-09 14:08:01 -04:00

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`,
});
});