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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions ui/src/components/ui/__tests__/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Badge } from '../badge';

describe('Badge', () => {
it('has the default size', () => {
render(<Badge>Default</Badge>);
expect(screen.getByText('Default')).toHaveClass('h-5', 'px-1.5');
});

it('has a small size for dense lists', () => {
render(<Badge size="sm">Small</Badge>);
const badge = screen.getByText('Small');
expect(badge).toHaveClass('h-4', 'px-1', 'text-[10px]', 'leading-4');
expect(badge).not.toHaveClass('h-5');
expect(badge).not.toHaveClass('px-1.5');
expect(badge).not.toHaveClass('text-[11px]');
});
});
83 changes: 83 additions & 0 deletions ui/src/components/ui/__tests__/copy-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later

import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CopyButton } from '@/components/ui/copy-button';

const copyText = vi.hoisted(() => vi.fn());
vi.mock('@/lib/clipboard', () => ({ copyText }));

async function click(name: string) {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name }));
});
}

describe('CopyButton', () => {
beforeEach(() => {
vi.useFakeTimers();
copyText.mockReset();
copyText.mockResolvedValue(true);
});
afterEach(() => {
vi.useRealTimers();
});

it('copies, shows Check for 2 s, then returns', async () => {
render(<CopyButton text="abc" label="Copy id" />);
const idle = screen.getByRole('button', { name: 'Copy id' });
const idleIcon = idle.innerHTML;
const idleClass = idle.className;

await click('Copy id');
expect(copyText).toHaveBeenCalledWith('abc');

const copied = screen.getByRole('button', { name: 'Copied' });
expect(copied.innerHTML).not.toBe(idleIcon);
expect(copied.querySelector('svg')).toHaveClass('text-success');
expect(copied.className).toBe(idleClass);

await act(async () => {
vi.advanceTimersByTime(1999);
});
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument();

await act(async () => {
vi.advanceTimersByTime(1);
});
const back = screen.getByRole('button', { name: 'Copy id' });
expect(back.innerHTML).toBe(idleIcon);
});

it('uses the copied label that the caller gives', async () => {
render(<CopyButton text="abc" label="Copy" copiedLabel="Done" />);
await click('Copy');
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
});

it('reads an async text function when clicked', async () => {
const text = vi.fn(async () => 'later');
render(<CopyButton text={text} label="Copy" />);
expect(text).not.toHaveBeenCalled();
await click('Copy');
expect(copyText).toHaveBeenCalledWith('later');
});

it('stays idle when the copy fails', async () => {
copyText.mockResolvedValue(false);
render(<CopyButton text="abc" label="Copy" />);
await click('Copy');
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument();
});

it('stays idle when the text function rejects', async () => {
const text = vi.fn(async (): Promise<string> => {
throw new Error('no link');
});
render(<CopyButton text={text} label="Copy" />);
await click('Copy');
expect(copyText).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument();
});
});
25 changes: 25 additions & 0 deletions ui/src/components/ui/__tests__/count-labels.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { CompactCount } from '../compact-count';
import { RetryCount } from '../retry-count';

// A name on a plain span is not exposed reliably, so the counts are images
// with their whole meaning as the name.
describe('count labels', () => {
it('names the retry count for assistive technology', () => {
render(<RetryCount count={3} />);
expect(screen.getByRole('img', { name: 'Retried 3×' })).toHaveTextContent(
'3'
);
});

it('names the whole count of a compact count', () => {
render(<CompactCount value={1234567} />);
expect(screen.getByRole('img', { name: '1,234,567' })).toHaveTextContent(
'1.2M'
);
});
});
154 changes: 154 additions & 0 deletions ui/src/components/ui/__tests__/icon-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later

import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Play } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { IconButton } from '@/components/ui/icon-button';
import { Spinner } from '@/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';

function sizeClasses(el: HTMLElement): string[] {
return el.className.split(/\s+/).filter((c) => /^size-|^h-|^w-/.test(c));
}

