Add full Composio managed-auth toolkit catalog (#1093)

This commit is contained in:
Steven Enamakel
2026-05-02 12:58:27 -07:00
committed by GitHub
parent 5523434516
commit 3c292b2347
6 changed files with 389 additions and 253 deletions
@@ -318,6 +318,9 @@ export default function ComposioConnectModal({
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-primary-500/15 text-primary-600">
composio
</span>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-sky-500/10 text-sky-700">
managed auth
</span>
</div>
<p className="text-xs text-stone-400 mt-1.5 line-clamp-2">{toolkit.description}</p>
</div>
@@ -343,9 +346,21 @@ export default function ComposioConnectModal({
{phase === 'idle' && (
<>
<p className="text-sm text-stone-600">
Connect your {toolkit.name} account. We will open a browser window where you can
grant access, and then this app will detect the connection automatically.
Connect your {toolkit.name} account through Composio&apos;s hosted authorization
flow. We&apos;ll open a browser window, you approve access there, and this app will
detect the connection automatically.
</p>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-stone-500">
Provider access
</p>
<p className="mt-1 text-xs leading-relaxed text-stone-600">
{toolkit.name} can expose{' '}
<span className="font-medium">{toolkit.permissionLabel}</span>. After you connect,
OpenHuman&apos;s own agent permissions are controlled below as read, write, and
admin toggles.
</p>
</div>
<button
type="button"
onClick={() => void handleConnect()}
@@ -478,9 +493,9 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
<div className="border-t border-stone-100 pt-3 mt-1 space-y-2">
<div className="flex items-baseline justify-between">
<h3 className="text-xs font-semibold text-stone-700 uppercase tracking-wide">
Agent permissions
OpenHuman agent permissions
</h3>
<p className="text-[10px] text-stone-400">Read+Write enabled by default</p>
<p className="text-[10px] text-stone-400">Read + Write enabled by default</p>
</div>
<ul className="space-y-1.5">
{SCOPE_ROWS.map(row => {
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { composioToolkitMeta, KNOWN_COMPOSIO_TOOLKITS } from './toolkitMeta';
describe('composioToolkitMeta', () => {
it('ships the full Composio managed-auth catalog fallback', () => {
expect(KNOWN_COMPOSIO_TOOLKITS).toHaveLength(118);
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('gmail');
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('discord');
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('supabase');
expect(KNOWN_COMPOSIO_TOOLKITS).toContain('zoom');
});
it('preserves canonical names for managed-auth toolkits and renders logo URLs', () => {
const gmail = composioToolkitMeta('gmail');
const calendar = composioToolkitMeta('google_calendar');
expect(gmail.name).toBe('Gmail');
expect(gmail.logoUrl).toContain('/gmail');
expect(gmail.permissionLabel).toBe('Docs, files, tasks, and workspace data');
expect(calendar.slug).toBe('googlecalendar');
expect(calendar.name).toBe('Google Calendar');
expect(calendar.logoUrl).toContain('/googlecalendar');
});
it('falls back cleanly for unknown slugs', () => {
const meta = composioToolkitMeta('my_custom_toolkit');
expect(meta.slug).toBe('my_custom_toolkit');
expect(meta.name).toBe('My Custom Toolkit');
expect(meta.logoUrl).toContain('/my_custom_toolkit');
});
});
+283 -247
View File
@@ -1,33 +1,20 @@
/**
* Display metadata for Composio toolkits shown in the Skills grid.
*
* The backend allowlist (`GET /agent-integrations/composio/toolkits`)
* returns plain toolkit slugs. This table turns each slug into a
* humanised name, description, category, and icon so the
* `UnifiedSkillCard` can render them next to regular skills without
* special-casing.
* We intentionally keep a local catalog of every Composio managed-auth
* toolkit so the desktop UI can render a broad connection surface even
* before the live backend allowlist expands further. The live toolkit
* list still wins for runtime availability; this file provides stable
* names, categories, descriptions, and logos for rendering.
*
* Unknown slugs fall back to a generic entry with a title-cased name —
* new backend toolkits will still render, just without custom copy.
* Source of truth for the managed-auth list:
* https://docs.composio.dev/toolkits/managed-auth (118 toolkits as of
* May 1, 2026).
*/
import type { ReactNode } from 'react';
import {
SiFacebook,
SiGithub,
SiGmail,
SiGooglecalendar,
SiGoogledrive,
SiGooglesheets,
SiInstagram,
SiLinear,
SiNotion,
SiReddit,
SiSlack,
} from 'react-icons/si';
import { type ReactNode, useState } from 'react';
import { canonicalizeComposioToolkitSlug } from '../../lib/composio/toolkitSlug';
import type { SkillCategory } from '../skills/skillCategories';
import { SkillIconBadge } from '../skills/skillIcons';
export interface ComposioToolkitMeta {
/** Toolkit slug as returned by the backend, e.g. `"gmail"`. */
@@ -40,128 +27,203 @@ export interface ComposioToolkitMeta {
category: SkillCategory;
/** Small branded icon rendered on the card and connect modal. */
icon: ReactNode;
/** Composio-hosted logo URL for richer provider branding. */
logoUrl: string;
/** Short UX hint for what the user is authorizing. */
permissionLabel: string;
}
function GmailIcon() {
return (
<SkillIconBadge
icon={SiGmail}
label="Gmail"
bgClassName="bg-white"
iconClassName="text-[#EA4335]"
/>
);
interface ManagedToolkitEntry {
slug: string;
name: string;
}
function GoogleCalendarIcon() {
return (
<SkillIconBadge
icon={SiGooglecalendar}
label="Google Calendar"
bgClassName="bg-[#E8F0FE]"
iconClassName="text-[#4285F4]"
/>
);
}
const MANAGED_COMPOSIO_TOOLKITS: readonly ManagedToolkitEntry[] = Object.freeze([
{ slug: 'airtable', name: 'Airtable' },
{ slug: 'apaleo', name: 'Apaleo' },
{ slug: 'asana', name: 'Asana' },
{ slug: 'attio', name: 'Attio' },
{ slug: 'basecamp', name: 'Basecamp' },
{ slug: 'bitbucket', name: 'Bitbucket' },
{ slug: 'blackbaud', name: 'Blackbaud' },
{ slug: 'boldsign', name: 'Boldsign' },
{ slug: 'box', name: 'Box' },
{ slug: 'cal', name: 'Cal' },
{ slug: 'calendly', name: 'Calendly' },
{ slug: 'canva', name: 'Canva' },
{ slug: 'capsule_crm', name: 'Capsule CRM' },
{ slug: 'clickup', name: 'ClickUp' },
{ slug: 'confluence', name: 'Confluence' },
{ slug: 'contentful', name: 'Contentful' },
{ slug: 'convex', name: 'Convex' },
{ slug: 'crowdin', name: 'Crowdin' },
{ slug: 'dart', name: 'Dart' },
{ slug: 'dialpad', name: 'Dialpad' },
{ slug: 'digital_ocean', name: 'DigitalOcean' },
{ slug: 'discord', name: 'Discord' },
{ slug: 'discordbot', name: 'Discord Bot' },
{ slug: 'dropbox', name: 'Dropbox' },
{ slug: 'dub', name: 'Dub' },
{ slug: 'dynamics365', name: 'Dynamics 365' },
{ slug: 'eventbrite', name: 'Eventbrite' },
{ slug: 'excel', name: 'Excel' },
{ slug: 'exist', name: 'Exist' },
{ slug: 'facebook', name: 'Facebook' },
{ slug: 'fathom', name: 'Fathom' },
{ slug: 'figma', name: 'Figma' },
{ slug: 'freeagent', name: 'Freeagent' },
{ slug: 'freshbooks', name: 'FreshBooks' },
{ slug: 'github', name: 'GitHub' },
{ slug: 'gitlab', name: 'GitLab' },
{ slug: 'gmail', name: 'Gmail' },
{ slug: 'googleads', name: 'Google Ads' },
{ slug: 'google_analytics', name: 'Google Analytics' },
{ slug: 'googlebigquery', name: 'Google BigQuery' },
{ slug: 'googlecalendar', name: 'Google Calendar' },
{ slug: 'google_classroom', name: 'Google Classroom' },
{ slug: 'googledocs', name: 'Google Docs' },
{ slug: 'googledrive', name: 'Google Drive' },
{ slug: 'google_maps', name: 'Google Maps' },
{ slug: 'googlemeet', name: 'Google Meet' },
{ slug: 'googlephotos', name: 'Google Photos' },
{ slug: 'google_search_console', name: 'Google Search Console' },
{ slug: 'googlesheets', name: 'Google Sheets' },
{ slug: 'googleslides', name: 'Google Slides' },
{ slug: 'googlesuper', name: 'Google Super' },
{ slug: 'googletasks', name: 'Google Tasks' },
{ slug: 'gorgias', name: 'Gorgias' },
{ slug: 'gumroad', name: 'Gumroad' },
{ slug: 'harvest', name: 'Harvest' },
{ slug: 'hubspot', name: 'HubSpot' },
{ slug: 'hugging_face', name: 'Hugging Face' },
{ slug: 'instagram', name: 'Instagram' },
{ slug: 'intercom', name: 'Intercom' },
{ slug: 'jira', name: 'Jira' },
{ slug: 'kit', name: 'Kit' },
{ slug: 'linear', name: 'Linear' },
{ slug: 'linkedin', name: 'LinkedIn' },
{ slug: 'linkhut', name: 'Linkhut' },
{ slug: 'mailchimp', name: 'Mailchimp' },
{ slug: 'microsoft_teams', name: 'Microsoft Teams' },
{ slug: 'miro', name: 'Miro' },
{ slug: 'monday', name: 'Monday' },
{ slug: 'moneybird', name: 'Moneybird' },
{ slug: 'mural', name: 'Mural' },
{ slug: 'notion', name: 'Notion' },
{ slug: 'omnisend', name: 'Omnisend' },
{ slug: 'one_drive', name: 'OneDrive' },
{ slug: 'outlook', name: 'Outlook' },
{ slug: 'pagerduty', name: 'PagerDuty' },
{ slug: 'prisma', name: 'Prisma' },
{ slug: 'productboard', name: 'Productboard' },
{ slug: 'pushbullet', name: 'Pushbullet' },
{ slug: 'quickbooks', name: 'QuickBooks' },
{ slug: 'reddit', name: 'Reddit' },
{ slug: 'reddit_ads', name: 'Reddit Ads' },
{ slug: 'roam', name: 'Roam' },
{ slug: 'salesforce', name: 'Salesforce' },
{ slug: 'sentry', name: 'Sentry' },
{ slug: 'servicem8', name: 'Servicem8' },
{ slug: 'share_point', name: 'SharePoint' },
{ slug: 'shippo', name: 'Shippo' },
{ slug: 'slack', name: 'Slack' },
{ slug: 'slackbot', name: 'Slackbot' },
{ slug: 'splitwise', name: 'Splitwise' },
{ slug: 'square', name: 'Square' },
{ slug: 'stack_exchange', name: 'Stack Exchange' },
{ slug: 'strava', name: 'Strava' },
{ slug: 'stripe', name: 'Stripe' },
{ slug: 'supabase', name: 'Supabase' },
{ slug: 'ticketmaster', name: 'Ticketmaster' },
{ slug: 'ticktick', name: 'Ticktick' },
{ slug: 'timely', name: 'Timely' },
{ slug: 'todoist', name: 'Todoist' },
{ slug: 'toneden', name: 'Toneden' },
{ slug: 'trello', name: 'Trello' },
{ slug: 'typeform', name: 'Typeform' },
{ slug: 'wakatime', name: 'WakaTime' },
{ slug: 'webex', name: 'Webex' },
{ slug: 'whatsapp', name: 'WhatsApp' },
{ slug: 'wrike', name: 'Wrike' },
{ slug: 'yandex', name: 'Yandex' },
{ slug: 'ynab', name: 'YNAB' },
{ slug: 'youtube', name: 'YouTube' },
{ slug: 'zendesk', name: 'Zendesk' },
{ slug: 'zoho', name: 'Zoho' },
{ slug: 'zoho_bigin', name: 'Zoho Bigin' },
{ slug: 'zoho_books', name: 'Zoho Books' },
{ slug: 'zoho_desk', name: 'Zoho Desk' },
{ slug: 'zoho_inventory', name: 'Zoho Inventory' },
{ slug: 'zoho_invoice', name: 'Zoho Invoice' },
{ slug: 'zoho_mail', name: 'Zoho Mail' },
{ slug: 'zoom', name: 'Zoom' },
]);
function GoogleDriveIcon() {
return (
<SkillIconBadge
icon={SiGoogledrive}
label="Google Drive"
bgClassName="bg-white"
iconClassName="text-[#0F9D58]"
/>
);
}
const MANAGED_TOOLKIT_NAME_BY_SLUG = new Map(
MANAGED_COMPOSIO_TOOLKITS.map(entry => [entry.slug, entry.name])
);
function NotionIcon() {
return (
<SkillIconBadge
icon={SiNotion}
label="Notion"
bgClassName="bg-white"
iconClassName="text-[#111111]"
/>
);
}
function GitHubIcon() {
return (
<SkillIconBadge
icon={SiGithub}
label="GitHub"
bgClassName="bg-[#111827]"
iconClassName="text-white"
/>
);
}
function SlackIcon() {
return (
<SkillIconBadge
icon={SiSlack}
label="Slack"
bgClassName="bg-white"
iconClassName="text-[#4A154B]"
/>
);
}
function LinearIcon() {
return (
<SkillIconBadge
icon={SiLinear}
label="Linear"
bgClassName="bg-[#0F172A]"
iconClassName="text-white"
/>
);
}
function FacebookIcon() {
return (
<SkillIconBadge
icon={SiFacebook}
label="Facebook"
bgClassName="bg-[#1877F2]"
iconClassName="text-white"
/>
);
}
function GoogleSheetsIcon() {
return (
<SkillIconBadge
icon={SiGooglesheets}
label="Google Sheets"
bgClassName="bg-[#E6F4EA]"
iconClassName="text-[#0F9D58]"
/>
);
}
function InstagramIcon() {
return (
<SkillIconBadge
icon={SiInstagram}
label="Instagram"
bgClassName="bg-[radial-gradient(circle_at_30%_107%,_#fdf497_0%,_#fdf497_5%,_#fd5949_45%,_#d6249f_60%,_#285AEB_90%)]"
iconClassName="text-white"
/>
);
}
function RedditIcon() {
return (
<SkillIconBadge
icon={SiReddit}
label="Reddit"
bgClassName="bg-[#FF4500]"
iconClassName="text-white"
/>
);
}
const CHAT_KEYWORDS = ['discord', 'slack', 'teams', 'webex', 'whatsapp', 'dialpad'];
const SOCIAL_KEYWORDS = [
'facebook',
'instagram',
'linkedin',
'reddit',
'youtube',
'stack_exchange',
];
const PRODUCTIVITY_KEYWORDS = [
'gmail',
'calendar',
'drive',
'docs',
'doc',
'sheets',
'slides',
'tasks',
'todoist',
'trello',
'notion',
'box',
'dropbox',
'sharepoint',
'one_drive',
'onedrive',
'outlook',
'miro',
'mural',
'monday',
'clickup',
'linear',
'jira',
'confluence',
'asana',
'basecamp',
'wrike',
'cal',
'calendly',
'typeform',
'excel',
'figma',
'google',
];
const PLATFORM_KEYWORDS = [
'github',
'gitlab',
'bitbucket',
'digital_ocean',
'contentful',
'supabase',
'convex',
'prisma',
'sentry',
'stripe',
'salesforce',
'hubspot',
'quickbooks',
'zendesk',
'zoho',
];
function GenericIntegrationIcon() {
return (
@@ -179,124 +241,98 @@ function GenericIntegrationIcon() {
);
}
const CATALOG: Record<string, Omit<ComposioToolkitMeta, 'slug'>> = {
gmail: {
name: 'Gmail',
description: 'Read, search, and send email through your Google account.',
category: 'Productivity',
icon: <GmailIcon />,
},
googlecalendar: {
name: 'Google Calendar',
description: 'List and manage events across your Google calendars.',
category: 'Productivity',
icon: <GoogleCalendarIcon />,
},
google_calendar: {
name: 'Google Calendar',
description: 'List and manage events across your Google calendars.',
category: 'Productivity',
icon: <GoogleCalendarIcon />,
},
googledrive: {
name: 'Google Drive',
description: 'Browse and fetch files from your Google Drive.',
category: 'Productivity',
icon: <GoogleDriveIcon />,
},
google_drive: {
name: 'Google Drive',
description: 'Browse and fetch files from your Google Drive.',
category: 'Productivity',
icon: <GoogleDriveIcon />,
},
notion: {
name: 'Notion',
description: 'Read and edit pages, databases, and comments in Notion.',
category: 'Productivity',
icon: <NotionIcon />,
},
github: {
name: 'GitHub',
description: 'Inspect repos, issues, and pull requests on GitHub.',
category: 'Tools & Automation',
icon: <GitHubIcon />,
},
slack: {
name: 'Slack',
description: 'Send messages and read channel history in Slack.',
category: 'Social',
icon: <SlackIcon />,
},
linear: {
name: 'Linear',
description: 'Triage and update issues in your Linear workspace.',
category: 'Tools & Automation',
icon: <LinearIcon />,
},
facebook: {
name: 'Facebook',
description: 'Create posts, manage pages, and work with Facebook social data.',
category: 'Social',
icon: <FacebookIcon />,
},
google_sheets: {
name: 'Google Sheets',
description: 'Read, update, and organize spreadsheets in Google Sheets.',
category: 'Productivity',
icon: <GoogleSheetsIcon />,
},
googlesheets: {
name: 'Google Sheets',
description: 'Read, update, and organize spreadsheets in Google Sheets.',
category: 'Productivity',
icon: <GoogleSheetsIcon />,
},
instagram: {
name: 'Instagram',
description: 'Manage Instagram publishing, messaging, and social content workflows.',
category: 'Social',
icon: <InstagramIcon />,
},
reddit: {
name: 'Reddit',
description: 'Read posts, monitor communities, and participate in Reddit discussions.',
category: 'Social',
icon: <RedditIcon />,
},
};
function ComposioLogoBadge({ slug, name }: { slug: string; name: string }) {
const [failed, setFailed] = useState(false);
const logoUrl = composioLogoUrl(slug);
if (failed) {
return <GenericIntegrationIcon />;
}
return (
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
<img
src={logoUrl}
alt={`${name} logo`}
className="h-full w-full object-contain p-1"
loading="lazy"
onError={() => setFailed(true)}
/>
</span>
);
}
function composioLogoUrl(slug: string): string {
return `https://logos.composio.dev/api/${slug}`;
}
function guessCategory(slug: string, name: string): SkillCategory {
const key = `${slug} ${name}`.toLowerCase();
if (CHAT_KEYWORDS.some(keyword => key.includes(keyword))) return 'Chat';
if (SOCIAL_KEYWORDS.some(keyword => key.includes(keyword))) return 'Social';
if (PRODUCTIVITY_KEYWORDS.some(keyword => key.includes(keyword))) return 'Productivity';
if (PLATFORM_KEYWORDS.some(keyword => key.includes(keyword))) return 'Platform';
return 'Tools & Automation';
}
function defaultDescription(name: string, category: SkillCategory): string {
switch (category) {
case 'Chat':
return `Connect ${name} for messaging, inbox, and team communication workflows.`;
case 'Social':
return `Connect ${name} for social publishing, community, and audience workflows.`;
case 'Productivity':
return `Connect ${name} for documents, planning, file, and day-to-day productivity workflows.`;
case 'Platform':
return `Connect ${name} for developer, platform, CRM, and business system workflows.`;
default:
return `Connect ${name} through Composio managed auth.`;
}
}
function permissionLabelFor(category: SkillCategory): string {
switch (category) {
case 'Chat':
return 'Messages, channels, and communication data';
case 'Social':
return 'Posts, profiles, and social content';
case 'Productivity':
return 'Docs, files, tasks, and workspace data';
case 'Platform':
return 'Repos, records, tickets, and system data';
default:
return 'Connected account data';
}
}
function prettifyUnknownSlug(slug: string): string {
return slug
.split(/[_-]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
/**
* Canonical toolkit slugs used as the default catalog when the backend
* allowlist hasn't loaded yet. One entry per integration — CATALOG
* handles alternate slug variants (e.g. `google_calendar` →
* `googlecalendar`) so they don't need to appear here.
* allowlist hasn't loaded yet. One entry per Composio managed-auth
* integration.
*/
export const KNOWN_COMPOSIO_TOOLKITS = Object.freeze([
'gmail',
'googlecalendar',
'googledrive',
'googlesheets',
'notion',
'github',
'slack',
'linear',
'facebook',
'instagram',
'reddit',
]);
export const KNOWN_COMPOSIO_TOOLKITS = Object.freeze(
MANAGED_COMPOSIO_TOOLKITS.map(entry => entry.slug)
);
export function composioToolkitMeta(slug: string): ComposioToolkitMeta {
const key = canonicalizeComposioToolkitSlug(slug);
const hit = CATALOG[key];
if (hit) return { slug: key, ...hit };
// Fallback: title-case the slug and bucket it under "Other".
const name = key.charAt(0).toUpperCase() + key.slice(1);
const name = MANAGED_TOOLKIT_NAME_BY_SLUG.get(key) ?? prettifyUnknownSlug(key);
const category = guessCategory(key, name);
return {
slug: key,
name,
description: `Integration for ${name}.`,
category: 'Other',
icon: <GenericIntegrationIcon />,
description: defaultDescription(name, category),
category,
icon: <ComposioLogoBadge slug={key} name={name} />,
logoUrl: composioLogoUrl(key),
permissionLabel: permissionLabelFor(category),
};
}
@@ -43,20 +43,24 @@ describe('Skills page — Composio catalog fallback', () => {
it('shows known composio integrations in their configured category groups when the live toolkit list is empty', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
expect(screen.getByRole('heading', { name: 'Chat' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Productivity' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Tools & Automation' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Social' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Platform' })).toBeInTheDocument();
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('Google Calendar')).toBeInTheDocument();
expect(screen.getByText('Google Drive')).toBeInTheDocument();
expect(screen.getByText('Gmail')).toBeInTheDocument();
expect(screen.getByText('Google Sheets')).toBeInTheDocument();
expect(screen.getByText('Notion')).toBeInTheDocument();
expect(screen.getByText('Facebook')).toBeInTheDocument();
expect(screen.getByText('GitHub')).toBeInTheDocument();
expect(screen.getByText('Instagram')).toBeInTheDocument();
expect(screen.getByText('Linear')).toBeInTheDocument();
expect(screen.getByText('Reddit')).toBeInTheDocument();
expect(screen.getByText('Slack')).toBeInTheDocument();
expect(screen.getByText('Supabase')).toBeInTheDocument();
expect(screen.getByText('Zoom')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Other' })).not.toBeInTheDocument();
});
@@ -43,6 +43,7 @@ describe('Skills page — Notion composio integration', () => {
fireEvent.click(within(notionCard as HTMLElement).getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('heading', { name: 'Connect Notion' })).toBeInTheDocument();
expect(screen.getByText(/Connect your Notion account\./i)).toBeInTheDocument();
expect(screen.getByText(/Connect your Notion account through Composio/i)).toBeInTheDocument();
expect(screen.getByText(/OpenHuman's own agent permissions/i)).toBeInTheDocument();
});
});
+46
View File
@@ -24,6 +24,52 @@ import {
vi.stubEnv('DEV', true);
vi.stubEnv('MODE', 'test');
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(String(key), String(value));
},
};
}
function ensureStorage(name: 'localStorage' | 'sessionStorage') {
const current = globalThis[name];
if (
current &&
typeof current.getItem === 'function' &&
typeof current.setItem === 'function' &&
typeof current.removeItem === 'function' &&
typeof current.clear === 'function'
) {
return;
}
Object.defineProperty(globalThis, name, {
value: createStorageMock(),
configurable: true,
writable: true,
});
}
ensureStorage('localStorage');
ensureStorage('sessionStorage');
// Polyfill ResizeObserver for cmdk/Radix components in jsdom
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {