test(e2e): add stable UI hooks (#2421)

This commit is contained in:
Aqil Aziz
2026-05-22 17:53:05 -07:00
committed by GitHub
parent 1a852cdc7c
commit 05e1e5de9e
14 changed files with 154 additions and 23 deletions
@@ -265,6 +265,7 @@ const SettingsHome = () => {
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === flatItems.length - 1}
@@ -45,6 +45,7 @@ const SettingsSectionPage = ({ title, description, items }: SettingsSectionPageP
title={item.title}
description={item.description}
onClick={() => navigateToSettings(item.route)}
testId={`settings-nav-${item.id}`}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
@@ -107,6 +107,8 @@ describe('SettingsHome', () => {
expect(screen.getByText('Advanced')).toBeInTheDocument();
expect(screen.getByText('Clear App Data')).toBeInTheDocument();
expect(screen.getByText('Log out')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-account')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-notifications')).toBeInTheDocument();
});
it('localizes Appearance and Mascot menu items', () => {
@@ -5,6 +5,7 @@ interface SettingsMenuItemProps {
title: string;
description?: string;
onClick?: () => void;
testId?: string;
dangerous?: boolean;
isFirst?: boolean;
isLast?: boolean;
@@ -16,6 +17,7 @@ const SettingsMenuItem = ({
title,
description,
onClick,
testId,
dangerous = false,
isFirst = false,
isLast = false,
@@ -49,6 +51,7 @@ const SettingsMenuItem = ({
return (
<button
type="button"
data-testid={testId}
onClick={onClick}
className={`w-full flex items-center justify-between py-3 px-4 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 ${borderClasses} hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 transition-all duration-200 text-left ${roundedClasses} focus:outline-none focus:ring-0 focus:border-inherit`}>
{content}
@@ -58,6 +61,7 @@ const SettingsMenuItem = ({
return (
<div
data-testid={testId}
className={`w-full flex items-center justify-between py-3 px-4 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 ${borderClasses} ${roundedClasses}`}>
{content}
</div>
@@ -502,6 +502,7 @@ const DeveloperOptionsPanel = () => {
title={t(item.titleKey)}
description={t(item.descriptionKey)}
onClick={() => navigateToSettings(item.route)}
testId={`settings-nav-${item.id}`}
isFirst={index === 0}
isLast={false}
/>
@@ -513,6 +514,7 @@ const DeveloperOptionsPanel = () => {
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
isFirst={false}
isLast={index === trailingItems.length - 1}
/>
@@ -172,7 +172,7 @@ const MemoryDebugPanel = () => {
}, [clearNamespaceInput, refreshAll]);
return (
<div>
<div data-testid="memory-debug-panel">
<SettingsHeader
title={t('memory.debugTitle')}
showBackButton={true}
@@ -155,7 +155,7 @@ const WebhooksDebugPanel = () => {
}, [loadData]);
return (
<div>
<div data-testid="webhooks-debug-panel">
<SettingsHeader
title={t('webhooks.debugTitle')}
showBackButton={true}
@@ -0,0 +1,34 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../../intelligence/MemoryTextWithEntities', () => ({
MemoryTextWithEntities: ({ text }: { text: string }) => <span>{text}</span>,
}));
vi.mock('../../../../utils/tauriCommands', () => ({
memoryClearNamespace: vi.fn().mockResolvedValue({}),
memoryDeleteDocument: vi.fn().mockResolvedValue({}),
memoryListDocuments: vi.fn().mockResolvedValue({ data: { documents: [] } }),
memoryListNamespaces: vi.fn().mockResolvedValue([]),
memoryQueryNamespace: vi.fn().mockResolvedValue({ matches: [] }),
memoryRecallNamespace: vi.fn().mockResolvedValue({ matches: [] }),
}));
describe('MemoryDebugPanel stable test hooks', () => {
it('renders the panel-level test id', async () => {
const { default: MemoryDebugPanel } = await import('../MemoryDebugPanel');
render(<MemoryDebugPanel />);
expect(screen.getByTestId('memory-debug-panel')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('Documents')).toBeInTheDocument());
});
});
@@ -8,7 +8,7 @@
* SSE-side observable behaviour (constructor URL, skip-on-null,
* webhooks_debug event handling).
*/
import { render, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Recording EventSource stub — jsdom has no native impl.
@@ -112,6 +112,7 @@ describe('WebhooksDebugPanel — SSE auth wiring (#1922)', () => {
render(<WebhooksDebugPanel />);
expect(screen.getByTestId('webhooks-debug-panel')).toBeInTheDocument();
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1));
expect(MockEventSource.instances[0].url).toBe(
'http://localhost:7788/events/webhooks?token=rpc-token-debug-1'
@@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import UnifiedSkillCard from './SkillCard';
vi.mock('../../lib/i18n/I18nContext', () => ({
useT: () => ({ t: (key: string) => key }),
}));
describe('UnifiedSkillCard stable test hooks', () => {
it('renders row and primary action test ids', () => {
const onCtaClick = vi.fn();
render(
<UnifiedSkillCard
icon={<span />}
title="Calendar"
description="Connect calendar context"
ctaLabel="Install"
testId="skill-row-calendar"
ctaTestId="skill-install-calendar"
onCtaClick={onCtaClick}
/>
);
expect(screen.getByTestId('skill-row-calendar')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('skill-install-calendar'));
expect(onCtaClick).toHaveBeenCalledTimes(1);
});
it('renders secondary action test ids', () => {
const onUninstall = vi.fn();
render(
<UnifiedSkillCard
icon={<span />}
title="Calendar"
description="Connect calendar context"
ctaLabel="Open"
onCtaClick={vi.fn()}
secondaryActions={[
{
label: 'Uninstall',
icon: <span />,
testId: 'skill-uninstall-calendar',
onClick: onUninstall,
},
]}
/>
);
fireEvent.click(screen.getByTitle('skills.card.moreActions'));
fireEvent.click(screen.getByTestId('skill-uninstall-calendar'));
expect(onUninstall).toHaveBeenCalledTimes(1);
});
});
+8 -1
View File
@@ -12,6 +12,8 @@ export interface UnifiedSkillCardProps {
ctaLabel: string;
ctaVariant?: 'primary' | 'sage' | 'amber';
onCtaClick: () => void;
testId?: string;
ctaTestId?: string;
badge?: ReactNode;
secondaryActions?: Array<{
label: string;
@@ -46,6 +48,8 @@ export function UnifiedSkillCard({
ctaLabel,
ctaVariant = 'primary',
onCtaClick,
testId,
ctaTestId,
secondaryActions,
syncProgress,
syncSummaryText,
@@ -69,7 +73,9 @@ export function UnifiedSkillCard({
const ctaStyle = CTA_STYLES[ctaVariant] ?? CTA_STYLES.primary;
return (
<div className="flex items-center gap-3 rounded-xl border border-stone-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60">
<div
data-testid={testId}
className="flex items-center gap-3 rounded-xl border border-stone-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center text-stone-600 dark:text-neutral-300">
{icon}
</div>
@@ -155,6 +161,7 @@ export function UnifiedSkillCard({
)}
<button
type="button"
data-testid={ctaTestId}
disabled={ctaDisabled}
onClick={e => {
e.stopPropagation();
+4
View File
@@ -1244,6 +1244,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
</h2>
{/* [#1123] welcomeLocked guard removed — always show new thread button */}
<button
data-testid="new-thread-sidebar-button"
onClick={() => void handleCreateNewThread()}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200 transition-colors"
title={t('chat.newThread')}>
@@ -1279,6 +1280,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
sortedThreads.map(thread => (
<div
key={thread.id}
data-testid={`thread-row-${thread.id}`}
role="button"
tabIndex={0}
onClick={() => {
@@ -1433,6 +1435,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
</div>
<TokenUsagePill />
<button
data-testid="new-thread-button"
onClick={() => void handleCreateNewThread()}
className="px-2.5 py-1 rounded-lg text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
title={t('chat.newThreadShortcut')}>
@@ -2027,6 +2030,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
</div>
<button
data-testid="send-message-button"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
+36 -17
View File
@@ -125,6 +125,7 @@ interface ComposioConnectorTileProps {
meta: ComposioToolkitMeta;
connection: ComposioConnection | undefined;
hasComposioError: boolean;
testId?: string;
onOpen: () => void;
onRetryGlobal: () => void;
}
@@ -133,6 +134,7 @@ function ComposioConnectorTile({
meta,
connection,
hasComposioError,
testId,
onOpen,
onRetryGlobal,
}: ComposioConnectorTileProps) {
@@ -169,6 +171,7 @@ function ComposioConnectorTile({
return (
<button
type="button"
data-testid={testId}
onClick={handleClick}
title={`${meta.name}${meta.description}`}
aria-label={`${meta.name}, ${statusLabel}. ${ctaLabel}.`}
@@ -205,10 +208,11 @@ interface ChannelTileProps {
def: ChannelDefinition;
status: ChannelConnectionStatus;
icon: React.ReactNode;
testId?: string;
onOpen: () => void;
}
function ChannelTile({ def, status, icon, onOpen }: ChannelTileProps) {
function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
const { t } = useT();
const isConnected = status === 'connected';
const isPending = status === 'connecting';
@@ -219,6 +223,7 @@ function ChannelTile({ def, status, icon, onOpen }: ChannelTileProps) {
return (
<button
type="button"
data-testid={testId}
onClick={onOpen}
title={`${def.display_name}${def.description}`}
aria-label={`${def.display_name}, ${statusLabel}. ${ctaLabel}.`}
@@ -605,6 +610,8 @@ export default function Skills() {
statusColor={screenIntelligenceStatus.statusColor}
ctaLabel={screenIntelligenceStatus.ctaLabel}
ctaVariant={screenIntelligenceStatus.ctaVariant}
testId={`skill-row-${item.id}`}
ctaTestId={`skill-install-${item.id}`}
onCtaClick={() => {
if (screenIntelligenceStatus.platformUnsupported) {
navigate(item.route!);
@@ -633,6 +640,8 @@ export default function Skills() {
statusColor={autocompleteStatus.statusColor}
ctaLabel={autocompleteStatus.ctaLabel}
ctaVariant={autocompleteStatus.ctaVariant}
testId={`skill-row-${item.id}`}
ctaTestId={`skill-install-${item.id}`}
onCtaClick={() => {
if (
autocompleteStatus.platformUnsupported ||
@@ -658,6 +667,8 @@ export default function Skills() {
statusColor={voiceStatus.statusColor}
ctaLabel={voiceStatus.ctaLabel}
ctaVariant={voiceStatus.ctaVariant}
testId={`skill-row-${item.id}`}
ctaTestId={`skill-install-${item.id}`}
onCtaClick={() => {
if (
voiceStatus.connectionStatus === 'connected' ||
@@ -679,6 +690,8 @@ export default function Skills() {
title={item.name}
description={item.description}
ctaLabel={t('nav.settings')}
testId={`skill-row-${item.id}`}
ctaTestId={`skill-install-${item.id}`}
onCtaClick={() => navigate(item.route!)}
/>
);
@@ -710,6 +723,8 @@ export default function Skills() {
statusLabel={scopeLabel}
statusColor={scopeColor}
ctaLabel={t('common.seeAll')}
testId={`skill-row-${skill.id}`}
ctaTestId={`skill-install-${skill.id}`}
onCtaClick={() => {
console.debug('[skills][discovered] open drawer', { skillId: skill.id });
setSelectedSkill(skill);
@@ -719,7 +734,7 @@ export default function Skills() {
? [
{
label: t('skills.disconnect'),
testId: `uninstall-skill-${skill.id}`,
testId: `skill-uninstall-${skill.id}`,
icon: (
<svg
className="h-3.5 w-3.5"
@@ -826,13 +841,15 @@ export default function Skills() {
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{channelsGroup.items.map(item => (
<ChannelTile
key={item.id}
def={item.channelDef!}
status={item.channelStatus!}
icon={item.icon}
onOpen={() => setChannelModalDef(item.channelDef!)}
/>
<div key={item.id} data-testid={`skill-row-${item.id}`}>
<ChannelTile
def={item.channelDef!}
status={item.channelStatus!}
icon={item.icon}
testId={`skill-install-${item.id}`}
onOpen={() => setChannelModalDef(item.channelDef!)}
/>
</div>
))}
</div>
</div>
@@ -864,14 +881,16 @@ export default function Skills() {
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{composioSortedEntries.map(({ meta, connection }) => (
<ComposioConnectorTile
key={meta.slug}
meta={meta}
connection={connection}
hasComposioError={Boolean(composioError)}
onOpen={() => setComposioModalToolkit(meta)}
onRetryGlobal={() => void refreshComposio()}
/>
<div key={meta.slug} data-testid={`skill-row-composio-${meta.slug}`}>
<ComposioConnectorTile
meta={meta}
connection={connection}
hasComposioError={Boolean(composioError)}
testId={`skill-install-composio-${meta.slug}`}
onOpen={() => setComposioModalToolkit(meta)}
onRetryGlobal={() => void refreshComposio()}
/>
</div>
))}
</div>
) : (
@@ -74,10 +74,10 @@ describe('Skills page — discovered skill cards', () => {
expect(within(legacyRow as HTMLElement).getByText('Legacy').className).toMatch(/stone-600/);
// Uninstall surfaces for user-scope, non-legacy only.
expect(screen.queryByTestId('uninstall-skill-user-skill')).not.toBeInTheDocument();
expect(screen.queryByTestId('skill-uninstall-user-skill')).not.toBeInTheDocument();
const userMore = within(userRow as HTMLElement).getByTitle('More actions');
fireEvent.click(userMore);
expect(await screen.findByTestId('uninstall-skill-user-skill')).toBeInTheDocument();
expect(await screen.findByTestId('skill-uninstall-user-skill')).toBeInTheDocument();
});
it('opens the detail drawer when the View CTA is clicked', async () => {