Fix desktop onboarding regressions (#7659)

## Summary
- restore neutral identity guidance icons and reveal-first private-key
copying
- keep runtime availability sections contiguous and restore primary
onboarding colors
- route API-key setup directly to Buzz provider configuration, including
slow discovery and signed-out states

## Testing
- `pnpm check`
- `pnpm test` (6,501 passed)
- focused Playwright onboarding coverage (API routing 3/3; visual flow
1/1)

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-09-15 10:30:05 -04:00
committed by GitHub
parent 4d08194ea9
commit a9dd4d3bd7
10 changed files with 199 additions and 154 deletions

View File

@@ -2,8 +2,6 @@ import {
Check,
ChevronRight,
Copy,
Eye,
EyeOff,
FileLock2,
ShieldCheck,
} from "lucide-react";
@@ -66,9 +64,9 @@ type BackupStepProps = {
/**
* Onboarding identity-key step — shows the freshly created key, then opens a
* dark backup-options state. The reusable secret stays out of the DOM until an
* explicit reveal or copy action; password backup opens the separate security
* flow.
* dark backup-options state. The new key is visible by default; hovering or
* focusing the key well blurs it and replaces the key with an explicit copy
* action. Password backup opens the separate security flow.
* Neither method blocks Next.
*/
export function BackupStep({
@@ -87,8 +85,6 @@ export function BackupStep({
>("idle");
const [copyError, setCopyError] = React.useState<string | null>(null);
const [nsec, setNsec] = React.useState<string | null>(null);
const [isRevealed, setIsRevealed] = React.useState(false);
const [isRevealPending, setIsRevealPending] = React.useState(false);
const cancelledRef = React.useRef(false);
const copiedTimerRef = React.useRef<number | null>(null);
@@ -109,13 +105,30 @@ export function BackupStep({
React.useEffect(() => {
cancelledRef.current = false;
return () => {
// Back-during-fetch: cancel any in-flight setState calls.
// Back-during-fetch: cancel any in-flight setState calls and release the
// renderer's reference to the freshly generated key.
cancelledRef.current = true;
setNsec(null);
if (copiedTimerRef.current !== null)
window.clearTimeout(copiedTimerRef.current);
};
}, []);
React.useEffect(() => {
void getNsec()
.then((value) => {
if (!cancelledRef.current) setNsec(value);
})
.catch((err: unknown) => {
if (cancelledRef.current) return;
setCopyError(
err instanceof Error
? err.message
: "Failed to retrieve private key.",
);
});
}, []);
const copyKeyToClipboard = React.useCallback(async () => {
setCopyState("copying");
setCopyError(null);
@@ -139,35 +152,6 @@ export function BackupStep({
}
}, [nsec]);
const toggleReveal = React.useCallback(async () => {
if (isRevealed) {
setIsRevealed(false);
return;
}
setIsRevealPending(true);
setCopyError(null);
try {
const value = nsec ?? (await getNsec());
if (cancelledRef.current) return;
setNsec(value);
setIsRevealed(true);
} catch (err) {
if (cancelledRef.current) return;
setCopyError(
err instanceof Error ? err.message : "Failed to retrieve private key.",
);
} finally {
if (!cancelledRef.current) setIsRevealPending(false);
}
}, [isRevealed, nsec]);
// Never read the credential merely to size its placeholder. The complete
// reusable secret enters the DOM only after an explicit Reveal action.
const maskedKey = React.useMemo(
() => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"),
[nsec],
);
const storageDescription =
identityStorage === "system-keyring"
? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key."
@@ -387,39 +371,24 @@ export function BackupStep({
>
<div className="w-full">
<div
className="relative flex h-[7.625rem] w-full items-center justify-center overflow-hidden rounded-xl border border-[#e5e5e5] bg-[#f5f5f5] px-4 py-6"
className="group/key relative flex h-[7.625rem] w-full items-center justify-center overflow-hidden rounded-xl border border-[#e5e5e5] bg-[#f5f5f5] px-4 py-6"
data-testid="backup-key-well"
>
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
"buzz-onboarding-key-text-v3 select-text break-all",
"buzz-onboarding-key-text-v3 select-text break-all transition-[filter] duration-150 ease-out group-hover/key:select-none group-hover/key:blur-[4px] group-focus-within/key:select-none group-focus-within/key:blur-[4px] motion-reduce:transition-none",
)}
data-testid="backup-key-value"
>
{isRevealed ? nsec : maskedKey}
{nsec}
</p>
<div
aria-hidden
className="pointer-events-none absolute inset-px rounded-[11px] bg-white/60 opacity-0 transition-opacity duration-150 ease-out group-hover/key:opacity-100 group-focus-within/key:opacity-100 motion-reduce:transition-none"
/>
<Button
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className="absolute right-3 top-3 size-8 rounded-full bg-transparent p-0 text-foreground/70 shadow-none hover:bg-foreground/[0.06] hover:text-foreground"
data-testid="backup-reveal-key"
disabled={isRevealPending}
onClick={() => void toggleReveal()}
type="button"
variant="ghost"
>
{isRevealPending ? (
<Spinner className="size-4 border-2" />
) : isRevealed ? (
<EyeOff aria-hidden className="size-4" />
) : (
<Eye aria-hidden className="size-4" />
)}
</Button>
<Button
className="absolute bottom-3 left-1/2 h-8 -translate-x-1/2 gap-2 rounded-full bg-foreground px-4 text-sm text-background shadow-none hover:bg-foreground/90 hover:text-background"
className="pointer-events-none absolute left-1/2 top-1/2 z-10 h-8 -translate-x-1/2 -translate-y-1/2 gap-2 rounded-full bg-primary px-4 text-sm text-primary-foreground opacity-0 shadow-none transition-opacity duration-150 ease-out group-hover/key:pointer-events-auto group-hover/key:opacity-100 group-focus-within/key:pointer-events-auto group-focus-within/key:opacity-100 hover:bg-primary/90 hover:text-primary-foreground motion-reduce:transition-none"
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
@@ -442,7 +411,7 @@ export function BackupStep({
</div>
<Button
className="mt-2 h-12 w-full justify-between rounded-xl bg-transparent px-3 py-0 text-base font-normal text-foreground shadow-none transition-colors duration-150 ease-out hover:bg-foreground/[0.04] hover:text-foreground motion-reduce:transition-none"
className="mt-2 h-12 w-full justify-between rounded-xl bg-transparent px-3 py-0 text-base font-normal text-primary shadow-none transition-colors duration-150 ease-out hover:bg-primary/[0.04] hover:text-primary motion-reduce:transition-none"
data-testid="backup-option-password"
disabled={!created}
onClick={onOpenPasswordBackup}
@@ -453,7 +422,7 @@ export function BackupStep({
<FileLock2 aria-hidden className="size-6" />
<span>Create locked backup</span>
</span>
<ChevronRight aria-hidden className="size-5 text-foreground/70" />
<ChevronRight aria-hidden className="size-5 text-primary" />
</Button>
{copyError ? (

View File

@@ -230,7 +230,7 @@ function AgentDefaultsSection({
return (
<fieldset
aria-busy={isPending}
className="w-full space-y-4 text-left text-sm disabled:pointer-events-none disabled:opacity-70"
className="w-full space-y-4 text-left text-sm text-primary disabled:pointer-events-none disabled:opacity-70"
disabled={isPending}
>
{configSurfaceLoading ? (
@@ -424,7 +424,7 @@ export function DefaultConfigStep({
<OnboardingFooter>
<Button
className="h-9 whitespace-nowrap rounded-full px-6 text-sm hover:bg-foreground/10"
className="h-9 whitespace-nowrap rounded-full px-6 text-sm text-primary hover:bg-primary/10 hover:text-primary"
data-testid="onboarding-config-skip"
disabled={isSaving}
onClick={handleSkip}

View File

@@ -10,7 +10,7 @@ import {
} from "./OnboardingSlideTransition";
import { ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS } from "./onboardingCardStyles";
const GUIDANCE_ICON_CLASS = `flex size-10 shrink-0 items-center justify-center rounded-full ${ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS}`;
const GUIDANCE_ICON_CLASS = `flex size-10 shrink-0 items-center justify-center rounded-full text-foreground ${ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS}`;
export function IdentityKeyIntroduction({
direction,
@@ -57,7 +57,10 @@ export function IdentityKeyIntroduction({
<div className="flex w-full flex-1 items-center py-10">
<div className="w-full space-y-6" data-testid="onboarding-key-guidance">
<div className="flex min-h-14 items-center gap-4 text-left">
<span className={GUIDANCE_ICON_CLASS}>
<span
className={GUIDANCE_ICON_CLASS}
data-testid="identity-key-guidance-icon"
>
<ShieldCheck aria-hidden className="size-5" />
</span>
<p className="text-base leading-6 text-foreground">
@@ -65,7 +68,10 @@ export function IdentityKeyIntroduction({
</p>
</div>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className={GUIDANCE_ICON_CLASS}>
<span
className={GUIDANCE_ICON_CLASS}
data-testid="identity-key-guidance-icon"
>
<CircleSlash2 aria-hidden className="size-5" />
</span>
<p className="text-base leading-6 text-foreground">
@@ -73,7 +79,10 @@ export function IdentityKeyIntroduction({
</p>
</div>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className={GUIDANCE_ICON_CLASS}>
<span
className={GUIDANCE_ICON_CLASS}
data-testid="identity-key-guidance-icon"
>
<HardDriveDownload aria-hidden className="size-5" />
</span>
<p className="text-base leading-6 text-foreground">

View File

@@ -288,7 +288,7 @@ describe("SetupStep cached-ready revalidation", () => {
queryClient.clear();
});
it("hands off only the API harness selected while forced discovery is pending", async () => {
it("hands Buzz directly to API config while forced discovery is pending", async () => {
const queryClient = makeQueryClient();
queryClient.setQueryData(acpRuntimesQueryKey, [
catalogEntry("buzz-agent", "not_applicable"),
@@ -324,8 +324,8 @@ describe("SetupStep cached-ready revalidation", () => {
});
assert.deepEqual(
nextCalls,
[],
"cached Buzz readiness cannot advance before forced discovery settles",
[[["buzz-agent"], "method"]],
"Buzz API configuration does not wait for runtime discovery",
);
await act(async () => {
@@ -336,7 +336,7 @@ describe("SetupStep cached-ready revalidation", () => {
assert.deepEqual(
nextCalls,
[[["buzz-agent"], "method"]],
"successful discovery advances with only the explicitly chosen API harness",
"discovery completion does not navigate a second time",
);
assert.ok(
readyRuntimeIdSnapshots.some(

View File

@@ -23,8 +23,8 @@ import {
runtimeIsReadyForOnboarding,
} from "./onboardingRuntimeSelection";
import {
getRuntimesForConnectionMethod,
type HarnessConnectionMethod,
orderRuntimesForConnectionMethod,
runtimeUnavailableDescription,
} from "./harnessConnectionOptions";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
@@ -679,20 +679,14 @@ function RuntimeProvidersSection({
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [canScrollUp, setCanScrollUp] = React.useState(false);
const [canScrollDown, setCanScrollDown] = React.useState(false);
const orderedItems = React.useMemo(() => {
const visible = getRuntimesForConnectionMethod(
getVisibleOnboardingRuntimes(items),
method,
);
if (method !== "api") return visible;
const group = (runtime: AcpRuntimeCatalogEntry) => {
if (runtime.id === "buzz-agent") return 0;
if (runtime.id === "goose") return 1;
return runtime.availability === "available" ? 2 : 3;
};
return [...visible].sort((left, right) => group(left) - group(right));
}, [items, method]);
const orderedItems = React.useMemo(
() =>
orderRuntimesForConnectionMethod(
getVisibleOnboardingRuntimes(items),
method,
),
[items, method],
);
const updateScrollEdges = React.useCallback(() => {
const element = scrollRef.current;
if (!element) return;
@@ -1076,19 +1070,8 @@ function SetupStepContent({
setLocalDirection("forward");
if (nextMethod === "api") {
const buzzRuntime = runtimeProviders.items.find(
(runtime) => runtime.id === "buzz-agent",
);
if (buzzRuntime) {
if (readinessConfirmed && runtimeIsReadyForOnboarding(buzzRuntime)) {
actions.next([buzzRuntime.id], "method");
return;
}
setDetailConfigBackTarget("method");
setSelectedRuntimeId(buzzRuntime.id);
setStage("detail");
return;
}
actions.next(["buzz-agent"], "method");
return;
}
setStage("list");
@@ -1096,6 +1079,10 @@ function SetupStepContent({
function openRuntime(runtimeId: string) {
setLocalDirection("forward");
if (runtimeId === "buzz-agent") {
actions.next([runtimeId]);
return;
}
const runtime = runtimeProviders.items.find(
(item) => item.id === runtimeId,
);
@@ -1129,7 +1116,7 @@ function SetupStepContent({
<ConnectionMethodSection onSelect={chooseMethod} />
<OnboardingFooter>
<Button
className="h-9 whitespace-nowrap rounded-full px-6 text-sm hover:bg-foreground/10"
className="h-9 whitespace-nowrap rounded-full px-6 text-sm text-primary hover:bg-primary/10 hover:text-primary"
data-testid="onboarding-setup-skip"
onClick={() => actions.next([])}
type="button"
@@ -1150,7 +1137,7 @@ function SetupStepContent({
/>
<OnboardingFooter>
<Button
className="h-9 whitespace-nowrap rounded-full px-6 text-sm hover:bg-foreground/10"
className="h-9 whitespace-nowrap rounded-full px-6 text-sm text-primary hover:bg-primary/10 hover:text-primary"
data-testid="onboarding-setup-skip"
onClick={() => actions.next([])}
type="button"

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import {
getRuntimesForConnectionMethod,
orderRuntimesForConnectionMethod,
runtimeSupportsConnectionMethod,
} from "./harnessConnectionOptions.ts";
@@ -36,3 +37,24 @@ test("custom harnesses are not assigned an onboarding connection method", () =>
);
assert.equal(runtimeSupportsConnectionMethod("custom", "api"), false);
});
test("runtime ordering keeps one contiguous not-installed section", () => {
const mixed = [
{ id: "buzz-agent", availability: "not_installed" },
{ id: "goose", availability: "available" },
{ id: "openclaw", availability: "not_installed" },
{ id: "opencode", availability: "available" },
];
assert.deepEqual(
orderRuntimesForConnectionMethod(mixed, "api").map(
({ id, availability }) => `${id}:${availability}`,
),
[
"goose:available",
"opencode:available",
"buzz-agent:not_installed",
"openclaw:not_installed",
],
);
});

View File

@@ -46,3 +46,29 @@ export function getRuntimesForConnectionMethod(
runtimeSupportsConnectionMethod(runtime.id, method),
);
}
/**
* Keeps the installed and unavailable groups contiguous so the list can render
* a single "Not installed" divider. API-first choices stay prioritized within
* their availability group instead of splitting that divider in two.
*/
export function orderRuntimesForConnectionMethod(
runtimes: readonly AcpRuntimeCatalogEntry[],
method: HarnessConnectionMethod,
) {
const priority = (runtime: AcpRuntimeCatalogEntry) => {
if (method !== "api") return 0;
if (runtime.id === "buzz-agent") return 0;
if (runtime.id === "goose") return 1;
return 2;
};
return [...getRuntimesForConnectionMethod(runtimes, method)].sort(
(left, right) => {
const availabilityDifference =
Number(left.availability !== "available") -
Number(right.availability !== "available");
return availabilityDifference || priority(left) - priority(right);
},
);
}

View File

@@ -243,7 +243,7 @@ test("setup filters the bundled harnesses by connection method", async ({
await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
});
test("API selection survives a pending forced discovery with multiple ready harnesses", async ({
test("API selection opens Buzz config immediately while discovery is pending", async ({
page,
}) => {
await installMockBridge(
@@ -260,28 +260,52 @@ test("API selection survives a pending forced discovery with multiple ready harn
await page.goto("/");
await navigateToSetupPage(page, null);
// Prime the shared catalog, then leave and re-enter setup so the second
// forced discovery runs against cached multi-runtime readiness.
// Selecting API never waits on discovery or shows Buzz's generic auth step.
await page.getByTestId("onboarding-harness-method-api").click();
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(page.getByRole("heading", { name: "Connect Buzz" })).toHaveCount(
0,
);
await expect(page.getByTestId("global-agent-provider")).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
expect(await readSavedRuntime(page)).toBeNull();
await page.getByTestId("onboarding-use-different-harness").click();
await expect(
page.getByRole("heading", { name: "Choose a harness" }),
).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-goose")).toBeVisible();
});
test("choosing signed-out Buzz skips the generic harness auth step", async ({
page,
}) => {
await installMockBridge(
page,
{
acpRuntimesCatalog: [
runtime("buzz-agent", "available", { status: "logged_out" }),
runtime("goose", "available", { status: "not_applicable" }),
],
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToSetupPage(page, "api");
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await page.getByTestId("onboarding-use-different-harness").click();
await expect(
page.getByRole("heading", { name: "Choose a harness" }),
).toBeVisible();
await page.getByTestId("onboarding-runtime-details-buzz-agent").click();
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await page.getByTestId("onboarding-back").click();
await expect(
page.getByRole("heading", { name: "Choose a harness" }),
).toBeVisible();
await page.getByTestId("onboarding-back").click();
await page.getByTestId("onboarding-harness-method-api").click();
await expect(page.getByTestId("onboarding-page-config")).toHaveCount(0);
await expect(page.getByTestId("onboarding-page-config")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(page.getByRole("heading", { name: "Connect Buzz" })).toHaveCount(
0,
);
await expect(page.getByTestId("global-agent-provider")).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
expect(await readSavedRuntime(page)).toBeNull();
});
test("setup distinguishes a missing CLI from an installed desktop app", async ({

View File

@@ -123,10 +123,10 @@ test("backup step appears on fresh-key path after profile submit", async ({
});
// ---------------------------------------------------------------------------
// Key-created view: masked key with explicit Reveal and Copy actions.
// Key-created view: visible key with hover-to-copy treatment.
// ---------------------------------------------------------------------------
test("key view keeps the secret masked until Reveal and Copy stay explicit", async ({
test("key view reveals the key by default and replaces it with Copy on hover", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
@@ -135,26 +135,24 @@ test("key view keeps the secret masked until Reveal and Copy stay explicit", asy
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
const key = page.getByTestId("backup-key-value");
const keyWell = page.getByTestId("backup-key-well");
const copyButton = page.getByTestId("backup-copy-key");
await expect(key).toBeVisible();
await expect(key).not.toContainText("nsec1mock");
expect(await invokedCommands(page)).not.toContain("get_nsec");
// The credential enters the DOM only after an explicit reveal action.
await page.getByTestId("backup-reveal-key").click();
await expect(key).toContainText("nsec1mock");
expect(await invokedCommands(page)).toContain("get_nsec");
await page.getByTestId("backup-reveal-key").click();
await expect(key).not.toContainText("nsec1mock");
await expect(page.getByTestId("backup-reveal-key")).toHaveCount(0);
await expect(key).toHaveCSS("filter", "none");
await expect(copyButton).toHaveCSS("opacity", "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 keyWell.hover();
await expect(key).toHaveCSS("filter", /blur\(4px\)/);
await expect(copyButton).toHaveCSS("opacity", "1");
await copyButton.click();
await expect(copyButton).toContainText("Copied to clipboard");
await expect
.poll(async () => invokedCommands(page))
.toContain("copy_text_to_clipboard");
await expect(key).not.toContainText("nsec1mock");
await expect(key).toContainText("nsec1mock");
// The backup action gains a subtle surface on hover without shifting its
// content or changing the resting state.
@@ -347,10 +345,10 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
// Creating an encrypted backup uses a dedicated native command and does not
// fetch the raw identity key into the renderer.
// The visible-by-default key card has already loaded the raw identity key;
// encrypted backup still uses the dedicated native backup command.
const commands = await invokedCommands(page);
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
// Completion remains inside the optional security subview. Return to the
@@ -507,7 +505,7 @@ test("copy shows inline error when get_nsec fails and Next still advances", asyn
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.
// The default reveal fails first; explicit copy retries and succeeds.
await installMockBridge(
page,
{ nsecErrors: ["Keychain locked", null] },
@@ -517,20 +515,17 @@ test("Copy retries after an initial key read fails", async ({ page }) => {
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByRole("button", { name: "Create my private key" }).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",
);
// Copy retries the read and clears the error without revealing the DOM value.
// Copy retries the read, clears the error, and restores the visible key.
await page.getByTestId("backup-key-well").hover();
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-key-value")).toContainText("nsec1mock");
await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
});

View File

@@ -285,6 +285,12 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
"forward",
);
await expectUsesFullCardWidth(page.getByTestId("onboarding-key-guidance"));
const guidanceIcons = page.getByTestId("identity-key-guidance-icon");
await expect(guidanceIcons).toHaveCount(3);
for (const icon of await guidanceIcons.all()) {
await expect(icon).toHaveCSS("color", "rgb(23, 23, 23)");
await expect(icon).toHaveClass(/bg-\[#e2e2e2\]\/30/);
}
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-key-introduction.png` });
await page.getByRole("button", { name: "Create my private key" }).click();
@@ -356,24 +362,22 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02a-backup-option-hover.png` });
// 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.
// The generated key is readable at rest. Hovering the well blurs it and
// replaces the key with the copy action; the reveal eye is intentionally gone.
const keyValue = page.getByTestId("backup-key-value");
const revealButton = page.getByTestId("backup-reveal-key");
const keyWell = page.getByTestId("backup-key-well");
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(page.getByTestId("backup-reveal-key")).toHaveCount(0);
await expect(copyButton).toHaveCSS("opacity", "0");
await keyWell.hover();
await expect(keyValue).toHaveCSS("filter", /blur\(4px\)/);
await expect(copyButton).toHaveCSS("opacity", "1");
await expect(copyButton).toBeEnabled();
await copyButton.click();
await expect(copyButton).toContainText("Copied to clipboard");
await expect(keyValue).not.toContainText("nsec1mock");
await expect(keyValue).toContainText("nsec1mock");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-copy.png` });
@@ -435,10 +439,15 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
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-skip")).toHaveCSS(
"color",
"rgb(23, 23, 23)",
);
await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
await expect(
page.getByText("CLI not detected", { exact: false }),
).toHaveCount(0);
await expect(page.getByText("Not installed", { exact: true })).toHaveCount(1);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03b-subscriptions.png` });
@@ -487,6 +496,10 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
);
await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
await expectUsesFullCardWidth(page.getByTestId("global-agent-provider"));
await expect(page.getByText("Provider", { exact: true })).toHaveCSS(
"color",
"rgb(23, 23, 23)",
);
await expect(
page.getByTestId("onboarding-use-different-harness"),
).toBeVisible();