mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Plugins can add UI surfaces to the board. > - Organization navigation is still fixed in the host sidebar. > - A distribution needs a supported way to supply its own organization menu. > - This pull request adds one optional React slot with host-owned navigation controls. > - The built-in menu stays available when the optional contribution cannot render. ## Linked Issues or Issue Description **Subsystem affected** Plugin SDK, server capability validation, and sidebar UI. **Problem or motivation** An installed plugin cannot replace the organization switcher without editing the host menu. Existing sidebar and overlay slots do not provide this replacement surface. **Proposed solution** Add an `organizationSwitcher` slot that requires `ui.sidebar.register`. Pass display state, an icon renderer, and navigation/logout callbacks. Keep the built-in menu for absent, ambiguous, missing, failed, or unsupported contributions. **Roadmap alignment** This keeps distribution UI in plugins and adds a small host contract. It does not add an account system or change company authorization. The maintainer requested this extension. Related prior menu changes: #12788, #10917, and #10850. No duplicate replacement-slot PR was found. ## What Changed - Add the slot to shared validation, SDK types, and server capability checks. - Wrap both sidebar menu variants with the optional replacement. - Resolve selection against the current account query before mounting, and reset replacement state on account or company changes. - Add host-specific component props and a fallback to `PluginSlotMount`. - Document the React-only contract and its trust boundary. - Report runtime-supervisor fixture startup details when CI readiness fails. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. - `pnpm check:token-gates` passed. - `pnpm exec vitest run --project @paperclipai/ui`: 6,580 tests passed. - Targeted manifest, replacement, and built-in menu tests: 26 passed, including the incoming-account selection regression. - Installed a local test contribution into an isolated server. CLI inspection reported `ready`. Browser checks covered the loaded production UI, keyboard dismissal, current-organization selection, and an expired remote session. Remote account responses were fixtures. - `pnpm test:run` was attempted. Its first server phase passed 8,323 tests but failed in 36 files due to embedded PostgreSQL startup and filesystem permission errors on this Mac. Later phases did not run. The current Linux CI run is green: 54 checks passed and two were skipped, including build, typecheck, and browser gates. See https://github.com/paperclipai/paperclip/actions/runs/35796722770. - The earlier runtime-supervisor readiness failure did not reproduce locally. The complete affected shard passed locally: 58 files and 843 tests. The six supervisor tests also passed on Node 24.21.0 with CI flags. Added fixture startup diagnostics for the selected Node executable and listener port. The affected Linux shard then passed all 843 tests. The original root cause remains unconfirmed; no production runtime behavior or timeout was changed. ## Risks - Plugin UI remains trusted same-origin code. Display props do not authorize account requests. - A replacement can change navigation behavior. The host retains the built-in menu when discovery or rendering fails and keeps logout/session cleanup host-owned. - No database migration. Existing menus and portfolio behavior remain available. - Local full-suite verification is limited by the environment failures listed above. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, code execution, and browser testing. The runtime does not expose a more specific model ID or context-window size. ## 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 (e.g. `docs/...`, `fix/...`) 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
829 lines
28 KiB
TypeScript
829 lines
28 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";
|
|
import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production";
|
|
|
|
vi.mock("./PluginOrganizationSwitcher", () => ({ PluginOrganizationSwitcher: ({ children }: { children: ReactNode }) => children }));
|
|
|
|
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",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: "company-2",
|
|
issuePrefix: "STR",
|
|
name: "Strata",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: "company-3",
|
|
issuePrefix: "ANA",
|
|
name: "Anachronist Wiki",
|
|
status: "active",
|
|
},
|
|
],
|
|
selectedCompany: {
|
|
id: "company-1",
|
|
issuePrefix: "PAP",
|
|
name: "Acme Labs",
|
|
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 organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Couldn't load organizations");
|
|
expect(document.body.textContent).not.toContain("No organizations");
|
|
|
|
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 organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("No organizations");
|
|
expect(document.body.textContent).not.toContain("Couldn't load organizations");
|
|
|
|
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 organization switcher"]');
|
|
expect(trigger).not.toBeNull();
|
|
expect(trigger?.classList).toContain("px-4");
|
|
expect(trigger?.classList).toContain("has-[>svg]:px-4");
|
|
expect(trigger?.classList).toContain("hover:bg-sidebar-accent");
|
|
expect(trigger?.classList).toContain("hover:text-sidebar-accent-foreground");
|
|
expect(trigger?.classList).toContain("dark:hover:bg-sidebar-accent");
|
|
expect(trigger?.classList).toContain("dark:hover:text-sidebar-accent-foreground");
|
|
expect(trigger?.classList).not.toContain("hover:bg-background");
|
|
expect(trigger?.classList).not.toContain("dark:hover:bg-background");
|
|
expect(trigger?.classList).not.toContain("dark:hover:bg-accent/50");
|
|
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 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 } },
|
|
});
|
|
// The invite shortcut waits for the health response before it shows, so
|
|
// resolve it here the way CloudAccessGate does in the app.
|
|
queryClient.setQueryData(queryKeys.health, { status: "ok" });
|
|
|
|
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 organization 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("Organizations");
|
|
expect(document.body.textContent).toContain("Edit");
|
|
expect(document.body.textContent).toContain("Strata");
|
|
expect(document.body.textContent).toContain("ANA");
|
|
expect(document.body.textContent).toContain("Create 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("Organizations");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows the production-shell invite shortcut when no surface is hidden", async () => {
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
queryClient.setQueryData(queryKeys.health, { status: "ok" });
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenuProduction />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs company switcher");
|
|
|
|
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("hides the production-shell invite shortcut when the operator hides the invites surface", async () => {
|
|
// The production shell (streamlined UI disabled) must honor
|
|
// PAPERCLIP_HIDDEN_SETTINGS like the streamlined menu — this is the knob
|
|
// Paperclip Cloud uses to drop the shortcut on its managed stacks.
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
queryClient.setQueryData(queryKeys.health, { status: "ok", hiddenSettings: ["company.invites"] });
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenuProduction />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs company switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the invite shortcut out of the menu until hidden settings resolve", async () => {
|
|
// No health data in the cache: the hidden-settings set is unknown, so the
|
|
// shortcut must not flash in and then disappear once the response lands.
|
|
const { root } = renderMenu();
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("hides the invite shortcut when the operator hides the invites surface", async () => {
|
|
const { root } = renderMenu({
|
|
health: { status: "ok", hiddenSettings: ["company.invites"] },
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("hides the invite shortcut when the operator hides the members page", async () => {
|
|
const { root } = renderMenu({
|
|
health: { status: "ok", hiddenSettings: ["company.members"] },
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
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 organization 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");
|
|
const reorderButton = document.body.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Reorder Strata"]',
|
|
);
|
|
expect(reorderButton).toBeTruthy();
|
|
expect(reorderButton?.classList).toContain("size-8");
|
|
|
|
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 organization 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 organization switcher");
|
|
|
|
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Create 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 organization 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("Organizations");
|
|
|
|
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("Organizations");
|
|
expect(document.body.textContent).toContain("Create 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?.classList.contains("bg-accent")).toBe(false);
|
|
|
|
// Long slugs must not squeeze the display name out of the row, so the
|
|
// secondary line 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 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 organization");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|
|
});
|