Files
PaperClipAI/ui/src/components/SidebarCompanyMenu.test.tsx
T
TonioandClaude Opus 5 ac91b7f3b2 fix(ui): scope the company selection to the signed-in account (#11430)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Every company-scoped screen reads the active company from
`CompanyProvider`, which picks one from the `["companies"]` list and
remembers it in localStorage
> - That cache entry is shared app-wide and carries no account identity,
so it survives a change of account in the tab
> - The provider therefore auto-selects from whatever list is cached,
which can belong to the account that just went away
> - This pull request makes the provider watch the account and refuse to
derive a selection from a list fetched for a different one
> - The benefit is that the app stops pointing at a company the
signed-in account may not be able to see

## Linked Issues or Issue Description

No public issue exists. Refs #11380, #11382, #11417. The problem
follows.

**What happened?**

`CompanyProvider` auto-selects a company from the shared `["companies"]`
cache entry and writes that id to `localStorage`. Nothing ties that
entry to an account. When the account changes in the tab, the previous
account's list is still served, so the provider can select — and persist
— a company belonging to the account that just went away. Company-scoped
screens then render against a company the current account may not be
able to see.

Signing in through `Auth.tsx` invalidates the entry, so the in-app
sign-in path is covered. Two paths are not: a session that lapses
server-side, and a second account signing in on another tab. The
sign-out sweep in #11380 does not cover them either, because neither
presses the sign-out button.

**Expected behavior**

The company selection is derived only from a company list fetched for
the account that is signed in now.

**Steps to reproduce**

1. On a self-hosted instance in `authenticated` mode, sign in as account
A, which belongs to company X.
2. In a second tab, sign in as account B, which does not belong to
company X.
3. Return to the first tab. The session query refetches and reports
account B, while the company list is still account A's.
4. The provider keeps company X selected and leaves its id in
`localStorage`.

**Paperclip version or commit**

`master` at `6542ad1f4`.

## What Changed

- `ui/src/context/CompanyContext.tsx` — the provider observes
`queryKeys.auth.session`. On a change of session user it clears the live
selection, removes the shared company list, and holds auto-select until
a list fetched for the new account lands. The stored id is left alone on
purpose: `resolveBootstrapCompanySelection` re-validates it, so an
account signing back in keeps its company while an unrelated account
cannot inherit it.
- `ui/src/context/CompanyContext.tsx` — an errored list is treated as
undecided rather than as "no companies". With `retry: false` a single
network blip sticks, and the empty-list branch read it as proof the
account owns nothing and cleared the stored selection.
- `ui/src/api/client.ts` — new `detachInflightGet(path)`. GET coalescing
keys on the request path alone, so a `/companies` request issued under
the previous session could be joined by the replacement fetch and answer
it with the previous account's companies. Detaching leaves that request
to settle for its own callers and makes the next call issue a fresh one.
- `ui/src/api/companies.ts` — `companiesApi.detachInflightList()` wraps
that for the list path.
- `ui/src/context/CompanyContext.tsx` — `companyListUnavailable`
separates "no usable list because a request failed" from "this account
owns nothing", and `retryCompanies` gives a recovery action that
fetches. Both are derived from the query rather than tracked beside it;
a second copy of "did the last attempt succeed" drifted out of step
during review, reporting a failure over a later empty list that was
simply the truth.
- `ui/src/components/SidebarCompanyMenu.tsx` — renders "Couldn't load
companies" and a Try again item in place of "No companies", which is a
claim about the account that a failed request cannot support. This is
the menu `Sidebar` mounts, so it is the only place a customer can act on
the failure.
- `ui/src/components/CompanySwitcher.tsx` — the same treatment. The
application does not render this component (its only mount is a
Storybook story), so it is kept in step rather than relied on.
- `ui/src/context/CompanyContext.test.tsx`,
`ui/src/components/SidebarCompanyMenu.test.tsx`,
`ui/src/api/client.test.ts` — coverage for the account switch, a
same-account re-observation not churning, the detached GET, the failed
replacement and its recovery, a single blip self-healing, unavailability
not outliving the failure, and the sidebar rendering the recovery action
for a failure but plain "No companies" for an account that owns nothing.

### No `retry` override on the replacement fetch

The obvious fix for a failed replacement is a retry, and it is not
load-bearing here. A transient failure already gets a second attempt:
the observer rebinds to a fresh query on the render those state updates
schedule, and issues its own request — measured as two attempts with or
without the option. Retries would only add failed round trips before a
real outage is reported, and the outage is what needs a way out, which
is what `companyListUnavailable` and `retryCompanies` provide.

### Why `removeQueries` here, and why that does not generalise

Removal notifies no observer. What rebinds them at this call site is the
render the surrounding state updates schedule; every observer re-binds
to a fresh query on the next render. A caller without that guarantee
would leave mounted observers serving the previous account's value, so
this is not a pattern to lift elsewhere — the sign-out sweep in #11380
must use `resetQueries` instead, and its measurements are at
[#11380](https://github.com/paperclipai/paperclip/pull/11380#issuecomment-5300984911).

The inverse caveat holds for a local reset under an observer that stays
mounted, which is why #11417 and #11382 avoid `resetQueries`.

## Verification

- `pnpm vitest run` in `ui`: **4014 passed, 1 failed**.
- `pnpm tsc -b` in `ui`: clean.
- `CompanyContext.test.tsx`: 16 passed. `SidebarCompanyMenu.test.tsx`:
15 passed. `client.test.ts`: 9 passed.

The failure is pre-existing and unrelated: `IssueProperties.test.tsx`
expects `4:08 PM` and gets `9:08 AM`, a timezone-dependent assertion. It
reproduces on a tree without this change, and #11478 fixes it.

Each new test was confirmed to fail against the implementation it
covers, by reverting that change and re-running rather than by assuming.
The account-switch test fails without the fix (the selection stays on
the previous account's company and no refetch is issued); the
flag-clearing test fails without its clause (an empty list keeps reading
as "couldn't load").

**Not done:** no manual two-account run in a browser. The path needs two
accounts on an `authenticated` instance, which a local dev instance
cannot exercise.

## Risks

Low. The failure direction is a company selection withheld for one extra
round trip, which resolves when the list arrives. The direction it
removes is one account's company selected and persisted for another.

It adds one company-list request per account change, on a query key the
app already uses. It adds no request at boot: the session query it
observes is already fetched app-wide.

**This does not close the class.** Company-scoped entries other than the
list — `["companies", id]`, stats, and the rest of the per-account cache
— still survive an account change. That is the cache-lifetime work in
#11380, not this provider's.

## Model Used

Claude Opus 5 (`claude-opus-5`), through Claude Code. Extended thinking
enabled. Tool use enabled: file read and edit, shell for typecheck and
test runs, and a scratch vitest harness to measure `removeQueries` and
`resetQueries` notification behaviour against the installed
`@tanstack/query-core` 5.101.4.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:51:03 -07:00

713 lines
24 KiB
TypeScript

// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
signInEmail: vi.fn(),
signUpEmail: vi.fn(),
getProfile: vi.fn(),
updateProfile: vi.fn(),
signOut: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockOpenOnboarding = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockLocation = vi.hoisted(() => ({ pathname: "/PAP/dashboard" }));
const mockSidebarPreferencesApi = vi.hoisted(() => ({
getCompanyOrder: vi.fn(),
updateCompanyOrder: vi.fn(),
}));
const mockCloudApi = vi.hoisted(() => ({
listStacks: vi.fn(),
}));
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("@/api/cloud", () => ({
cloudApi: mockCloudApi,
}));
vi.mock("@/lib/browserNavigation", () => ({
navigateTopLevel: mockNavigateTopLevel,
}));
vi.mock("@/api/sidebarPreferences", () => ({
sidebarPreferencesApi: mockSidebarPreferencesApi,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
useLocation: () => mockLocation,
useNavigate: () => mockNavigate,
}));
// Overridable so the list-unavailable branch can be exercised; null means "use
// the default three companies below".
const mockCompanyState = vi.hoisted(() => ({
companies: null as unknown[] | null,
companyListUnavailable: false,
retryCompanies: vi.fn(),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
companyListUnavailable: mockCompanyState.companyListUnavailable,
retryCompanies: mockCompanyState.retryCompanies,
companies: mockCompanyState.companies ?? [
{
id: "company-1",
issuePrefix: "PAP",
name: "Acme Labs",
brandColor: "#3366ff",
status: "active",
},
{
id: "company-2",
issuePrefix: "STR",
name: "Strata",
brandColor: "#36a269",
status: "active",
},
{
id: "company-3",
issuePrefix: "ANA",
name: "Anachronist Wiki",
brandColor: "#a36a21",
status: "active",
},
],
selectedCompany: {
id: "company-1",
issuePrefix: "PAP",
name: "Acme Labs",
brandColor: "#3366ff",
logoUrl: "/api/assets/logo-asset-1/content",
status: "active",
},
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
vi.mock("@/context/DialogContext", () => ({
useDialogActions: () => ({
openOnboarding: mockOpenOnboarding,
}),
}));
vi.mock("./CompanyPatternIcon", () => ({
CompanyPatternIcon: ({ companyName, logoUrl }: { companyName: string; logoUrl?: string | null }) => (
<span aria-hidden="true" data-logo-url={logoUrl ?? undefined}>{companyName.slice(0, 1)}</span>
),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: false,
setSidebarOpen: mockSetSidebarOpen,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(callback);
}
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
const CLOUD_HEALTH = {
status: "ok" as const,
cloud: {
managed: true as const,
managedBy: "paperclip-cloud" as const,
stackSlug: "acme-labs",
cloudBaseUrl: "https://cloud.example.test",
},
};
const CLOUD_STACKS = {
stacks: [
{
displayName: "Acme Labs",
stackSlug: "acme-labs",
primaryHost: "acme-labs.example.test",
lifecycleState: "active",
sleepState: "awake",
role: "owner",
isCurrent: true,
},
{
displayName: "Strata Systems",
stackSlug: "strata",
primaryHost: "strata.example.test",
lifecycleState: "active",
sleepState: "asleep",
role: "member",
isCurrent: false,
},
],
};
describe("SidebarCompanyMenu", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
},
});
mockAuthApi.signOut.mockResolvedValue(undefined);
mockCloudApi.listStacks.mockResolvedValue(CLOUD_STACKS);
mockSidebarPreferencesApi.getCompanyOrder.mockResolvedValue({
orderedIds: ["company-1", "company-2", "company-3"],
updatedAt: null,
});
mockSidebarPreferencesApi.updateCompanyOrder.mockResolvedValue({
orderedIds: ["company-1", "company-2", "company-3"],
updatedAt: null,
});
mockLocation.pathname = "/PAP/dashboard";
mockCompanyState.companies = null;
mockCompanyState.companyListUnavailable = false;
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
function renderMenu(options: { cloud?: boolean; health?: unknown } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// CloudAccessGate owns the health fetch in the app; seeding the cache is how
// useCloudInstance sees a cloud-managed instance under test.
if (options.cloud || options.health) {
queryClient.setQueryData(queryKeys.health, options.health ?? CLOUD_HEALTH);
}
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
async function openMenu(ariaLabel: string) {
const trigger = container.querySelector(`button[aria-label="${ariaLabel}"]`);
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
// This menu is the one the app renders, so it is the only place a customer can
// act on a failed company list. Saying "No companies" there states something
// about the account that a failed request cannot support, and leaves the tab
// with no way back short of a browser reload.
it("offers a way back when the company list could not be loaded", async () => {
mockCompanyState.companies = [];
mockCompanyState.companyListUnavailable = true;
const { root } = renderMenu();
await flushReact();
await openMenu("Open Acme Labs company switcher");
expect(document.body.textContent).toContain("Couldn't load companies");
expect(document.body.textContent).not.toContain("No companies");
const retryItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(item) => item.textContent?.includes("Try again"),
);
expect(retryItem).not.toBeUndefined();
act(() => {
retryItem?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
retryItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockCompanyState.retryCompanies).toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("still reports an account that owns no companies as empty, not broken", async () => {
mockCompanyState.companies = [];
mockCompanyState.companyListUnavailable = false;
const { root } = renderMenu();
await flushReact();
await openMenu("Open Acme Labs company switcher");
expect(document.body.textContent).toContain("No companies");
expect(document.body.textContent).not.toContain("Couldn't load companies");
act(() => {
root.unmount();
});
});
it("uses company-centric create copy without the chat flag", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const trigger = container.querySelector('button[aria-label="Open Acme Labs company switcher"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).not.toContain("Add company...");
act(() => {
root.unmount();
});
});
it("shows the requested company actions and signs out through the dropdown", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Acme Labs");
const trigger = container.querySelector('button[aria-label="Open Acme Labs company switcher"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Switch company");
expect(document.body.textContent).toContain("Edit");
expect(document.body.textContent).toContain("Strata");
expect(document.body.textContent).toContain("ANA");
expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
expect(document.body.textContent).not.toContain("Company settings");
expect(document.body.textContent).toContain("Sign out");
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Sign out"));
expect(signOutButton).toBeTruthy();
act(() => {
signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
expect(document.body.textContent).not.toContain("Switch company");
act(() => {
root.unmount();
});
});
it("toggles company order editing without selecting a company", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const trigger = container.querySelector('button[aria-label="Open Acme Labs company switcher"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const editButton = Array.from(document.body.querySelectorAll("button"))
.find((element) => element.textContent === "Edit");
expect(editButton).toBeTruthy();
act(() => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Done");
expect(document.body.textContent).not.toContain("PAP");
expect(document.body.textContent).not.toContain("ANA");
expect(document.body.querySelector('button[aria-label="Reorder Strata"]')).toBeTruthy();
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Strata"));
expect(strataItem).toBeTruthy();
act(() => {
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("navigates to the selected company dashboard from company-prefixed routes", async () => {
mockLocation.pathname = "/PAP/issues";
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const trigger = container.querySelector('button[aria-label="Open Acme Labs company switcher"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Strata"));
expect(strataItem).toBeTruthy();
act(() => {
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
expect(mockNavigate).toHaveBeenCalledWith("/STR/dashboard");
act(() => {
root.unmount();
});
});
it("keeps the in-app company wizard and never leaves the app when self-hosted", async () => {
const { root } = renderMenu();
await flushReact();
await flushReact();
await openMenu("Open Acme Labs company switcher");
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Create new organization..."));
expect(createItem).toBeTruthy();
act(() => {
createItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockOpenOnboarding).toHaveBeenCalledTimes(1);
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
expect(mockCloudApi.listStacks).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
// The trigger sits in a fixed 240px sidebar header beside two shrink-0
// controls, so a long name may only truncate — never widen the row and push
// the chevron, search, and collapse controls out of the panel. Truncation
// needs `min-w-0` on EVERY flex link of the chain (a flex item's default
// `min-width:auto` floors it at its content width), which is invisible to
// behavioural assertions, so the class chain itself is the contract.
it("lets a long workspace name truncate instead of widening the trigger", async () => {
const { root } = renderMenu();
await flushReact();
const trigger = container.querySelector('button[aria-label="Open Acme Labs company switcher"]');
expect(trigger).not.toBeNull();
expect(trigger?.className).toContain("min-w-0");
const labelRow = trigger?.firstElementChild;
expect(labelRow?.className).toContain("min-w-0");
const label = labelRow?.lastElementChild;
expect(label?.textContent).toBe("Acme Labs");
expect(label?.className).toContain("min-w-0");
expect(label?.className).toContain("truncate");
// A truncated name stays recoverable on hover.
expect(label?.getAttribute("title")).toBe("Acme Labs");
act(() => {
root.unmount();
});
});
describe("in Paperclip Cloud", () => {
it("closes the menu and enters the Cloud logout flow without local sign-out", async () => {
const { root } = renderMenu({ cloud: true });
await flushReact();
await flushReact();
await openMenu("Open Acme Labs organization switcher");
const signOutItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Sign out"));
expect(signOutItem).toBeTruthy();
act(() => {
signOutItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
expect(mockNavigateTopLevel).toHaveBeenCalledOnce();
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
expect(document.body.textContent).not.toContain("Switch organization");
act(() => {
root.unmount();
});
});
it("switches organizations instead of companies", async () => {
const { root } = renderMenu({ cloud: true });
await flushReact();
await flushReact();
expect(mockCloudApi.listStacks).toHaveBeenCalledTimes(1);
await openMenu("Open Acme Labs organization switcher");
expect(document.body.textContent).toContain("Switch organization");
expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).not.toContain("Organization settings");
expect(document.body.textContent).not.toContain("Switch company");
expect(document.body.textContent).not.toContain("Create new company...");
expect(document.body.textContent).not.toContain("Company settings");
// Stacks, not companies: both rows carry the slug badge, and the current
// stack is the checked one.
expect(document.body.textContent).toContain("Acme Labs");
expect(document.body.textContent).toContain("acme-labs");
expect(document.body.textContent).toContain("Strata Systems");
expect(document.body.textContent).toContain("strata");
expect(document.body.textContent).not.toContain("Anachronist Wiki");
expect(document.body.textContent).not.toContain("ANA");
const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Acme Labs"));
expect(currentRow?.className).toContain("bg-accent");
// Long slugs must not squeeze the display name out of the row, so the
// badge truncates and keeps the full slug on hover.
const slugBadge = document.body.querySelector('[title="strata"]');
expect(slugBadge?.className).toContain("truncate");
// Drag-to-reorder is self-hosted only in v1.
expect(Array.from(document.body.querySelectorAll("button"))
.some((element) => element.textContent === "Edit")).toBe(false);
act(() => {
root.unmount();
});
});
it("shows the synced company logo on the trigger while stack rows keep monograms", async () => {
const { root } = renderMenu({ cloud: true });
await flushReact();
await flushReact();
// A Cloud tenant holds exactly one company, and the harness pushes the
// stack's uploaded workspace icon into that company's branding — so the
// trigger renders the company logo, not the monogram.
const trigger = container.querySelector(
'button[aria-label="Open Acme Labs organization switcher"]',
);
expect(trigger).not.toBeNull();
expect(
trigger?.querySelector('[data-logo-url="/api/assets/logo-asset-1/content"]'),
).not.toBeNull();
// Other stacks have no hot-linkable icon in the portfolio payload, so
// their rows keep the deterministic monogram treatment.
await openMenu("Open Acme Labs organization switcher");
const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Strata Systems"));
expect(strataRow).toBeTruthy();
expect(strataRow?.querySelector("[data-logo-url]")).toBeNull();
act(() => {
root.unmount();
});
});
it("enters another stack through a full top-level navigation", async () => {
const { root } = renderMenu({ cloud: true });
await flushReact();
await flushReact();
await openMenu("Open Acme Labs organization switcher");
const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Strata Systems"));
expect(strataRow).toBeTruthy();
act(() => {
strataRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockNavigateTopLevel).toHaveBeenCalledWith(
"https://cloud.example.test/stacks/strata/enter",
);
// No in-app company selection and no client-side route change.
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("does not navigate when the current stack is picked again", async () => {
const { root } = renderMenu({ cloud: true });
await flushReact();
await flushReact();
await openMenu("Open Acme Labs organization switcher");
const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Acme Labs"));
act(() => {
currentRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("sends organization creation to the cloud app, not the in-app wizard", async () => {
const { root } = renderMenu({ cloud: true });
await flushReact();
await flushReact();
await openMenu("Open Acme Labs organization switcher");
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Create new organization..."));
expect(createItem).toBeTruthy();
act(() => {
createItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockNavigateTopLevel).toHaveBeenCalledWith("https://cloud.example.test/stacks/new");
expect(mockOpenOnboarding).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("falls back to the stack slug and hides creation without a cloud base url", async () => {
mockCloudApi.listStacks.mockRejectedValue(new Error("portfolio unavailable"));
const { root } = renderMenu({
cloud: true,
health: {
status: "ok" as const,
cloud: { ...CLOUD_HEALTH.cloud, cloudBaseUrl: null },
},
});
await flushReact();
await flushReact();
await openMenu("Open acme-labs organization switcher");
expect(document.body.textContent).toContain("Could not load organizations");
expect(document.body.textContent).not.toContain("Create new organization...");
act(() => {
root.unmount();
});
});
});
});