Refresh desktop onboarding flow (#7528)

## Summary
- apply the refreshed card layout across desktop onboarding
- update identity backup, recovery, and key presentation flows
- clarify AI connection choices and default model settings
- simplify the backup ceremony from creation through optional
verification
- bring the harness and provider setup flow forward with validation,
preserved state, and consistent navigation
- fix action alignment and stray card scrolling in the refreshed layout

## Validation
- pre-push desktop lint, typecheck, frontend tests, and native tests
passed
- focused onboarding Playwright coverage passed
- manually reviewed the refreshed onboarding and backup flow in the
native app
- just ci passed all sections except one unchanged process-cleanup
timing test; its exact rerun passed

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Clay Delk <clay.delk@gmail.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Clay Delk <clay.delk@gmail.com>
This commit is contained in:
klopez4212
2026-09-15 05:58:16 -04:00
committed by GitHub
parent 7c789dee09
commit 81fba7bda4
59 changed files with 4901 additions and 2343 deletions

View File

@@ -193,6 +193,17 @@ test.describe("animated avatar", () => {
await expect(
page.getByTestId("profile-avatar-animated-poster-selector"),
).toBeVisible();
const selectorBox = await page
.getByTestId("profile-avatar-animated-poster-selector")
.boundingBox();
const posterStripBox = await page
.getByTestId("profile-avatar-animated-poster-scrubber")
.boundingBox();
if (!selectorBox || !posterStripBox) {
throw new Error("Animated avatar poster selector bounds are missing.");
}
expect(selectorBox.x - posterStripBox.x).toBeGreaterThanOrEqual(8);
expect(selectorBox.y - posterStripBox.y).toBeGreaterThanOrEqual(8);
await expect(
page.getByTestId("profile-avatar-animated-review-help"),
).toHaveText("Pick the still shown before hover.");

View File

@@ -21,7 +21,6 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { passThroughBackupStep } from "../helpers/onboarding";
// ── Shared catalog fixtures ───────────────────────────────────────────────────
@@ -618,73 +617,3 @@ test("not-ready custom harness row shows status, no install action", async ({
page.getByTestId("doctor-runtime-ready-my-custom-agent"),
).toHaveCount(0);
});
// ── F8: onboarding navigate-after-complete ────────────────────────────────────
//
// Verifies the parent-owned route intent introduced in B-8:
// 1. User reaches the machine-onboarding setup page.
// 2. Clicks "More harnesses" (onboarding-setup-more-harnesses).
// 3. App completes onboarding and immediately navigates to Settings → Agents.
//
// This test exercises the real App.tsx effect that gates router.navigate() on
// machine.stage === "ready", which the pure-logic tests in
// postOnboardingNav.test.mjs cannot cover (they simulate the predicate, not
// the real render path).
test("onboarding setup More-harnesses click navigates to Settings → Agents", async ({
page,
}) => {
// Start with a fresh machine (no machine-onboarding-complete flag).
// skipCommunitySeed: true so the user goes through machine onboarding.
// skipOnboardingSeed: true so the community/identity banner doesn't appear.
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
// Seed a community stamped with a *foreign* pubkey. This is the only shape
// that satisfies both preconditions of this test at once:
// - machine onboarding must still run, so the community must NOT vouch for
// the active identity (migrateMachineOnboardingCompletion only accepts a
// community whose recorded pubkey matches — see machineOnboarding.ts:70).
// - after onboarding completes, useCommunityInit must NOT report
// needsSetup, or App.tsx:499 renders WelcomeSetup instead of the router
// and the navigation lands on a screen that has no settings tree.
// The default seed vouches (it uses the active pubkey) and skipping it
// entirely leaves zero communities, so neither default gets there.
await page.addInitScript(() => {
const communityId = "e2e-default-community";
window.localStorage.setItem(
"buzz-communities",
JSON.stringify([
{
id: communityId,
name: "E2E Test",
relayUrl: "ws://127.0.0.1:7777",
pubkey: "f".repeat(64),
addedAt: new Date().toISOString(),
},
]),
);
window.localStorage.setItem("buzz-active-community-id", communityId);
});
await page.goto("/");
// Reach setup by creating a new identity key and continuing past the
// created-key page without opening the optional backup options.
await page.getByRole("button", { name: "Create a new identity key" }).click();
await passThroughBackupStep(page);
// Now on the setup page.
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
).toBeVisible({ timeout: 10_000 });
// Click the "More harnesses" link — fires navigateToAgentSettings.
await page.getByTestId("onboarding-setup-more-harnesses").click();
// After onboarding completes + router mounts, the app must land on
// Settings → Agents (consolidated harnesses section visible).
await expect(page.getByTestId("settings-harnesses")).toBeVisible({
timeout: 15_000,
});
});

View File

@@ -32,13 +32,8 @@ test("identity key help explains the first-run choice", async ({ page }) => {
await expect(
dialog.getByRole("heading", { name: "What’s an identity key?" }),
).toBeVisible();
await expect(dialog).toHaveClass(/shadow-none/);
await expect(page.getByTestId("dialog-overlay")).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
const dialogWrapper = dialog.locator("..");
await expect(dialogWrapper).toHaveCSS("overflow-x", "hidden");
await expect(dialog).toHaveClass(/w-full/);
await expect(page.getByTestId("dialog-overlay")).toHaveCount(0);
const dialogBounds = await dialog.boundingBox();
expect(dialogBounds).not.toBeNull();
expect(dialogBounds?.x).toBeGreaterThanOrEqual(0);
@@ -46,7 +41,7 @@ test("identity key help explains the first-run choice", async ({ page }) => {
(dialogBounds?.x ?? 0) + (dialogBounds?.width ?? 0),
).toBeLessThanOrEqual(720);
await page.keyboard.press("Escape");
await page.getByTestId("onboarding-back").click();
await expect(dialog).not.toBeVisible();
await expect(trigger).toHaveCSS("opacity", "1");

View File

@@ -2,6 +2,7 @@ import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
test("normal first launch uses the already-persisted identity", async ({
@@ -24,10 +25,11 @@ test("normal first launch uses the already-persisted identity", async ({
page.getByRole("button", { name: "Create a new identity key" }),
).toHaveCSS("background-color", "rgb(23, 23, 23)");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByRole("button", { name: "Create my private key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
name: "Your private identity key",
}),
).toBeVisible();
// Non-landing pages layer the dot grid over the chartreuse→light-blue gradient.
@@ -111,12 +113,14 @@ test("lost boot offers phone recovery with a single-use QR", async ({
await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(
page.getByText("Scan this code with a signed-in Buzz phone."),
page.getByText(
"Scan this code with a device where you’re currently signed in to Buzz.",
),
).toBeVisible();
await expect(
page.getByText("On your phone, open Settings → Send identity to desktop."),
).toBeVisible();
await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle.
).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({
path: testInfo.outputPath("desktop-phone-recovery-qr.png"),
fullPage: true,
@@ -169,20 +173,39 @@ test("phone recovery uses the desktop pairing card semantics", async ({
await page.getByTestId("nostr-import-phone-link").click();
const card = page.getByTestId("identity-recovery-pairing");
const stage = page.getByTestId("identity-recovery-stage");
const qrContainer = card.getByTestId("identity-recovery-qr-container");
const qrCode = card.getByTestId("identity-recovery-qr");
const copyButton = card.getByTestId("copy-identity-recovery-code");
await expect(qrCode).toBeVisible();
await expect(card).toHaveCSS("border-top-width", "0px");
await expect(qrContainer).toHaveCSS("border-top-width", "0px");
await expect(card).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(qrContainer).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-name",
"buzz-qr-cell-reveal",
);
const qrBox = await qrContainer.boundingBox();
const copyBox = await copyButton.boundingBox();
await waitForAnimations(page);
const [stageBox, cardBox, qrBox, copyBox] = await Promise.all([
stage.boundingBox(),
card.boundingBox(),
qrContainer.boundingBox(),
copyButton.boundingBox(),
]);
expect(stageBox).not.toBeNull();
expect(cardBox).not.toBeNull();
expect(qrBox).not.toBeNull();
expect(copyBox).not.toBeNull();
expect(
Math.abs(
(cardBox?.y ?? 0) +
(cardBox?.height ?? 0) / 2 -
((stageBox?.y ?? 0) + (stageBox?.height ?? 0) / 2),
),
).toBeLessThan(1);
expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(1);
expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1);
@@ -204,9 +227,7 @@ test("phone recovery uses the desktop pairing card semantics", async ({
"This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.",
),
).toBeVisible();
await expect(
card.getByText(/On your phone, open Settings/),
).not.toBeVisible();
await expect(card.getByText(/On your phone, open Settings/)).toHaveCount(0);
await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456");
await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText(
"Codes match",
@@ -282,12 +303,12 @@ test("phone recovery continues to harness setup without creating or restarting",
});
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
page.getByRole("heading", { name: "Connect your AI provider" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
name: "Your private identity key",
}),
).toHaveCount(0);
});

View File

@@ -6,10 +6,6 @@ import { installMockBridge } from "../helpers/bridge";
const SAMPLE_NSEC =
"nsec1u70xptkumvfc4k4hu0rc4fnzcexvw63zvq2ng9vmqujsaayhparqu8eju9";
// --buzz-onboarding-backup-ink (#717106), the olive key ink shared with the
// backup step.
const BACKUP_INK = "rgb(113, 113, 6)";
test("key import masks the key with a reveal toggle", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await installMockBridge(page, undefined, {
@@ -23,12 +19,15 @@ test("key import masks the key with a reveal toggle", async ({ page }) => {
await expect(input).toBeVisible();
await waitForAnimations(page);
// Masked by default; no toggle until there is input; key text uses the
// shared backup ink.
// Masked by default; no toggle until there is input. The refreshed card
// keeps key text on the standard foreground token.
const toggle = page.getByTestId("nostr-import-reveal-toggle");
await expect(input).toHaveAttribute("type", "password");
await expect(toggle).toHaveCSS("opacity", "0");
await expect(input).toHaveCSS("color", BACKUP_INK);
await expect(input).toHaveAttribute(
"class",
/text-\[oklch\(0\.22213_0_0\)\]/,
);
// The toggle is absolutely positioned: its appearance must not resize the
// input or shift the centered text.

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,12 @@
import { expect, test } from "@playwright/test";
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import { expectEmojiMartStylesInstalled } from "../helpers/css";
import {
expectEmojiMartStylesInstalled,
expectSmoothCorners,
} from "../helpers/css";
import { installFakeCamera } from "../helpers/fakeCamera";
import { seedActiveIdentity } from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
@@ -12,6 +16,25 @@ const BLANK_TYLER_IDENTITY = {
const SHOTS = "test-results/screenshots-onboarding";
async function selectFirstEmojiFromPicker(page: Page) {
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await expect
.poll(() =>
picker.evaluate((element) =>
Boolean(element.shadowRoot?.querySelector(".scroll button")),
),
)
.toBe(true);
await picker.evaluate((element) => {
const button = element.shadowRoot?.querySelector(".scroll button");
if (!(button instanceof HTMLElement)) {
throw new Error("Emoji picker did not render an emoji button.");
}
button.click();
});
}
test("avatar step always shows Skip for now button without an error", async ({
page,
}) => {
@@ -29,6 +52,16 @@ test("avatar step always shows Skip for now button without an error", async ({
await expect(skipBtn).toBeVisible();
await expect(skipBtn).toBeEnabled();
await expect(skipBtn).toHaveText("Skip for now");
const nextBtn = page.getByTestId("onboarding-next");
const [skipRadius, nextRadius] = await Promise.all([
skipBtn.evaluate(
(element) => window.getComputedStyle(element).borderRadius,
),
nextBtn.evaluate(
(element) => window.getComputedStyle(element).borderRadius,
),
]);
expect(skipRadius).toBe(nextRadius);
// Capture the whole viewport: the Skip/Next/Back CTAs are portaled into the
// docked footer (a sibling of the step subtree), so a section-scoped shot
@@ -39,7 +72,7 @@ test("avatar step always shows Skip for now button without an error", async ({
});
});
test("avatar step shares the profile emoji picker controls", async ({
test("avatar step uses the compact prototype emoji picker", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -48,31 +81,267 @@ test("avatar step shares the profile emoji picker controls", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await page.getByRole("tab", { name: "Emoji" }).click();
await page.getByTestId("onboarding-avatar-mode-emoji").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
await expectEmojiMartStylesInstalled(picker);
await expect(page.getByTestId("onboarding-avatar-emoji-picker")).toHaveCSS(
"height",
"384px",
"420px",
);
const segmentControl = page.getByTestId("onboarding-avatar-mode-control");
await expect(segmentControl).toHaveAttribute(
"data-slot",
"segmented-control",
);
const surfaceColors = await Promise.all([
segmentControl.evaluate(
(element) => window.getComputedStyle(element).backgroundColor,
),
page
.getByTestId("onboarding-avatar-emoji-picker")
.evaluate((element) => window.getComputedStyle(element).backgroundColor),
]);
expect(surfaceColors[0]).toBe(surfaceColors[1]);
const activeSegmentColor = await page
.getByTestId("onboarding-avatar-mode-indicator")
.evaluate((element) => window.getComputedStyle(element).backgroundColor);
const cardColor = await page
.getByTestId("onboarding-content-card")
.evaluate((element) => window.getComputedStyle(element).backgroundColor);
expect(activeSegmentColor).toBe(cardColor);
await expectSmoothCorners(page.getByTestId("onboarding-avatar-emoji-picker"));
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/04-avatar-compact-emoji.png`,
});
const controlHeights = await picker.evaluate((element) => {
const pickerGeometry = await picker.evaluate((element) => {
const input = element.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
const toneControl =
element.shadowRoot?.querySelector<HTMLElement>(".search + .flex");
if (!input || !toneControl) {
const firstEmojiButton =
element.shadowRoot?.querySelector<HTMLElement>("[aria-posinset]");
const firstEmojiRow =
element.shadowRoot?.querySelector<HTMLElement>(".row");
const categoryLabel =
element.shadowRoot?.querySelector<HTMLElement>(".category .sticky");
const searchHeader = element.shadowRoot?.querySelector<HTMLElement>(
"#root > .padding-lr",
);
const scroll = element.shadowRoot?.querySelector<HTMLElement>(".scroll");
const toneButton =
element.shadowRoot?.querySelector<HTMLElement>(".skin-tone-button");
const nav = element.shadowRoot?.querySelector<HTMLElement>("#nav");
if (
!input ||
!toneControl ||
!firstEmojiButton ||
!firstEmojiRow ||
!categoryLabel ||
!searchHeader ||
!scroll ||
!toneButton
) {
throw new Error("Onboarding emoji picker controls did not render.");
}
return {
categoryLabelBackground:
window.getComputedStyle(categoryLabel).backgroundColor,
categoryLabelDisplay: window.getComputedStyle(categoryLabel).display,
categoryLabelPosition: window.getComputedStyle(categoryLabel).position,
categoryLabelZIndex: window.getComputedStyle(categoryLabel).zIndex,
inputRadius: window.getComputedStyle(input).borderRadius,
input: input.getBoundingClientRect().height,
firstEmojiButton: firstEmojiButton.getBoundingClientRect().height,
firstEmojiRowItems: firstEmojiRow.children.length,
navDisplay: nav ? window.getComputedStyle(nav).display : "absent",
searchHeaderBottomPadding:
window.getComputedStyle(searchHeader).paddingBottom,
searchHeaderSidePadding:
window.getComputedStyle(searchHeader).paddingLeft,
scrollTopPadding: window.getComputedStyle(scroll).paddingTop,
tone: toneControl.getBoundingClientRect().height,
toneButtonRadius: window.getComputedStyle(toneButton).borderRadius,
toneControlRadius: window.getComputedStyle(toneControl).borderRadius,
};
});
expect(controlHeights).toEqual({ input: 48, tone: 48 });
expect(pickerGeometry).toEqual({
categoryLabelBackground: "rgb(245, 245, 245)",
categoryLabelDisplay: "block",
categoryLabelPosition: "sticky",
categoryLabelZIndex: "5",
firstEmojiButton: 72,
firstEmojiRowItems: 6,
input: 40,
inputRadius: "8px",
navDisplay: "absent",
searchHeaderBottomPadding: "8px",
searchHeaderSidePadding: "8px",
scrollTopPadding: "0px",
tone: 40,
toneButtonRadius: "4px",
toneControlRadius: "8px",
});
await picker.evaluate((element) => {
const toneButton =
element.shadowRoot?.querySelector<HTMLButtonElement>(".skin-tone-button");
if (!toneButton) throw new Error("Skin tone button did not render.");
toneButton.click();
});
await expect
.poll(() =>
picker.evaluate((element) => {
const menu = element.shadowRoot?.querySelector<HTMLElement>(".menu");
if (!menu) return null;
const style = window.getComputedStyle(menu);
const rect = menu.getBoundingClientRect();
const pickerRect = element.getBoundingClientRect();
return {
bottomInsidePicker: rect.bottom <= pickerRect.bottom,
opacity: style.opacity,
topInsidePicker: rect.top >= pickerRect.top,
zIndex: style.zIndex,
};
}),
)
.toEqual({
bottomInsidePicker: true,
opacity: "1",
topInsidePicker: true,
zIndex: "7",
});
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/05-avatar-skin-tone-menu.png`,
});
await picker.evaluate((element) => {
const selectedTone =
element.shadowRoot?.querySelector<HTMLButtonElement>(".menu .option");
const scroll = element.shadowRoot?.querySelector<HTMLElement>(".scroll");
if (!selectedTone || !scroll) {
throw new Error("Emoji picker scroll state did not render.");
}
selectedTone.click();
scroll.scrollTop = 48;
scroll.dispatchEvent(new Event("scroll"));
});
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/06-avatar-sticky-category.png`,
});
});
test("avatar step keeps a stable card and compact horizontal navigation", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installFakeCamera(page);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
const card = page.getByTestId("onboarding-content-card");
await expect(card).toBeVisible();
const profileCardWidth = await card.evaluate(
(element) => element.clientWidth,
);
expect(profileCardWidth).toBe(610);
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
const modeShell = page.getByTestId("onboarding-avatar-mode-content-shell");
const cardWidths = [await card.evaluate((element) => element.clientWidth)];
expect(cardWidths[0]).toBeGreaterThan(profileCardWidth);
const skipBox = await page.getByTestId("onboarding-skip").boundingBox();
const nextBox = await page.getByTestId("onboarding-next").boundingBox();
if (!skipBox || !nextBox) throw new Error("Avatar navigation is missing.");
expect(skipBox.y).toBeCloseTo(nextBox.y, 0);
expect(skipBox.x + skipBox.width).toBeLessThanOrEqual(nextBox.x);
const imageShellBox = await modeShell.boundingBox();
const uploadBox = await page
.getByTestId("onboarding-avatar-upload")
.boundingBox();
const urlBox = await page
.getByTestId("onboarding-avatar-url")
.locator("..")
.boundingBox();
if (!imageShellBox || !uploadBox || !urlBox) {
throw new Error("Image controls are missing.");
}
expect(imageShellBox.height).toBeCloseTo(420, 0);
expect(uploadBox.height + 12 + urlBox.height).toBeCloseTo(
imageShellBox.height,
0,
);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-avatar-actions.png` });
await page.getByTestId("onboarding-avatar-mode-emoji").click();
cardWidths.push(await card.evaluate((element) => element.clientWidth));
await page.getByTestId("onboarding-avatar-mode-animated").click();
cardWidths.push(await card.evaluate((element) => element.clientWidth));
expect(new Set(cardWidths).size).toBe(1);
const iphoneBox = await page
.getByTestId("onboarding-avatar-animated-camera-iphone")
.boundingBox();
const computerBox = await page
.getByTestId("onboarding-avatar-animated-camera-computer")
.boundingBox();
if (!iphoneBox || !computerBox) {
throw new Error("Animated camera options are missing.");
}
expect(iphoneBox.x).toBeCloseTo(computerBox.x, 0);
expect(iphoneBox.width).toBeCloseTo(computerBox.width, 0);
expect(iphoneBox.y + iphoneBox.height).toBeLessThan(computerBox.y);
expect(iphoneBox.height).toBeCloseTo(computerBox.height, 0);
expect(iphoneBox.height + 12 + computerBox.height).toBeCloseTo(420, 0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-avatar-animated.png` });
await page.getByTestId("onboarding-avatar-animated-camera-computer").click();
const recordButton = page.getByTestId("onboarding-avatar-animated-record");
await expect(recordButton).toBeVisible({ timeout: 10_000 });
await waitForAnimations(page);
const liveShellBox = await modeShell.boundingBox();
const liveIphoneBox = await page
.getByTestId("onboarding-avatar-animated-camera-iphone")
.boundingBox();
const liveComputerBox = await page
.getByTestId("onboarding-avatar-animated-camera-computer")
.boundingBox();
const recordBox = await recordButton.boundingBox();
if (!liveShellBox || !liveIphoneBox || !liveComputerBox || !recordBox) {
throw new Error("Live animated-avatar controls are missing.");
}
expect(liveShellBox.height).toBeCloseTo(420, 0);
expect(liveIphoneBox.height).toBeCloseTo(liveComputerBox.height, 0);
expect(liveIphoneBox.height).toBeLessThan(iphoneBox.height);
expect(recordBox.y + recordBox.height).toBeLessThanOrEqual(
liveShellBox.y + liveShellBox.height,
);
await page.screenshot({ path: `${SHOTS}/02b-avatar-animated-live.png` });
await page.getByTestId("onboarding-avatar-mode-emoji").click();
await selectFirstEmojiFromPicker(page);
await page.getByTestId("onboarding-avatar-custom-color").click();
const spectrum = page.getByTestId("onboarding-avatar-custom-color-spectrum");
await expect(spectrum).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
const spectrumBox = await spectrum.boundingBox();
if (!spectrumBox) throw new Error("Custom color spectrum is missing.");
expect(spectrumBox.height).toBeGreaterThan(300);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-avatar-compact-color.png` });
});
test("avatar step skip button completes community profile setup", async ({

View File

@@ -7,25 +7,75 @@ import {
startWindowFileDrag,
} from "../helpers/fileDrag";
async function enterMachineBackup(page: import("@playwright/test").Page) {
await installMockBridge(page, undefined, {
async function enterMachineBackup(
page: import("@playwright/test").Page,
mock?: Parameters<typeof installMockBridge>[1],
) {
await installMockBridge(page, mock, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByRole("button", { name: "Create my private key" }).click();
}
async function openBackupOptions(page: import("@playwright/test").Page) {
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-options-link").click();
test("fresh-key path explains the identity key before creating it", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
await expect(
page.getByTestId("onboarding-page-backup-options"),
page.getByRole("heading", { name: "Create a private identity key" }),
).toBeVisible();
}
await expect(
page.getByTestId("onboarding-key-guidance").locator("p"),
).toHaveText([
"Stored securely on this device",
"Never share it—anyone with this key can sign in as you",
"Use a secure backup to recover your account",
]);
await expect(page.getByTestId("onboarding-page-backup")).toHaveCount(0);
await page.getByRole("button", { name: "Create my private key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
});
test("identity creation failures stay visible on the intro page", async ({
page,
}) => {
await installMockBridge(
page,
{
identityReadErrorAfter: {
message: "Keychain is unavailable",
successfulReads: 1,
},
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByRole("button", { name: "Create my private key" }).click();
await expect(page.getByTestId("identity-key-create-error")).toContainText(
"Keychain is unavailable",
);
await expect(
page.getByRole("button", { name: "Create my private key" }),
).toBeEnabled();
await expect(page.getByTestId("onboarding-page-key-intro")).toBeVisible();
});
async function openPasswordBackup(page: import("@playwright/test").Page) {
await openBackupOptions(page);
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
}
@@ -65,7 +115,7 @@ test("backup step appears on fresh-key path after profile submit", async ({
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
name: "Your private identity key",
}),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
@@ -73,11 +123,10 @@ test("backup step appears on fresh-key path after profile submit", async ({
});
// ---------------------------------------------------------------------------
// Key-created view: masked key with reveal toggle. Backup options open the
// dark security view; the raw key is fetched only on explicit reveal/copy.
// Key-created view: masked key with explicit Reveal and Copy actions.
// ---------------------------------------------------------------------------
test("key view reveals explicitly; options copy explicitly", async ({
test("key view keeps the secret masked until Reveal and Copy stay explicit", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
@@ -85,41 +134,53 @@ test("key view reveals explicitly; options copy explicitly", async ({
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
// Masked by default: decorative mask only, no key material in the DOM.
const key = page.getByTestId("backup-key-value");
await expect(key).toBeVisible();
await expect(key).toHaveClass(/blur/);
await expect(key).not.toContainText("nsec1");
await expect(key).not.toContainText("nsec1mock");
expect(await invokedCommands(page)).not.toContain("get_nsec");
// Reveal fetches the key; box must not reflow (same-length monospace mask).
await page.getByTestId("backup-key-reveal-toggle").click();
// The credential enters the DOM only after an explicit reveal action.
await page.getByTestId("backup-reveal-key").click();
await expect(key).toContainText("nsec1mock");
await expect(key).toHaveClass(/select-text/);
expect(await invokedCommands(page)).toContain("get_nsec");
await page.getByTestId("backup-reveal-key").click();
await expect(key).not.toContainText("nsec1mock");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` });
// Hide again.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).not.toContainText("nsec1");
// Copy is available only after opening the dark backup-options view.
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
// Copy remains a separate explicit action and may reuse the in-memory key.
await page.getByTestId("backup-copy-key").click();
await expect(page.getByTestId("backup-copy-key")).toContainText(
"Copied to clipboard",
);
await expect
.poll(async () => invokedCommands(page))
.toContain("copy_text_to_clipboard");
expect(await invokedCommands(page)).toContain("get_nsec");
await expect(key).not.toContainText("nsec1mock");
// Return restores the yellow key-created view; its Next skips backup and
// continues directly to setup.
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// The backup action gains a subtle surface on hover without shifting its
// content or changing the resting state.
const backupOption = page.getByTestId("backup-option-password");
const backupOptionBox = await backupOption.boundingBox();
const [backupIconBox, backupChevronBox] = await Promise.all([
backupOption.locator("svg").first().boundingBox(),
backupOption.locator("svg").last().boundingBox(),
]);
if (!backupOptionBox || !backupIconBox || !backupChevronBox) {
throw new Error("Could not measure locked-backup row padding");
}
expect(backupIconBox.x - backupOptionBox.x).toBeGreaterThanOrEqual(12);
expect(
backupOptionBox.x +
backupOptionBox.width -
(backupChevronBox.x + backupChevronBox.width),
).toBeGreaterThanOrEqual(12);
await expect(backupOption).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await backupOption.hover();
await expect(backupOption).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
// The primary action continues directly to setup.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
@@ -127,8 +188,7 @@ test("key view reveals explicitly; options copy explicitly", async ({
// ---------------------------------------------------------------------------
// Encrypted download path ("Backup your key" step): password → encrypt
// locally → native save → saved confirmation. The raw key must never be
// fetched on this path.
// locally → native save → saved confirmation.
// ---------------------------------------------------------------------------
test("download happy path: generated password, encrypt, native save, Next", async ({
@@ -136,8 +196,8 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
}) => {
await enterMachineBackup(page);
// Password backup opens from the dark options state without adding an
// onboarding progress step.
// Password backup stays inside the onboarding card without adding a generic
// Next action.
await openPasswordBackup(page);
// The password field starts empty; the create button sits in the footer's
@@ -150,7 +210,28 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
await expect(passwordPanel).toHaveCSS("padding-left", "24px");
await expect(passwordPanel).toHaveCSS("padding-left", "0px");
await expect(page.getByTestId("backup-password-timeline")).toHaveCount(0);
await expect(
passwordPanel.getByText("Password", { exact: true }),
).toBeVisible();
const subtitle = page.getByText(
"This creates a password-protected file with your private key. Remember, Buzz can’t recover your key if you lose it.",
);
const passwordLabel = passwordPanel.getByText("Password", { exact: true });
const [subtitleBox, passwordLabelBox] = await Promise.all([
subtitle.boundingBox(),
passwordLabel.boundingBox(),
]);
expect(subtitleBox).not.toBeNull();
expect(passwordLabelBox).not.toBeNull();
expect(
(passwordLabelBox?.y ?? 0) -
((subtitleBox?.y ?? 0) + (subtitleBox?.height ?? 0)),
).toBeLessThanOrEqual(96);
await expect(input).toHaveCSS("height", "48px");
await expect(input).toHaveCSS("text-align", "left");
await expect(input).toHaveCSS("background-color", "rgb(249, 249, 249)");
// The inset refresh icon opens the generator popover and immediately
// fills the field (mock default: 3 words, spaces).
@@ -177,8 +258,7 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
// Encryption may still be running when the user commits the download. The
// explicit click queues the native save without exposing the password or
// fetching the raw key.
// explicit click queues the native save without exposing the password.
await page.keyboard.press("Escape");
await expect(page.getByTestId("backup-passphrase-separator")).toHaveCount(0);
@@ -186,20 +266,34 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await page.getByTestId("encrypted-backup-create").click();
// Only a successful save (the mock "picks" a path) advances to the
// "Optionally, test your backup" flow: a select-file button for the saved file
// "Your backup is ready" flow: a select-file button for the saved file
// (a composer-style drop overlay takes over the card while a file drag is
// over the window), then the password to unlock it.
await expect(
page.getByRole("heading", { name: "Optionally, test your backup" }),
page.getByRole("heading", { name: "Your backup is ready" }),
).toBeVisible();
const dropzone = page.getByTestId("backup-test-dropzone");
await expect(dropzone).toBeVisible();
await expect(dropzone).toHaveText("Test your backup");
await expect(dropzone).toHaveClass(/w-full/);
const [dropzoneBox, backupPanelBox] = await Promise.all([
dropzone.boundingBox(),
passwordPanel.boundingBox(),
]);
expect(dropzoneBox).not.toBeNull();
expect(backupPanelBox).not.toBeNull();
expect(dropzoneBox?.width ?? 0).toBeGreaterThanOrEqual(
(backupPanelBox?.width ?? 0) * 0.95,
);
await expect(
page.getByRole("button", { name: "Re-download backup" }),
page.getByRole("button", { name: "Download backup again" }),
).toBeVisible();
await expect(page.getByTestId("encrypted-backup-save-copy")).toHaveClass(
/w-full/,
);
// The optional security subview has no onboarding Next action. Returning to
// the yellow key view is the single exit throughout the ceremony.
// the key-created view is the single exit throughout the ceremony.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await expect(page.getByTestId("backup-return-to-onboarding")).toBeVisible();
@@ -253,13 +347,14 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
// The download path must never have fetched the raw key.
// Creating an encrypted backup uses a dedicated native command and does not
// fetch the raw identity key into the renderer.
const commands = await invokedCommands(page);
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
// Completion remains inside the optional security subview. Return to the
// yellow key view, whose standard Next action continues onboarding.
// key-created view, whose standard Next action continues onboarding.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
@@ -268,14 +363,46 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("security view returns to the yellow onboarding view", async ({
test("pending backup action collapses to an in-button loader", async ({
page,
}) => {
await enterMachineBackup(page, { backupEncryptionDelayMs: 3_000 });
await openPasswordBackup(page);
await page
.getByTestId("backup-passphrase-input")
.fill("mock-horse-battery-staple");
const create = page.getByTestId("encrypted-backup-create");
const readyButtonBox = await create.boundingBox();
await create.click();
await expect(create).toHaveAttribute("aria-busy", "true");
await expect(create).toHaveAccessibleName("Encrypting your key");
await expect(create.getByTestId("encrypted-backup-encrypting")).toBeVisible();
const pendingButtonBox = await create.boundingBox();
if (!readyButtonBox || !pendingButtonBox) {
throw new Error("Could not measure the encrypted-backup action button");
}
expect(pendingButtonBox.height).toBeCloseTo(readyButtonBox.height, 0);
expect(pendingButtonBox.width).toBeCloseTo(pendingButtonBox.height, 0);
expect(pendingButtonBox.width).toBeLessThan(readyButtonBox.width);
await expect(
page.getByText("Encrypting your password", { exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Your backup is ready" }),
).toBeVisible();
});
test("security view returns to the identity-key onboarding view", async ({
page,
}) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
await expect(page.getByTestId("onboarding-step-dots")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
@@ -294,7 +421,7 @@ test("returning to onboarding resets password-backup progress", async ({
await input.fill("mock-horse-battery-staple");
await page.getByTestId("encrypted-backup-create").click();
await expect(
page.getByRole("heading", { name: "Optionally, test your backup" }),
page.getByRole("heading", { name: "Your backup is ready" }),
).toBeVisible();
await page.getByTestId("backup-return-to-onboarding").click();
@@ -304,7 +431,7 @@ test("returning to onboarding resets password-backup progress", async ({
// backup session so no password or completed state leaks across navigation.
await openPasswordBackup(page);
await expect(
page.getByRole("heading", { name: "Backup your key with a password" }),
page.getByRole("heading", { name: "Create a secure backup file" }),
).toBeVisible();
await expect(page.getByTestId("backup-passphrase-input")).toHaveValue("");
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
@@ -328,13 +455,17 @@ test("typed password requires 12 characters", async ({ page }) => {
await expect(create).toBeEnabled();
});
test("backup step back button returns to machine identity choice", async ({
test("backup step back button returns through key guidance to identity choice", async ({
page,
}) => {
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("onboarding-back").click();
await expect(
page.getByRole("heading", { name: "Create a private identity key" }),
).toBeVisible();
await page.getByTestId("onboarding-back").click();
// Backing out preserves the loaded key — primary CTA continues setup rather
// than minting another identity (#2318).
@@ -347,10 +478,10 @@ test("backup step back button returns to machine identity choice", async ({
});
// ---------------------------------------------------------------------------
// B4: Error path coverage (reveal/copy)
// B4: Error path coverage (copy)
// ---------------------------------------------------------------------------
test("reveal shows inline error when get_nsec fails and Next still advances", async ({
test("copy shows inline error when get_nsec fails and Next still advances", async ({
page,
}) => {
await installMockBridge(
@@ -360,9 +491,11 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByRole("button", { name: "Create my private key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("backup-key-reveal-toggle").click();
await page.getByTestId("backup-key-well").hover();
await page.getByTestId("backup-copy-key").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Keychain failure does not trap the user: Next still skips backup and
@@ -372,8 +505,9 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("reveal retry succeeds after initial failure", async ({ page }) => {
// First call fails, second succeeds (sequenced via nsecErrors).
test("Copy retries after an initial key read fails", async ({ page }) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
// Explicit reveal fails first; explicit copy retries and succeeds.
await installMockBridge(
page,
{ nsecErrors: ["Keychain locked", null] },
@@ -381,12 +515,22 @@ test("reveal retry succeeds after initial failure", async ({ page }) => {
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByRole("button", { name: "Create my private key" }).click();
await page.getByTestId("backup-key-reveal-toggle").click();
// Reveal consumes the first failure without exposing a secret.
await page.getByTestId("backup-reveal-key").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
await expect(page.getByTestId("backup-key-value")).not.toContainText(
"nsec1mock",
);
// Retry — second call succeeds and clears the error.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock");
// Copy retries the read and clears the error without revealing the DOM value.
await page.getByTestId("backup-copy-key").click();
await expect(page.getByTestId("backup-copy-key")).toContainText(
"Copied to clipboard",
);
await expect(page.getByTestId("backup-key-value")).not.toContainText(
"nsec1mock",
);
await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
});

View File

@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, type Locator, type Page, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
@@ -10,14 +10,204 @@ const BLANK_TYLER_IDENTITY = {
};
const SHOT_DIR = "test-results/onboarding-docked-cta";
const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY =
"buzz-community-onboarding-transaction.v1";
const NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
test.use({ viewport: { width: 1280, height: 800 } });
async function expectSharedCardGeometry(page: Page, expectedWidth = 610) {
const geometry = await page
.getByTestId("onboarding-content-card")
.evaluate((element) => {
const rect = element.getBoundingClientRect();
const styles = window.getComputedStyle(element);
return {
borderRadius: styles.borderRadius,
height: rect.height,
paddingBottom: styles.paddingBottom,
paddingLeft: styles.paddingLeft,
paddingRight: styles.paddingRight,
paddingTop: styles.paddingTop,
width: rect.width,
};
});
expect(geometry.width).toBeCloseTo(expectedWidth, 0);
expect(geometry.height).toBeCloseTo(664, 0);
expect(geometry.borderRadius).toBe("32px");
expect(geometry.paddingTop).toBe("48px");
expect(geometry.paddingRight).toBe("48px");
expect(geometry.paddingBottom).toBe("48px");
expect(geometry.paddingLeft).toBe("48px");
}
async function expectUsesFullCardWidth(element: Locator) {
const geometry = await element.evaluate((node) => {
const available = node.closest<HTMLElement>(
".buzz-onboarding-transition-content",
);
if (!available) throw new Error("Onboarding content column is missing");
const availableBox = available.getBoundingClientRect();
const elementBox = node.getBoundingClientRect();
return {
availableLeft: availableBox.left,
availableWidth: availableBox.width,
elementLeft: elementBox.left,
elementWidth: elementBox.width,
};
});
expect(geometry.elementLeft).toBeCloseTo(geometry.availableLeft, 0);
expect(geometry.elementWidth).toBeCloseTo(geometry.availableWidth, 0);
}
async function expectProfileFooterMatchesContentGutters(page: Page) {
const geometry = await page.evaluate(() => {
const input = document
.querySelector<HTMLElement>("#onboarding-display-name")
?.getBoundingClientRect();
const back = document
.querySelector<HTMLElement>('[data-testid="onboarding-back"]')
?.getBoundingClientRect();
const next = document
.querySelector<HTMLElement>('[data-testid="onboarding-next"]')
?.getBoundingClientRect();
if (!input || !back || !next) {
throw new Error("Profile controls are missing");
}
return {
backLeft: back.left,
inputLeft: input.left,
inputRight: input.right,
nextRight: next.right,
};
});
expect(geometry.backLeft).toBeCloseTo(geometry.inputLeft, 0);
expect(geometry.nextRight).toBeCloseTo(geometry.inputRight, 0);
}
async function expectHorizontalCardTransition(
page: Page,
pageTestId: string,
expectedDirection: "forward" | "backward",
) {
const transition = page
.getByTestId(pageTestId)
.locator(".buzz-onboarding-transition-line")
.first();
await expect(transition).toHaveAttribute(
"data-onboarding-direction",
expectedDirection,
);
const motion = await transition.evaluate((line) => {
const content = line.querySelector<HTMLElement>(
":scope > .buzz-onboarding-transition-content",
);
if (!content) throw new Error("Onboarding transition content is missing");
const frame = line.closest<HTMLElement>(".buzz-onboarding-step-frame");
if (!frame) throw new Error("Onboarding transition frame is missing");
const animationName = window.getComputedStyle(content).animationName;
const keyframes: Array<{ transform: string; x: number; y: number }> = [];
const visitRules = (rules: CSSRuleList) => {
for (const rule of Array.from(rules)) {
if (rule instanceof CSSKeyframesRule && rule.name === animationName) {
for (const frame of Array.from(rule.cssRules)) {
const transform = (frame as CSSKeyframeRule).style.transform;
const matrix = new DOMMatrixReadOnly(transform || "none");
keyframes.push({
transform: transform || "none",
x: matrix.m41,
y: matrix.m42,
});
}
continue;
}
if ("cssRules" in rule) {
try {
visitRules((rule as CSSGroupingRule).cssRules);
} catch {
// Cross-origin and unsupported grouping rules are irrelevant here.
}
}
}
};
for (const styleSheet of Array.from(document.styleSheets)) {
try {
visitRules(styleSheet.cssRules);
} catch {
// Ignore stylesheets whose rules the browser does not expose.
}
}
const activeFrames = line
.getAnimations({ subtree: true })
.flatMap((animation) => {
const effect = animation.effect;
if (!(effect instanceof KeyframeEffect)) return [];
const target = effect.target;
return effect
.getKeyframes()
.filter((frame) => frame.transform && frame.transform !== "none")
.map((frame) => {
const transform = String(frame.transform);
const matrix = new DOMMatrixReadOnly(transform);
return {
target:
target instanceof HTMLElement
? target.className.toString()
: (target?.nodeName ?? "unknown"),
transform,
x: matrix.m41,
y: matrix.m42,
};
});
});
const frameRect = frame.getBoundingClientRect();
const lineRect = line.getBoundingClientRect();
const travel = 48;
const runway = {
backwardStart: lineRect.left - travel,
forwardEnd: lineRect.left + content.offsetWidth + travel,
frameLeft: frameRect.left,
frameRight: frameRect.right,
};
return { activeFrames, animationName, keyframes, runway };
});
expect(motion.animationName).toBe(
`buzz-onboarding-line-slide-${expectedDirection}`,
);
expect(motion.keyframes.length).toBeGreaterThanOrEqual(2);
for (const frame of [...motion.keyframes, ...motion.activeFrames]) {
expect(
Math.abs(frame.y),
`Unexpected vertical motion in ${frame.transform}`,
).toBeLessThan(0.01);
}
const enteringFrame = motion.keyframes[0];
if (!enteringFrame) throw new Error("Transition entry frame is missing");
expect(enteringFrame.x).toBe(expectedDirection === "forward" ? 48 : -48);
if (expectedDirection === "forward") {
expect(motion.runway.forwardEnd).toBeLessThanOrEqual(
motion.runway.frameRight + 0.5,
);
} else {
expect(motion.runway.backwardStart).toBeGreaterThanOrEqual(
motion.runway.frameLeft - 0.5,
);
}
}
test("machine onboarding: landing, backup, setup docked CTAs", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
@@ -33,21 +223,40 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
const importCard = page.getByTestId("nostr-import-card");
await expectHorizontalCardTransition(
page,
"machine-onboarding-gate",
"forward",
);
const importCard = page.getByTestId("onboarding-content-card");
await expect(importCard).toBeVisible();
await expectSharedCardGeometry(page);
await expect(page.getByLabel("Private key", { exact: true })).toBeVisible();
// The production card uses a baked nine-slice texture: no runtime SVG
// filter, measurement, or texture regeneration during resize.
await expect(importCard).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(importCard).toHaveCSS("border-top-width", "0px");
await expect(importCard).toHaveCSS("border-image-repeat", "repeat");
await expect(importCard).toHaveCSS("border-image-outset", "96px");
// Icon SVGs (e.g. the reveal toggle) are fine; a filter would mean the
// texture regressed to the runtime SVG pipeline.
await expectUsesFullCardWidth(page.getByTestId("nostr-import-nsec-input"));
await expect(importCard).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
await expect(importCard.locator("svg filter")).toHaveCount(0);
const onboardingBack = page.getByTestId("onboarding-back");
await expect(onboardingBack).toHaveCSS("width", "52px");
await expect(onboardingBack).toHaveCSS("height", "52px");
await expect(onboardingBack.locator("svg")).toHaveCSS("width", "24px");
await expect(onboardingBack.locator("svg")).toHaveCSS("height", "24px");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` });
await page.getByTestId("nostr-import-file-button").click();
await expect(page.getByTestId("backup-recovery-dialog")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01c-backup-file-sheet.png` });
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("phone-recovery-dialog")).toBeVisible();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01d-phone-recovery-sheet.png` });
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByTestId("nostr-import-nsec-input").fill(NCRYPTSEC);
await expect(
page.getByRole("heading", { name: "Unlock your account" }),
@@ -57,7 +266,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await expect(page.getByTestId("restore-unlock-icon")).toBeVisible();
await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01c-restore-backup.png` });
await page.screenshot({ path: `${SHOT_DIR}/01e-restore-backup.png` });
// The first Back returns to key selection; the second leaves import.
await page.getByRole("button", { name: "Back", exact: true }).click();
@@ -67,51 +276,120 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
page.getByRole("button", { name: "Create a new identity key" }),
).toBeVisible();
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
page.getByRole("heading", { name: "Create a private identity key" }),
).toBeVisible();
await expectHorizontalCardTransition(
page,
"onboarding-page-key-intro",
"forward",
);
await expectUsesFullCardWidth(page.getByTestId("onboarding-key-guidance"));
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-key-introduction.png` });
await page.getByRole("button", { name: "Create my private key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
name: "Your private identity key",
}),
).toBeVisible();
await expectHorizontalCardTransition(
page,
"onboarding-page-backup",
"forward",
);
await expectSharedCardGeometry(page);
const keyGeometry = await page.evaluate(() => {
const keyWell = document
.querySelector('[data-testid="backup-key-well"]')
?.getBoundingClientRect();
const keyWellStyles = window.getComputedStyle(
document.querySelector('[data-testid="backup-key-well"]') ??
document.documentElement,
);
const backupRow = document
.querySelector('[data-testid="backup-option-password"]')
?.getBoundingClientRect();
const copyButton = document
.querySelector('[data-testid="backup-copy-key"]')
?.getBoundingClientRect();
const keyValue = document.querySelector('[data-testid="backup-key-value"]');
const keyRows = keyValue
? (() => {
const range = document.createRange();
range.selectNodeContents(keyValue);
return new Set(
Array.from(range.getClientRects()).map((rect) =>
Math.round(rect.top),
),
).size;
})()
: 0;
return {
backupRowHeight: backupRow?.height ?? 0,
backupRowWidth: backupRow?.width ?? 0,
copyButtonHeight: copyButton?.height ?? 0,
keyRows,
keyWellPaddingLeft: keyWellStyles.paddingLeft,
keyWellPaddingRight: keyWellStyles.paddingRight,
keyWellHeight: keyWell?.height ?? 0,
keyWellWidth: keyWell?.width ?? 0,
};
});
expect(keyGeometry.keyWellWidth).toBeCloseTo(512, 0);
expect(keyGeometry.keyWellHeight).toBeCloseTo(122, 0);
expect(keyGeometry.keyRows).toBe(2);
expect(keyGeometry.keyWellPaddingLeft).toBe("16px");
expect(keyGeometry.keyWellPaddingRight).toBe("16px");
expect(keyGeometry.copyButtonHeight).toBeCloseTo(32, 0);
expect(keyGeometry.backupRowWidth).toBeCloseTo(512, 0);
expect(keyGeometry.backupRowHeight).toBeCloseTo(48, 0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
// The key stays masked behind an explicit reveal toggle.
await expect(page.getByTestId("backup-key-value")).toBeVisible();
// Reveal the key: box must not reflow (same-length monospace mask).
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/);
const backupOption = page.getByTestId("backup-option-password");
await backupOption.hover();
await expect(backupOption).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
await page.screenshot({ path: `${SHOT_DIR}/02a-backup-option-hover.png` });
// Backup options leave the yellow flow for the dark security view without
// adding a progress step or a generic Next action.
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
const optionPanels = page.getByTestId("backup-option-panel");
await expect(optionPanels).toHaveCount(3);
await expect(
page.getByTestId("backup-options").locator(".buzz-card-textured"),
).toHaveCount(0);
await expect(optionPanels.first()).toHaveCSS("padding-left", "24px");
const titleTops = await optionPanels
.locator("span.text-lg")
.evaluateAll((titles) =>
titles.map((title) => title.getBoundingClientRect().top),
);
expect(Math.max(...titleTops) - Math.min(...titleTops)).toBeLessThan(1);
// The reusable secret stays out of the DOM until the user explicitly asks
// to reveal it. Copy is a separate action and leaves the rendered value
// masked.
const keyValue = page.getByTestId("backup-key-value");
const revealButton = page.getByTestId("backup-reveal-key");
const copyButton = page.getByTestId("backup-copy-key");
await expect(keyValue).toBeVisible();
await expect(keyValue).not.toContainText("nsec1mock");
await expect(revealButton).toHaveAccessibleName("Reveal private key");
await revealButton.click();
await expect(keyValue).toContainText("nsec1mock");
await expect(revealButton).toHaveAccessibleName("Hide private key");
await revealButton.click();
await expect(keyValue).not.toContainText("nsec1mock");
await expect(copyButton).toBeEnabled();
await copyButton.click();
await expect(copyButton).toContainText("Copied to clipboard");
await expect(keyValue).not.toContainText("nsec1mock");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02c-backup-options.png` });
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-copy.png` });
// The locked-backup action is part of the generated-key sheet.
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
await expectHorizontalCardTransition(
page,
"onboarding-page-download",
"forward",
);
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
await expectUsesFullCardWidth(passwordPanel);
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
await expect(passwordPanel).toHaveCSS("padding-left", "24px");
await expect(passwordPanel).toHaveCSS("padding-left", "0px");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02d-backup-password.png` });
@@ -125,12 +403,174 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expectHorizontalCardTransition(
page,
"onboarding-page-backup",
"backward",
);
await page.getByTestId("onboarding-next").click();
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
page.getByRole("heading", { name: "Connect your AI provider" }),
).toBeVisible();
const subscriptionMethod = page.getByTestId(
"onboarding-harness-method-subscription",
);
const apiMethod = page.getByTestId("onboarding-harness-method-api");
await expect(subscriptionMethod).toContainText("Log in with a subscription");
await expect(apiMethod).toContainText("Use an API key");
await expect(subscriptionMethod).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expectUsesFullCardWidth(subscriptionMethod);
await expectUsesFullCardWidth(apiMethod);
await expectSharedCardGeometry(page);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` });
await page.getByTestId("onboarding-harness-method-subscription").click();
await expect(
page.getByRole("heading", { name: "Continue with an AI subscription" }),
).toBeVisible();
await expectHorizontalCardTransition(page, "onboarding-page-2", "forward");
await expect(page.getByText(/More harnesses can be added in/)).toHaveCount(0);
await expect(page.getByTestId("onboarding-setup-skip")).toBeVisible();
await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
await expect(
page.getByText("CLI not detected", { exact: false }),
).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03b-subscriptions.png` });
await page.getByTestId("onboarding-runtime-details-codex").click();
await expect(
page.getByTestId("onboarding-harness-setup-guide"),
).toBeVisible();
const guideCard = page.getByTestId("onboarding-harness-setup-guide-card");
await expect(guideCard).toContainText("Codex");
await expect(guideCard).toContainText(
"Codex is not detected on this computer.",
);
await expect(
guideCard.getByTestId("onboarding-harness-open-setup-guide"),
).toHaveText("Open guide");
await expect(
page.getByTestId("onboarding-runtime-install-codex"),
).toHaveCount(0);
await expectHorizontalCardTransition(page, "onboarding-page-2", "forward");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03c-harness-guide.png` });
await page.getByTestId("onboarding-back").click();
await expect(
page.getByRole("heading", { name: "Continue with an AI subscription" }),
).toBeVisible();
await expectHorizontalCardTransition(page, "onboarding-page-2", "backward");
await page.getByTestId("onboarding-back").click();
await expect(
page.getByRole("heading", { name: "Connect your AI provider" }),
).toBeVisible();
await expectHorizontalCardTransition(page, "onboarding-page-2", "backward");
await page.getByTestId("onboarding-harness-method-api").click();
await expect(
page.getByRole("heading", { name: "Connect with an API key" }),
).toBeVisible();
await expect(
page.getByText(
"Choose your provider and enter an API key to connect to the Buzz harness.",
),
).toBeVisible();
await expectHorizontalCardTransition(
page,
"onboarding-page-config",
"forward",
);
await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
await expectUsesFullCardWidth(page.getByTestId("global-agent-provider"));
await expect(
page.getByTestId("onboarding-use-different-harness"),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03d-api-buzz-model.png` });
await page.getByTestId("global-agent-provider").click();
await page.getByTestId("global-agent-provider-option-anthropic").click();
await expect(page.getByTestId("persona-provider-api-key")).toBeVisible();
await expect(
page.getByText("ANTHROPIC_API_KEY", { exact: true }),
).toHaveCount(0);
await expect(page.getByTestId("global-agent-model")).toHaveCount(0);
await expect(
page.getByTestId("global-agent-thinking-effort-select"),
).toHaveCount(0);
const providerBeforeKey = await page
.getByTestId("global-agent-provider")
.boundingBox();
const apiKeyBeforeKey = await page
.getByTestId("persona-provider-api-key")
.boundingBox();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOT_DIR}/03d2-api-provider-selected.png`,
});
await page.getByTestId("persona-provider-api-key").fill("sk-test-key");
await expect(page.getByTestId("global-agent-model")).toHaveText(
"Claude Opus 4.6",
);
const providerAfterKey = await page
.getByTestId("global-agent-provider")
.boundingBox();
const apiKeyAfterKey = await page
.getByTestId("persona-provider-api-key")
.boundingBox();
expect(providerBeforeKey).not.toBeNull();
expect(apiKeyBeforeKey).not.toBeNull();
expect(providerAfterKey).not.toBeNull();
expect(apiKeyAfterKey).not.toBeNull();
expect(
Math.abs((providerBeforeKey?.y ?? 0) - (providerAfterKey?.y ?? 0)),
).toBeLessThan(2);
expect(
Math.abs((apiKeyBeforeKey?.y ?? 0) - (apiKeyAfterKey?.y ?? 0)),
).toBeLessThan(2);
await waitForAnimations(page);
await page.screenshot({
path: `${SHOT_DIR}/03d3-api-key-entered.png`,
});
await page.getByTestId("onboarding-use-different-harness").click();
await expect(
page.getByRole("heading", { name: "Choose a harness" }),
).toBeVisible();
await expectHorizontalCardTransition(page, "onboarding-page-2", "forward");
await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toContainText(
"Recommended",
);
await expect(
page.getByTestId("onboarding-runtime-ready-buzz-agent"),
).toHaveCount(0);
await page.getByTestId("onboarding-runtime-buzz-agent").hover();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03e-api-harnesses.png` });
await page.getByTestId("onboarding-back").click();
await expect(
page.getByRole("heading", { name: "Connect with an API key" }),
).toBeVisible();
await expect(page.getByTestId("global-agent-provider")).toHaveText(
"Anthropic",
);
await expect(page.getByTestId("persona-provider-api-key")).toHaveValue(
"sk-test-key",
);
await expectHorizontalCardTransition(
page,
"onboarding-page-config",
"backward",
);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03f-api-return.png` });
});
test("machine key import remains usable in a short viewport", async ({
@@ -175,10 +615,8 @@ test("machine key import remains usable in a short viewport", async ({
expect(layout.scrollWidth).toBe(layout.clientWidth);
});
test("backup options keep one-column geometry on narrow windows", async ({
page,
}) => {
await page.setViewportSize({ width: 600, height: 700 });
test("identity-key help stays inside the onboarding card", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 650 });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
@@ -186,26 +624,29 @@ test("backup options keep one-column geometry on narrow windows", async ({
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
page.getByRole("button", { name: "Learn how identity keys work" }),
).toBeVisible();
await page.getByTestId("backup-options-link").click();
await page
.getByRole("button", { name: "Learn how identity keys work" })
.click();
const panels = page.getByTestId("backup-option-panel");
await expect(panels).toHaveCount(3);
const help = page.getByTestId("identity-key-help-dialog");
await expect(help).toBeVisible();
await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
await expect(
help.getByRole("heading", { name: "What’s an identity key?" }),
).toBeVisible();
await expectUsesFullCardWidth(help.getByTestId("identity-key-help-body"));
await waitForAnimations(page);
const geometry = await panels.evaluateAll((elements) => ({
await page.screenshot({ path: `${SHOT_DIR}/02f-identity-key-help.png` });
const geometry = await help.evaluate((element) => ({
clientWidth: document.documentElement.clientWidth,
lefts: elements.map((element) => element.getBoundingClientRect().left),
rights: elements.map((element) => element.getBoundingClientRect().right),
left: element.getBoundingClientRect().left,
right: element.getBoundingClientRect().right,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(new Set(geometry.lefts.map(Math.round)).size).toBe(1);
expect(geometry.lefts.every((left) => left >= 0)).toBe(true);
expect(geometry.rights.every((right) => right <= geometry.clientWidth)).toBe(
true,
);
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(geometry.clientWidth);
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
});
@@ -215,8 +656,11 @@ test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expectSharedCardGeometry(page, 610);
await expect(page.getByTestId("onboarding-back")).toBeVisible();
await page.getByTestId("onboarding-display-name").fill("Ada Lovelace");
await waitForAnimations(page);
await expectProfileFooterMatchesContentGutters(page);
await page.screenshot({ path: `${SHOT_DIR}/04-profile.png` });
await page.getByTestId("onboarding-next").click();
@@ -227,3 +671,62 @@ test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/05-avatar.png` });
});
test("community onboarding: profile and starter-team cards", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "screenshot-community-profile",
source: "first-community",
stage: "profile",
relayUrl: "ws://localhost:3000",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{ profileHasEvent: false },
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
await expectSharedCardGeometry(page);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/06-community-profile.png` });
await page.getByTestId("community-profile-name-key").fill("Ada Lovelace");
await page.getByTestId("community-profile-next").click();
await expect(
page.getByRole("heading", { name: "Meet your starter team" }),
).toBeVisible();
await expectSharedCardGeometry(page);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/07-starter-team.png` });
});

View File

@@ -731,7 +731,7 @@ test("fresh existing-identity path leads with private-key recovery", async ({
await expect(
page.getByText("Paste your private key to sign in to Buzz."),
).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
await expect(page.getByTestId("nostr-import-file-button")).toHaveText(
"backup file",
);
@@ -795,17 +795,17 @@ test("fresh existing-identity path leads with private-key recovery", async ({
);
});
await expect(backupDrop).toHaveCount(0);
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
await backupDialog.getByRole("button", { name: "Close" }).click();
await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByTestId("nostr-import-phone-link").click();
const phoneDialog = page.getByTestId("phone-recovery-dialog");
await expect(phoneDialog).toBeVisible();
await expect(
phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }),
phoneDialog.getByRole("heading", { name: "Scan to sign in" }),
).toBeVisible();
await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
});
test("first-launch key import continues to machine setup", async ({ page }) => {
@@ -850,6 +850,31 @@ test("key import locks host navigation and ignores rapid duplicate submits", asy
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("key import keeps alternate recovery methods disabled while submitting", async ({
page,
}) => {
await installMockBridge(
page,
{ identityImportDelayMs: 500 },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("nostr-import-file-button")).toBeDisabled();
await expect(page.getByTestId("nostr-import-phone-link")).toBeDisabled();
await page.getByTestId("nostr-import-file-button").click({ force: true });
await page.getByTestId("nostr-import-phone-link").click({ force: true });
await expect(page.getByTestId("nostr-import-nsec-input")).toBeVisible();
await expect(page.getByTestId("backup-recovery-dialog")).toHaveCount(0);
await expect(page.getByTestId("phone-recovery-dialog")).toHaveCount(0);
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("imported-key users can skip out of harness setup", async ({ page }) => {
// Regression: importing an existing key sets the onboarding state machine's
// "continuing" marker, which pinned the stage to onboarding even after
@@ -876,6 +901,138 @@ test("imported-key users can skip out of harness setup", async ({ page }) => {
await expect(page.getByTestId("onboarding-page-2")).toHaveCount(0);
});
test("fresh-key harness completion continues directly into profile onboarding", async ({
page,
}) => {
await page.addInitScript(() => {
const communityId = "e2e-existing-community";
window.localStorage.setItem(
"buzz-communities",
JSON.stringify([
{
id: communityId,
name: "E2E Test",
relayUrl: "ws://localhost:3000",
addedAt: new Date().toISOString(),
},
]),
);
window.localStorage.setItem("buzz-active-community-id", communityId);
});
await installMockBridge(
page,
{
profileHasEvent: false,
deferProfileReads: true,
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { bootSplashHoldMs?: number };
};
testWindow.__BUZZ_E2E__ = {
...(testWindow.__BUZZ_E2E__ ?? {}),
bootSplashHoldMs: 2_000,
};
});
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByRole("button", { name: "Create my private key" }).click();
await page.getByTestId("onboarding-next").click();
await expect(
page.getByRole("heading", { name: "Connect your AI provider" }),
).toBeVisible();
await page.evaluate(() => {
const testWindow = window as Window & {
__BUZZ_E2E_ONBOARDING_LOADING_GATES__?: string[];
};
testWindow.__BUZZ_E2E_ONBOARDING_LOADING_GATES__ = [];
new MutationObserver(() => {
for (const testId of ["app-loading-gate", "boot-splash-overlay"]) {
if (document.querySelector(`[data-testid="${testId}"]`)) {
testWindow.__BUZZ_E2E_ONBOARDING_LOADING_GATES__?.push(testId);
}
}
}).observe(document.body, { childList: true, subtree: true });
});
await page.getByTestId("onboarding-setup-skip").click();
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(
page.getByTestId("onboarding-step-dots").locator("span"),
).toHaveCount(7);
await expect(
page.getByTestId("onboarding-step-dots").locator("span").nth(4),
).toHaveClass(/w-7/);
const profileSubmit = page.getByTestId("onboarding-next");
await page.getByTestId("onboarding-display-name").fill("Delayed Profile");
await expect(profileSubmit).toBeDisabled();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_PROFILE_READS_PENDING__?: () => number;
}
).__BUZZ_E2E_PROFILE_READS_PENDING__?.() ?? 0,
),
)
.toBeGreaterThanOrEqual(1);
await page.getByTestId("onboarding-display-name").press("Enter");
await profileSubmit.evaluate((element) => {
const reactPropsKey = Object.keys(element).find((key) =>
key.startsWith("__reactProps$"),
);
if (!reactPropsKey) {
throw new Error("React props were not attached to the profile button");
}
const reactProps = (
element as unknown as Record<
string,
{ onClick?: (event: MouseEvent) => void }
>
)[reactPropsKey];
reactProps.onClick?.(new MouseEvent("click"));
});
await expect(page.getByTestId("onboarding-display-name")).toBeEnabled();
expect(await commandCount(page, "update_profile")).toBe(0);
await expect(page.getByTestId("onboarding-page-avatar")).toHaveCount(0);
expect(
await page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_RELEASE_PROFILE_READS__?: () => number;
}
).__BUZZ_E2E_RELEASE_PROFILE_READS__?.() ?? 0,
),
).toBeGreaterThanOrEqual(1);
await expect(profileSubmit).toBeEnabled();
await profileSubmit.click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-skip").click();
await expectWelcomeView(page);
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
await expect(page.getByTestId("boot-splash-overlay")).toHaveCount(0);
expect(
await page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_ONBOARDING_LOADING_GATES__?: string[];
}
).__BUZZ_E2E_ONBOARDING_LOADING_GATES__ ?? [],
),
).toEqual([]);
});
test("first-launch encrypted backup import asks for a passphrase and continues", async ({
page,
}) => {
@@ -969,7 +1126,7 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
// File contents advance to the password stage inside the same dialog.
// File contents advance to the password stage inside the same sheet.
const backupDialog = page.getByTestId("backup-recovery-dialog");
const backupFileSection = backupDialog.getByTestId(
"nostr-import-backup-file-section",
@@ -1023,8 +1180,8 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
backupDialog.getByTestId("nostr-import-passphrase"),
).toBeFocused();
// Back first returns to backup-file selection instead of closing the dialog.
await backupDialog.getByRole("button", { name: "Back", exact: true }).click();
// Back first returns to backup-file selection instead of leaving the sheet.
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(
backupDialog.getByRole("heading", { name: "Restore from a backup file" }),
).toBeVisible();
@@ -1040,7 +1197,7 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
await backupDialog
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
await backupDialog.getByTestId("nostr-import-submit").click();
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
@@ -1868,7 +2025,7 @@ test("first-community shows the scenario cards for localhost", async ({
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(
page.getByRole("heading", {
name: "Configure your default model settings",
name: "Choose your model settings",
}),
).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
@@ -2129,7 +2286,7 @@ test("canceling a join to an existing inactive community preserves it", async ({
.toEqual(["active-community", "existing-community"]);
});
test("connected first-community profile keeps Back bottom-left and balances the avatar editor", async ({
test("connected first-community profile keeps navigation inside the card and balances the avatar editor", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -2213,7 +2370,13 @@ test("connected first-community profile keeps Back bottom-left and balances the
if (!profileHeadingBox) {
throw new Error("Could not measure community profile heading position");
}
expect(Math.abs(profileHeadingBox.y - 106)).toBeLessThan(8);
const onboardingCard = page.getByTestId("onboarding-content-card");
const onboardingCardBox = await onboardingCard.boundingBox();
if (!onboardingCardBox) {
throw new Error("Could not measure onboarding card position");
}
expect(profileHeadingBox.y).toBeGreaterThan(onboardingCardBox.y);
expect(profileHeadingBox.y).toBeLessThan(onboardingCardBox.y + 96);
const nameKey = page.getByTestId("community-profile-name-key");
const avatarButton = page.getByTestId("community-avatar-open");
await expect(nameKey).toBeVisible();
@@ -2221,26 +2384,20 @@ test("connected first-community profile keeps Back bottom-left and balances the
const nameKeyBox = await nameKey.boundingBox();
const avatarButtonBox = await avatarButton.boundingBox();
expect(nameKeyBox?.width).toBeGreaterThan(380);
expect(avatarButtonBox?.width).toBe(144);
expect(avatarButtonBox?.width).toBeCloseTo(144, 3);
const nameKeyStyles = await nameKey.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderRadius: styles.borderRadius,
boxShadow: styles.boxShadow,
fontSize: styles.fontSize,
};
});
expect(nameKeyStyles.backgroundColor).toMatch(
/^(rgba\(255, 255, 255, 0\.95\)|oklab\(.+ \/ 0\.95\))$/,
);
expect(nameKeyStyles.borderColor).toBe("rgba(113, 113, 6, 0.28)");
expect(nameKeyStyles.boxShadow).toContain(
"rgba(113, 113, 6, 0.5) 0px 0px 0px 1px inset",
);
expect(nameKeyStyles.backgroundColor).toBe("rgb(249, 249, 249)");
expect(nameKeyStyles.borderColor).toBe("rgb(226, 226, 226)");
expect(nameKeyStyles).toMatchObject({
borderRadius: "16px",
borderRadius: "12px",
fontSize: "14px",
});
await expect(page.getByText("Your username", { exact: true })).toBeVisible();
@@ -2540,7 +2697,7 @@ test("connected first-community profile keeps Back bottom-left and balances the
const backButton = page.getByTestId("community-profile-back");
await expect(nextButton).toHaveText("Next");
await expect(nextButton).toBeDisabled();
await expect(backButton).toHaveText("Back");
await expect(backButton).toHaveAttribute("aria-label", "Back");
await expect(backButton).toBeEnabled();
const [nextBox, backBox] = await Promise.all([
nextButton.boundingBox(),
@@ -2549,12 +2706,11 @@ test("connected first-community profile keeps Back bottom-left and balances the
if (!nextBox || !backBox) {
throw new Error("Could not measure community profile navigation controls");
}
const viewport = page.viewportSize();
if (!viewport) throw new Error("Could not measure onboarding viewport");
expect(backBox.x).toBeLessThanOrEqual(32);
expect(
Math.abs(nextBox.x + nextBox.width / 2 - viewport.width / 2),
).toBeLessThanOrEqual(1);
expect(backBox.x).toBeGreaterThanOrEqual(onboardingCardBox.x + 40);
expect(backBox.width).toBe(52);
expect(nextBox.x + nextBox.width).toBeLessThanOrEqual(
onboardingCardBox.x + onboardingCardBox.width - 40,
);
await backButton.click();
await expect(
@@ -2579,6 +2735,9 @@ test("name-only community profile save preserves an existing avatar", async ({
skipOnboardingSeed: true,
});
await page.goto("/");
await expect
.poll(() => commandCount(page, "get_profile"))
.toBeGreaterThanOrEqual(2);
const existingAvatarUrl =
"https://mock.relay/media/existing-community-avatar.png";
@@ -3205,7 +3364,7 @@ test("avatar step reveals preset backgrounds after the first emoji pick", async
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByRole("tab", { name: "Emoji" }).click();
await page.getByTestId("onboarding-avatar-mode-emoji").click();
const colorGridShell = page.getByTestId("onboarding-avatar-color-grid-shell");
await expect(colorGridShell).toHaveAttribute("aria-hidden", "true");
@@ -4057,6 +4216,7 @@ test("same-relay identity replacement rebuilds the community boundary (A→B→A
test("onboarding relay reconnect — click shows Connected then auto-dismisses", async ({
page,
}) => {
await page.setViewportSize({ width: 800, height: 500 });
// Produce the relay reconnect card via a relay-unreachable profile save error.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
@@ -4096,6 +4256,30 @@ test("onboarding relay reconnect — click shows Connected then auto-dismisses",
await expect(card).toBeHidden({ timeout: 10_000 });
});
test("onboarding relay reconnect — dismiss is clickable at minimum size", async ({
page,
}) => {
await page.setViewportSize({ width: 800, height: 500 });
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: could not connect to relay",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
const card = page.getByTestId("onboarding-relay-reconnect-card");
await expect(card).toBeVisible();
await page
.getByRole("button", { name: "Dismiss relay notification" })
.click();
await expect(card).toHaveCount(0);
});
test("onboarding relay reconnect — connected without a prior click does not show Connected", async ({
page,
}) => {

View File

@@ -329,6 +329,8 @@ type MockBridgeOptions = {
/** Delay (ms) for newest-page fetches; see e2eBridge mock config. */
channelHeadDelayMs?: number;
profileReadDelayMs?: number;
/** Hold `get_profile` responses until `__BUZZ_E2E_RELEASE_PROFILE_READS__()`. */
deferProfileReads?: boolean;
profileReadError?: string;
/** Override whether get_profile reports a real kind:0 event. */
profileHasEvent?: boolean;
@@ -473,6 +475,8 @@ type MockBridgeOptions = {
* can exercise the "Thread deleted" label / disabled-send path.
*/
deletedEventIds?: string[];
/** Reject one identity read after the configured number of successful reads. */
identityReadErrorAfter?: { message: string; successfulReads: number };
/**
* When true, `get_identity` returns `lost: true` until `persist_current_identity`
* or `import_identity` is invoked. Drives the identity-lost recovery UX in tests.