mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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.");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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` });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user