mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish onboarding runtime error states (#2116)
Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3
Wes
Pinky
parent
820d023305
commit
31090b186f
@@ -0,0 +1,49 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
type RuntimeErrorTooltipProps = {
|
||||
className?: string;
|
||||
detail: string;
|
||||
label: string;
|
||||
showIcon?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export function RuntimeErrorTooltip({
|
||||
className,
|
||||
detail,
|
||||
label,
|
||||
showIcon = false,
|
||||
testId,
|
||||
}: RuntimeErrorTooltipProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={`${label}. ${detail}`}
|
||||
className={className}
|
||||
data-testid={testId}
|
||||
role="status"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Focus exposes the error tooltip to keyboard users without adding a nested card action
|
||||
tabIndex={0}
|
||||
>
|
||||
{showIcon ? (
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-80 text-left"
|
||||
side="bottom"
|
||||
sideOffset={12}
|
||||
>
|
||||
<span className="leading-4">{detail}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
runtimeCanBeSelected,
|
||||
} from "./onboardingRuntimeSelection";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
|
||||
import {
|
||||
@@ -153,6 +154,7 @@ function RuntimeStatus({
|
||||
runtime.authStatus.status === "logged_out",
|
||||
});
|
||||
const connectMutation = useConnectAcpRuntimeMutation();
|
||||
const runtimesQuery = useAcpRuntimesQuery();
|
||||
const authMethods = getOnboardingAuthMethods(
|
||||
runtime,
|
||||
methodsQuery.data?.methods ?? [],
|
||||
@@ -197,14 +199,18 @@ function RuntimeStatus({
|
||||
SET UP
|
||||
</Button>
|
||||
{methodsQuery.error instanceof Error ? (
|
||||
<span className="text-2xs text-destructive">
|
||||
Couldn’t load sign-in options.
|
||||
</span>
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
|
||||
detail="Couldn’t load sign-in options."
|
||||
label="Sign-in unavailable"
|
||||
/>
|
||||
) : null}
|
||||
{connectMutation.error instanceof Error ? (
|
||||
<span className="text-2xs text-destructive">
|
||||
{connectMutation.error.message}
|
||||
</span>
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
|
||||
detail="Couldn’t start sign-in. Try again."
|
||||
label="Sign-in failed"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -223,11 +229,44 @@ function RuntimeStatus({
|
||||
);
|
||||
}
|
||||
|
||||
if (installError) {
|
||||
if (
|
||||
runtime.availability === "available" &&
|
||||
runtime.authStatus.status === "unknown"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-8 w-8 items-center justify-center">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Check ${runtime.label} again`}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
disabled={runtimesQuery.isFetching}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void runtimesQuery.refetch();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{runtimesQuery.isFetching ? "CHECKING…" : "CHECK AGAIN"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (installError && runtime.canAutoInstall) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Retry ${runtime.label} setup`}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
data-setup-flash={isSetupFlashing ? "true" : undefined}
|
||||
data-testid={`onboarding-runtime-install-${runtime.id}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
onInstall();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
SET UP
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,7 +441,7 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
|
||||
if (runtime.availability === "cli_missing") {
|
||||
return "ACP adapter detected; CLI missing.";
|
||||
}
|
||||
return "Not installed yet.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function isSupportedOnboardingAuthMethod(
|
||||
@@ -452,36 +491,26 @@ function getOnboardingAuthMethods(
|
||||
return supported;
|
||||
}
|
||||
|
||||
function RuntimeAuthActions({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
|
||||
const runtimesQuery = useAcpRuntimesQuery();
|
||||
|
||||
function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
|
||||
if (runtime.authStatus.status === "config_invalid") {
|
||||
return (
|
||||
<p className="mt-2 text-2xs leading-4 text-destructive">
|
||||
{runtime.authStatus.diagnostic}
|
||||
</p>
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
|
||||
detail="Check this runtime’s configuration and try again."
|
||||
label="Configuration invalid"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (runtime.authStatus.status === "unknown") {
|
||||
if (
|
||||
runtime.availability === "available" &&
|
||||
runtime.authStatus.status === "unknown"
|
||||
) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col items-center gap-1.5">
|
||||
<span className="text-2xs text-muted-foreground">
|
||||
Couldn’t verify authentication.
|
||||
</span>
|
||||
<Button
|
||||
disabled={runtimesQuery.isFetching}
|
||||
className="h-6 rounded-full px-2 text-2xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void runtimesQuery.refetch();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{runtimesQuery.isFetching ? "Checking…" : "Check again"}
|
||||
</Button>
|
||||
</div>
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
|
||||
detail="Couldn’t verify authentication."
|
||||
label="Status unavailable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -552,18 +581,29 @@ function RuntimeCard({
|
||||
runtime={runtime}
|
||||
setupFlashToken={setupFlashToken}
|
||||
/>
|
||||
{!isAvailable && !installError ? (
|
||||
<p className="max-w-[13rem] text-2xs leading-4 text-muted-foreground">
|
||||
{!isAvailable && runtimeDetailText(runtime) ? (
|
||||
<p
|
||||
aria-hidden={installError ? "true" : undefined}
|
||||
className={cn(
|
||||
"max-w-[13rem] text-2xs leading-4 text-muted-foreground",
|
||||
installError && "invisible",
|
||||
)}
|
||||
>
|
||||
{runtimeDetailText(runtime)}
|
||||
</p>
|
||||
) : null}
|
||||
{installError ? (
|
||||
<p className="max-w-[13rem] text-2xs leading-4 text-destructive">
|
||||
{installError}
|
||||
</p>
|
||||
) : null}
|
||||
<RuntimeAuthActions runtime={runtime} />
|
||||
</div>
|
||||
{installError ? (
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
|
||||
detail="Setup couldn’t be completed. Try again."
|
||||
label="Setup failed"
|
||||
showIcon
|
||||
testId={`onboarding-runtime-error-${runtime.id}`}
|
||||
/>
|
||||
) : (
|
||||
<RuntimeAuthError runtime={runtime} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ type E2eConfig = {
|
||||
acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[];
|
||||
acpRuntimesDelayMs?: number;
|
||||
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
|
||||
acpAuthMethodsError?: string;
|
||||
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
|
||||
connectAcpRuntimeDelayMs?: number;
|
||||
connectAcpRuntimeError?: string;
|
||||
@@ -6699,6 +6700,10 @@ async function handleDiscoverAcpAuthMethods(
|
||||
args: { runtimeId?: string },
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawAcpAuthMethodsResult> {
|
||||
const error = config?.mock?.acpAuthMethodsError;
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
const runtimeId = args.runtimeId ?? "";
|
||||
const configured = config?.mock?.acpAuthMethods?.[runtimeId];
|
||||
if (configured) {
|
||||
|
||||
@@ -636,13 +636,35 @@ for (const authStatus of [
|
||||
await setupButton.click();
|
||||
await expect(card).toHaveAttribute("aria-checked", "true");
|
||||
} else if (authStatus.status === "unknown") {
|
||||
const error = card.getByRole("status", {
|
||||
name: /Status unavailable/,
|
||||
});
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toHaveCSS("font-size", "12px");
|
||||
await expect(error).toHaveCSS("position", "absolute");
|
||||
await error.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText(
|
||||
"Couldn’t verify authentication.",
|
||||
);
|
||||
await expect(
|
||||
card.getByText("Couldn’t verify authentication"),
|
||||
).toBeVisible();
|
||||
card.getByRole("button", { name: "Check Claude again" }),
|
||||
).toHaveText("CHECK AGAIN");
|
||||
await card.click();
|
||||
await expect(card).toHaveAttribute("aria-checked", "true");
|
||||
} else {
|
||||
await expect(card.getByText("Fix Claude config")).toBeVisible();
|
||||
const error = card.getByRole("status", {
|
||||
name: /Configuration invalid/,
|
||||
});
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toHaveCSS("font-size", "12px");
|
||||
await expect(error).toHaveCSS("position", "absolute");
|
||||
await error.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText(
|
||||
"Check this runtime’s configuration and try again.",
|
||||
);
|
||||
await expect(page.getByRole("tooltip")).not.toContainText(
|
||||
"Fix Claude config",
|
||||
);
|
||||
await card.click();
|
||||
await expect(card).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
@@ -678,6 +700,199 @@ test("setup cards only show checks after user selection", async ({ page }) => {
|
||||
).toHaveText("INSTALLED");
|
||||
});
|
||||
|
||||
test("unavailable sign-in options use the compact error and tooltip pattern", async ({
|
||||
page,
|
||||
}) => {
|
||||
const discoveryError = "Auth discovery failed with sensitive details";
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
acpRuntimesCatalog: [
|
||||
availableRuntime("claude", { status: "logged_out" }),
|
||||
],
|
||||
acpAuthMethodsError: discoveryError,
|
||||
},
|
||||
{ skipCommunitySeed: true, skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
await navigateToSetupPage(page);
|
||||
|
||||
const card = page.getByTestId("onboarding-runtime-claude");
|
||||
const setupButton = card.getByTestId(
|
||||
"onboarding-runtime-instructions-claude",
|
||||
);
|
||||
const error = card.getByRole("status", { name: /Sign-in unavailable/ });
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toHaveCSS("font-size", "12px");
|
||||
await expect(error).toHaveCSS("position", "absolute");
|
||||
await expect(error).toHaveCSS("white-space", "nowrap");
|
||||
await expect(error).not.toHaveAttribute("title");
|
||||
await error.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText(
|
||||
"Couldn’t load sign-in options.",
|
||||
);
|
||||
await expect(page.getByRole("tooltip")).not.toContainText(discoveryError);
|
||||
await expect(setupButton).toBeVisible();
|
||||
await expect(setupButton).toHaveText("SET UP");
|
||||
});
|
||||
|
||||
test("failed sign-in uses the compact error and tooltip pattern", async ({
|
||||
page,
|
||||
}) => {
|
||||
const connectionError = "Terminal launch failed with sensitive details";
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
acpRuntimesCatalog: [
|
||||
availableRuntime("claude", { status: "logged_out" }),
|
||||
],
|
||||
acpAuthMethods: {
|
||||
claude: {
|
||||
methods: [
|
||||
{
|
||||
id: "login",
|
||||
name: "Sign in",
|
||||
description: null,
|
||||
type: "terminal",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
connectAcpRuntimeError: connectionError,
|
||||
},
|
||||
{ skipCommunitySeed: true, skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
await navigateToSetupPage(page);
|
||||
|
||||
const card = page.getByTestId("onboarding-runtime-claude");
|
||||
const setupButton = card.getByTestId(
|
||||
"onboarding-runtime-instructions-claude",
|
||||
);
|
||||
const heading = card.getByRole("heading", { name: "Claude" });
|
||||
const headingTopBefore = await heading.evaluate(
|
||||
(element) => element.getBoundingClientRect().top,
|
||||
);
|
||||
const setupTopBefore = await setupButton.evaluate(
|
||||
(element) => element.getBoundingClientRect().top,
|
||||
);
|
||||
|
||||
await setupButton.click();
|
||||
|
||||
const error = card.getByRole("status", { name: /Sign-in failed/ });
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toHaveCSS("font-size", "12px");
|
||||
await expect(error).toHaveCSS("position", "absolute");
|
||||
await expect(error).toHaveCSS("white-space", "nowrap");
|
||||
await expect(error).not.toHaveAttribute("title");
|
||||
await error.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText(
|
||||
"Couldn’t start sign-in. Try again.",
|
||||
);
|
||||
await expect(page.getByRole("tooltip")).not.toContainText(connectionError);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("tooltip")).toBeHidden();
|
||||
await error.focus();
|
||||
const tooltip = page.getByRole("tooltip");
|
||||
await expect(tooltip).toHaveText("Couldn’t start sign-in. Try again.");
|
||||
await expect(error).toHaveAccessibleName(
|
||||
"Sign-in failed. Couldn’t start sign-in. Try again.",
|
||||
);
|
||||
const [cardBox, setupBox, tooltipBox] = await Promise.all([
|
||||
card.boundingBox(),
|
||||
setupButton.boundingBox(),
|
||||
tooltip.boundingBox(),
|
||||
]);
|
||||
expect(cardBox).not.toBeNull();
|
||||
expect(setupBox).not.toBeNull();
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
expect(tooltipBox?.y).toBeGreaterThanOrEqual(
|
||||
(cardBox?.y ?? 0) + (cardBox?.height ?? 0),
|
||||
);
|
||||
expect(tooltipBox?.y).toBeGreaterThanOrEqual(
|
||||
(setupBox?.y ?? 0) + (setupBox?.height ?? 0),
|
||||
);
|
||||
await expect(setupButton).toBeVisible();
|
||||
expect(
|
||||
await heading.evaluate((element) => element.getBoundingClientRect().top),
|
||||
).toBe(headingTopBefore);
|
||||
expect(
|
||||
await setupButton.evaluate(
|
||||
(element) => element.getBoundingClientRect().top,
|
||||
),
|
||||
).toBe(setupTopBefore);
|
||||
});
|
||||
|
||||
test("failed install pins a single-line 12px error without moving card content", async ({
|
||||
page,
|
||||
}) => {
|
||||
const installError = "Install already in progress with additional details";
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
installAcpRuntimeResult: {
|
||||
success: false,
|
||||
steps: [
|
||||
{
|
||||
step: "Install adapter",
|
||||
command: "npm install adapter",
|
||||
success: false,
|
||||
stdout: "",
|
||||
stderr: installError,
|
||||
exit_code: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ skipCommunitySeed: true, skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
await navigateToSetupPage(page);
|
||||
|
||||
const card = page.getByTestId("onboarding-runtime-claude");
|
||||
const setupButton = page.getByTestId("onboarding-runtime-install-claude");
|
||||
const heading = card.getByRole("heading", { name: "Claude" });
|
||||
const headingTopBefore = await heading.evaluate(
|
||||
(element) => element.getBoundingClientRect().top,
|
||||
);
|
||||
const detail = card.getByText("CLI detected; ACP adapter missing.");
|
||||
const detailTopBefore = await detail.evaluate(
|
||||
(element) => element.getBoundingClientRect().top,
|
||||
);
|
||||
const setupTopBefore = await setupButton.evaluate(
|
||||
(element) => element.getBoundingClientRect().top,
|
||||
);
|
||||
|
||||
await setupButton.click();
|
||||
|
||||
const error = page.getByTestId("onboarding-runtime-error-claude");
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toHaveText(/Setup failed/);
|
||||
await expect(error).not.toHaveAttribute("title");
|
||||
await expect(setupButton).toBeVisible();
|
||||
await expect(setupButton).toHaveText("SET UP");
|
||||
await expect(error).toHaveCSS("font-size", "12px");
|
||||
await expect(error).toHaveCSS("position", "absolute");
|
||||
await expect(error).toHaveCSS("white-space", "nowrap");
|
||||
await expect(error.locator("span")).toHaveCSS("text-overflow", "ellipsis");
|
||||
await error.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText(
|
||||
"Setup couldn’t be completed. Try again.",
|
||||
);
|
||||
await expect(page.getByRole("tooltip")).not.toContainText(installError);
|
||||
expect(
|
||||
await heading.evaluate((element) => element.getBoundingClientRect().top),
|
||||
).toBe(headingTopBefore);
|
||||
expect(
|
||||
await detail.evaluate((element) => element.getBoundingClientRect().top),
|
||||
).toBe(detailTopBefore);
|
||||
expect(
|
||||
await setupButton.evaluate(
|
||||
(element) => element.getBoundingClientRect().top,
|
||||
),
|
||||
).toBe(setupTopBefore);
|
||||
});
|
||||
|
||||
test("successful install still waits for refreshed runtime readiness", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -130,6 +130,7 @@ type MockBridgeOptions = {
|
||||
acpRuntimesCatalog?: Record<string, unknown>[];
|
||||
acpRuntimesDelayMs?: number;
|
||||
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
|
||||
acpAuthMethodsError?: string;
|
||||
connectAcpRuntimeResult?: { launched: boolean };
|
||||
connectAcpRuntimeDelayMs?: number;
|
||||
connectAcpRuntimeError?: string;
|
||||
|
||||
Reference in New Issue
Block a user