describe('IconButton', () => {
it('keeps the same size classes when idle, busy and disabled', () => {
render(
<div>
<IconButton label="Idle" icon={Play} size="sm" />
<IconButton label="Busy" icon={Play} size="sm" busy />
<IconButton label="Off" icon={Play} size="sm" disabledReason="No" />
</div>
);
const idle = sizeClasses(screen.getByRole('button', { name: 'Idle' }));
expect(idle).toContain('size-6');
expect(sizeClasses(screen.getByRole('button', { name: 'Busy' }))).toEqual(
idle
);
expect(sizeClasses(screen.getByRole('button', { name: 'Off' }))).toEqual(
idle
);
});

it('uses 28 px for md and puts a spinner in the icon slot', () => {
render(<IconButton label="Run" icon={Play} busy />);
const button = screen.getByRole('button', { name: 'Run' });
expect(button).toHaveClass('size-7');
expect(button).toHaveAttribute('aria-busy', 'true');
expect(button.querySelectorAll('svg')).toHaveLength(1);
expect(button.querySelector('svg')).toHaveClass('animate-spin');
});

it('ignores clicks while busy', async () => {
const onClick = vi.fn();
render(<IconButton label="Run" icon={Play} busy onClick={onClick} />);
await userEvent.click(screen.getByRole('button', { name: 'Run' }));
expect(onClick).not.toHaveBeenCalled();
});

it('sets aria-pressed and a pressed background', () => {
render(
<div>
<IconButton label="On" icon={Play} pressed />
<IconButton label="Off" icon={Play} pressed={false} />
<IconButton label="Plain" icon={Play} />
</div>
);
const on = screen.getByRole('button', { name: 'On' });
expect(on).toHaveAttribute('aria-pressed', 'true');
expect(on).toHaveClass('bg-muted');
expect(screen.getByRole('button', { name: 'Off' })).toHaveAttribute(
'aria-pressed',
'false'
);
expect(screen.getByRole('button', { name: 'Plain' })).not.toHaveAttribute(
'aria-pressed'
);
});

it('shows the label, shortcut and description in the tooltip', async () => {
render(
<IconButton
label="Run step"
icon={Play}
shortcut="Ctrl+R"
description="Runs this step alone"
/>
);
await userEvent.hover(screen.getByRole('button', { name: 'Run step' }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Run step');
expect(tip).toHaveTextContent('Ctrl+R');
expect(tip).toHaveTextContent('Runs this step alone');
});

it('shows the disabled reason in the tooltip', async () => {
const onClick = vi.fn();
render(
<IconButton
label="Run step"
icon={Play}
disabledReason="Save the workflow first"
onClick={onClick}
/>
);
const button = screen.getByRole('button', { name: 'Run step' });
expect(button).toBeDisabled();
// The wrapper takes the pointer event that the disabled button cannot.
await userEvent.hover(button.parentElement as HTMLElement);
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Save the workflow first');
expect(onClick).not.toHaveBeenCalled();
});
});

// A second Radix tooltip that opens closes the first one and its content:
// the button of a tooltip keeps working, and the title says its name.
describe('IconButton in a tooltip', () => {
it('stays clickable and names itself with a title', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} data-testid="host">
host
</span>
</TooltipTrigger>
<TooltipContent>
<IconButton label="Copy it" icon={Play} onClick={onClick} />
</TooltipContent>
</Tooltip>
);
await user.hover(screen.getByTestId('host'));
const tip = await screen.findByRole('tooltip');
const button = within(tip).getByRole('button', { name: 'Copy it' });
expect(button).toHaveAttribute('title', 'Copy it');

await user.click(button);

expect(onClick).toHaveBeenCalledTimes(1);
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
});
});

describe('Spinner', () => {
it('is hidden from assistive technology without a label', () => {
const { container } = render(<Spinner />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('aria-hidden', 'true');
expect(svg).toHaveClass('size-3.5', 'animate-spin');
expect(screen.queryByRole('status')).toBeNull();
});

it('is a named status with a label', () => {
render(<Spinner label="Loading" />);
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
});
});
15 changes: 15 additions & 0 deletions ui/src/components/ui/__tests__/kbd.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Kbd } from '@/components/ui/kbd';

describe('Kbd', () => {
it('renders a kbd element with the key text and the shared box', () => {
render(<Kbd className="ml-4">Ctrl/Cmd+C</Kbd>);
const key = screen.getByText('Ctrl/Cmd+C');
expect(key.tagName).toBe('KBD');
expect(key).toHaveClass('rounded', 'border', 'bg-muted', 'ml-4');
});
});
Loading