Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ concurrency:

jobs:
macos:
name: macOS desktop artifacts
runs-on: macos-latest
name: macOS arm64 desktop artifacts
runs-on: macos-15
timeout-minutes: 45

steps:
Expand All @@ -51,6 +51,9 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm

- name: Verify arm64 runner
run: test "$(uname -m)" = "arm64"

- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline

Expand Down Expand Up @@ -100,7 +103,7 @@ jobs:
exit 1
fi

- name: Package universal desktop artifacts
- name: Package arm64 desktop artifacts
env:
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE_P12 }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
Expand Down Expand Up @@ -160,7 +163,7 @@ jobs:
- name: Upload desktop workflow artifacts
uses: actions/upload-artifact@v4
with:
name: bb-desktop-macos-universal
name: bb-desktop-macos-arm64
path: |
apps/desktop/release/*.dmg
apps/desktop/release/*.zip
Expand Down Expand Up @@ -244,7 +247,7 @@ jobs:
{
echo "## bb desktop artifacts"
echo
echo "- universal macOS .dmg, .zip, latest-mac.yml, and desktop-version.json uploaded as workflow artifacts"
echo "- arm64 macOS .dmg, .zip, latest-mac.yml, and desktop-version.json uploaded as workflow artifacts"
echo "- version: ${VERSION}"
echo "- prerelease version: ${IS_PRERELEASE}"
echo "- stable release publication requested and allowed: ${SHOULD_PUBLISH}"
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MainView } from "./views/MainView";
import { ProjectMainView } from "./views/ProjectMainView";
import { NewManagerDialogProvider } from "./hooks/useNewManagerDialog";
import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject";
import { ProviderCliHealthToasts } from "./components/provider-cli/ProviderCliHealthToasts";
import {
useDesktopUpdateAvailableToast,
useUpdateAvailableToast,
Expand Down Expand Up @@ -93,6 +94,7 @@ export function App() {
return (
<QuickCreateProjectProvider>
<NewManagerDialogProvider>
<ProviderCliHealthToasts />
<Routes>
<Route
path={AUTH_CALLBACK_ROUTE_PATH}
Expand Down
299 changes: 299 additions & 0 deletions apps/app/src/components/provider-cli/ProviderCliHealthToasts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
// @vitest-environment jsdom

import { act, cleanup, render, waitFor } from "@testing-library/react";
import type { QueryClient } from "@tanstack/react-query";
import type {
ProviderCliStatus,
ProviderCliStatusResponse,
} from "@bb/host-daemon-contract";
import { afterEach, describe, expect, it, vi } from "vitest";
import { localProviderCliStatusQueryKey } from "@/hooks/queries/query-keys";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { installFetchRoutes, jsonResponse } from "@/test/http-test-utils";
import { ProviderCliHealthToasts } from "./ProviderCliHealthToasts";

interface ToastButton {
label: string;
onClick: () => void;
}

interface ProviderCliWarningToastOptions {
id: string;
description: string;
duration: number;
closeButton: boolean;
action?: ToastButton;
cancel?: ToastButton;
onDismiss?: () => void;
}

interface ProviderCliToastInvocation {
title: string;
options: ProviderCliWarningToastOptions;
}

interface ProviderCliHealthFetchState {
hostDaemonPort: number;
status: ProviderCliStatusResponse;
}

interface ProviderCliHealthRenderResult {
queryClient: QueryClient;
state: ProviderCliHealthFetchState;
}

const providerCliToastState = vi.hoisted(() => {
const invocations: ProviderCliToastInvocation[] = [];
const activeToasts = new Map<string, ProviderCliWarningToastOptions>();
const warning = vi.fn(
(title: string, options: ProviderCliWarningToastOptions) => {
invocations.push({ title, options });
activeToasts.set(options.id, options);
},
);
const dismiss = vi.fn((toastId: string | number | undefined) => {
if (typeof toastId !== "string") {
return;
}
const options = activeToasts.get(toastId);
activeToasts.delete(toastId);
options?.onDismiss?.();
});
return {
activeToasts,
dismiss,
error: vi.fn(),
invocations,
success: vi.fn(),
warning,
};
});

vi.mock("sonner", () => ({
toast: {
dismiss: providerCliToastState.dismiss,
error: providerCliToastState.error,
success: providerCliToastState.success,
warning: providerCliToastState.warning,
},
}));

const HOST_DAEMON_PORT = 4123;
const CODEX_TOAST_ID = "provider-cli-health:codex";
const CODEX_MISSING_FINGERPRINT = "codex:missing:0.133.0";
const DISMISSED_STORAGE_KEY_PREFIX = "bb:provider-cli-toast:dismissed-v2:";
const CODEX_DISMISSED_STORAGE_KEY = `${DISMISSED_STORAGE_KEY_PREFIX}${CODEX_MISSING_FINGERPRINT}`;

function codexMissingStatus(): ProviderCliStatus {
return {
currentVersion: null,
displayName: "Codex",
executableName: "codex",
executablePath: null,
installAction: {
command: "npm install -g @openai/codex",
commandKind: "exec",
kind: "install",
label: "Install",
},
installed: false,
installSource: "notInstalled",
latestVersion: "0.133.0",
needsUpdate: false,
npmGlobalPackageVersion: null,
npmPackageName: "@openai/codex",
};
}

function codexInstalledStatus(): ProviderCliStatus {
return {
currentVersion: "0.133.0",
displayName: "Codex",
executableName: "codex",
executablePath: "/usr/local/bin/codex",
installAction: null,
installed: true,
installSource: "npmGlobal",
latestVersion: "0.133.0",
needsUpdate: false,
npmGlobalPackageVersion: "0.133.0",
npmPackageName: "@openai/codex",
};
}

function claudeCodeInstalledStatus(): ProviderCliStatus {
return {
currentVersion: "1.0.0",
displayName: "Claude Code",
executableName: "claude",
executablePath: "/usr/local/bin/claude",
installAction: null,
installed: true,
installSource: "npmGlobal",
latestVersion: "1.0.0",
needsUpdate: false,
npmGlobalPackageVersion: "1.0.0",
npmPackageName: "@anthropic-ai/claude-code",
};
}

function statusResponseWithCodex(
codex: ProviderCliStatus,
): ProviderCliStatusResponse {
return {
claudeCode: claudeCodeInstalledStatus(),
codex,
};
}

function installProviderCliHealthFetchRoutes(
state: ProviderCliHealthFetchState,
): void {
installFetchRoutes([
{
pathname: "/api/v1/system/config",
handler: async () =>
jsonResponse({
hostDaemonPort: state.hostDaemonPort,
voiceTranscriptionEnabled: false,
}),
},
{
pathname: "/provider-clis/status",
port: state.hostDaemonPort,
handler: async () => jsonResponse(state.status),
},
]);
}

function renderProviderCliHealthToasts(
initialStatus: ProviderCliStatusResponse,
): ProviderCliHealthRenderResult {
const state: ProviderCliHealthFetchState = {
hostDaemonPort: HOST_DAEMON_PORT,
status: initialStatus,
};
installProviderCliHealthFetchRoutes(state);

const { queryClient, wrapper } = createQueryClientTestHarness();
render(<ProviderCliHealthToasts />, { wrapper });

return { queryClient, state };
}

function resetProviderCliToastState(): void {
providerCliToastState.activeToasts.clear();
providerCliToastState.invocations.splice(0);
providerCliToastState.dismiss.mockClear();
providerCliToastState.error.mockClear();
providerCliToastState.success.mockClear();
providerCliToastState.warning.mockClear();
}

function requireLatestCodexToastInvocation(): ProviderCliToastInvocation {
for (
let index = providerCliToastState.invocations.length - 1;
index >= 0;
index -= 1
) {
const invocation = providerCliToastState.invocations[index];
if (invocation.options.id === CODEX_TOAST_ID) {
return invocation;
}
}
throw new Error("Expected a Codex provider CLI toast invocation.");
}

async function waitForVisibleCodexToast(): Promise<ProviderCliToastInvocation> {
await waitFor(() => {
expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(true);
});
return requireLatestCodexToastInvocation();
}

function clickToastCancel(invocation: ProviderCliToastInvocation): void {
const cancel = invocation.options.cancel;
if (!cancel) {
throw new Error("Expected provider CLI toast to have a cancel action.");
}
cancel.onClick();
}

async function refetchProviderCliStatus(
result: ProviderCliHealthRenderResult,
): Promise<void> {
await act(async () => {
await result.queryClient.refetchQueries({
queryKey: localProviderCliStatusQueryKey(result.state.hostDaemonPort),
});
});
}

afterEach(() => {
cleanup();
resetProviderCliToastState();
window.localStorage.clear();
vi.unstubAllGlobals();
});

describe("ProviderCliHealthToasts", () => {
it("does not persist dismissal when the toast closes without the cancel button", async () => {
renderProviderCliHealthToasts(statusResponseWithCodex(codexMissingStatus()));

const invocation = await waitForVisibleCodexToast();

act(() => {
providerCliToastState.dismiss(invocation.options.id);
});

expect(window.localStorage.getItem(CODEX_DISMISSED_STORAGE_KEY)).toBeNull();
expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(false);
});

it("persists dismissal when the user clicks the cancel button", async () => {
renderProviderCliHealthToasts(statusResponseWithCodex(codexMissingStatus()));

const invocation = await waitForVisibleCodexToast();

act(() => {
clickToastCancel(invocation);
});

expect(window.localStorage.getItem(CODEX_DISMISSED_STORAGE_KEY)).toBe(
"true",
);
expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(false);
});

it("clears resolved issue state so a recurring missing CLI shows again", async () => {
const result = renderProviderCliHealthToasts(
statusResponseWithCodex(codexMissingStatus()),
);
await waitForVisibleCodexToast();
window.localStorage.setItem(CODEX_DISMISSED_STORAGE_KEY, "true");

result.state.status = statusResponseWithCodex(codexInstalledStatus());
await refetchProviderCliStatus(result);

await waitFor(() => {
expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(
false,
);
expect(
window.localStorage.getItem(CODEX_DISMISSED_STORAGE_KEY),
).toBeNull();
});

const toastInvocationCountAfterResolve =
providerCliToastState.invocations.length;
result.state.status = statusResponseWithCodex(codexMissingStatus());
await refetchProviderCliStatus(result);

await waitFor(() => {
expect(providerCliToastState.invocations.length).toBe(
toastInvocationCountAfterResolve + 1,
);
expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(true);
});
});
});
Loading
Loading