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