Files
openhuman/app/src/agentworld/pages/DirectorySection.test.tsx
T

446 lines
19 KiB
TypeScript

/**
* Tests for DirectorySection — Agent World directory grid.
*
* The page loads the agent directory via `apiClient.directory.listAgents()` and
* renders one of: loading skeleton / payment_required / error (generic + wallet
* locked) / empty / populated grid of agent cards. Each card derives a handle,
* initials, avatar colour and skills/tags from the raw `AgentCard`, and toggles
* a "selected" ring on click / Enter / Space. We mock the apiClient so no RPC
* fires and the render stays deterministic.
*/
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { PaymentRequiredError } from '../../lib/agentworld/invokeApiClient';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import DirectorySection from './DirectorySection';
// ── Mock apiClient ────────────────────────────────────────────────────────────
vi.mock('../AgentWorldShell', () => ({
apiClient: {
directory: { listAgents: vi.fn() },
follows: {
stats: vi.fn(),
followers: vi.fn(),
following: vi.fn(),
follow: vi.fn(),
unfollow: vi.fn(),
},
},
}));
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
const listAgents = vi.mocked(apiClient.directory.listAgents);
const walletStatus = vi.mocked(fetchWalletStatus);
const followStats = vi.mocked(apiClient.follows.stats);
const followFollowing = vi.mocked(apiClient.follows.following);
const followFollow = vi.mocked(apiClient.follows.follow);
const followUnfollow = vi.mocked(apiClient.follows.unfollow);
beforeEach(() => {
vi.clearAllMocks();
// Default: wallet returns a Solana address.
walletStatus.mockResolvedValue({
accounts: [{ chain: 'solana', address: 'MyWaLLetAddr123' }],
} as unknown as Awaited<ReturnType<typeof fetchWalletStatus>>);
// Default: stats return zero and we follow nobody (single batched lookup).
followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 });
followFollowing.mockResolvedValue({ following: [] });
});
// ── Loading state ──────────────────────────────────────────────────────────────
describe('loading state', () => {
test('renders the pulsing skeleton grid before the fetch settles', () => {
// A promise that never resolves keeps the component in `loading`.
listAgents.mockReturnValue(new Promise(() => {}));
const { container } = render(<DirectorySection />);
// The skeleton renders six animate-pulse placeholder cards.
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons).toHaveLength(6);
// No status text or real cards yet.
expect(screen.queryByText(/No agents found/i)).not.toBeInTheDocument();
});
});
// ── Payment required state ──────────────────────────────────────────────────────
describe('payment_required state', () => {
test('renders the x402 wallet payment message', async () => {
listAgents.mockRejectedValueOnce(new PaymentRequiredError({ terms: 'x402' }));
render(<DirectorySection />);
expect(await screen.findByText(/Access requires payment/i)).toBeInTheDocument();
expect(
screen.getByText(/Your wallet will be used to fulfill the x402 payment challenge/i)
).toBeInTheDocument();
});
});
// ── Error states ────────────────────────────────────────────────────────────────
describe('error state', () => {
test('renders a generic error message for an unknown failure', async () => {
listAgents.mockRejectedValueOnce(new Error('boom: backend exploded'));
render(<DirectorySection />);
expect(await screen.findByText(/Failed to load Directory/i)).toBeInTheDocument();
// `String(err)` includes the "Error: " prefix.
expect(screen.getByText(/boom: backend exploded/i)).toBeInTheDocument();
});
test('renders the wallet-unlock prompt when the wallet is not configured', async () => {
listAgents.mockRejectedValueOnce(new Error('the wallet is not configured'));
render(<DirectorySection />);
expect(
await screen.findByText(/Unlock your wallet to browse the Directory/i)
).toBeInTheDocument();
expect(screen.getByText(/Import your recovery phrase in Settings/i)).toBeInTheDocument();
});
test('renders the wallet-unlock prompt when wallet secret material is missing', async () => {
listAgents.mockRejectedValueOnce(new Error('wallet secret material is missing'));
render(<DirectorySection />);
expect(
await screen.findByText(/Unlock your wallet to browse the Directory/i)
).toBeInTheDocument();
});
});
// ── Empty state ─────────────────────────────────────────────────────────────────
describe('empty state', () => {
test('renders "No agents found" when the directory returns no agents', async () => {
listAgents.mockResolvedValueOnce({ agents: [] });
render(<DirectorySection />);
expect(await screen.findByText(/No agents found/i)).toBeInTheDocument();
expect(screen.getByText(/No agents are registered in the directory yet/i)).toBeInTheDocument();
});
test('renders the empty state when the data has an explicit empty agents array', async () => {
// The `?? []` render fallback guards against a null/undefined `agents` field
// surviving into the ok state; an explicit [] takes the same empty branch.
listAgents.mockResolvedValueOnce({ agents: [] } as { agents: [] });
render(<DirectorySection />);
expect(await screen.findByText(/No agents found/i)).toBeInTheDocument();
});
test('surfaces an error when the payload omits the agents array', async () => {
// The success handler reads `data.agents.length`, so a missing `agents`
// field throws and lands in the error branch rather than the empty state.
listAgents.mockResolvedValueOnce({} as { agents: [] });
render(<DirectorySection />);
expect(await screen.findByText(/Failed to load Directory/i)).toBeInTheDocument();
});
});
// ── Populated grid ──────────────────────────────────────────────────────────────
describe('populated directory grid', () => {
test('renders a fully-populated agent card (handle, initials, description, skills)', async () => {
listAgents.mockResolvedValueOnce({
agents: [
{
agentId: 'agent-001',
username: 'aurora',
name: 'Aurora',
description: 'A helpful demo agent.',
skills: ['research', { id: 'sk-2', name: 'writing' }, { id: 'sk-3' }, 42],
},
],
});
render(<DirectorySection />);
// Handle (username already without a leading @ → @aurora).
expect(await screen.findByText('@aurora')).toBeInTheDocument();
// Description.
expect(screen.getByText('A helpful demo agent.')).toBeInTheDocument();
// Initials derived from display name (first two chars, uppercased).
expect(screen.getByText('AU')).toBeInTheDocument();
// Skills normalised: strings pass through, objects use `name` when present,
// otherwise `String(s)` ('[object Object]' for nameless objects, '42' for numbers).
expect(screen.getByText('research')).toBeInTheDocument();
expect(screen.getByText('writing')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
// The `{ id: 'sk-3' }` object has no `name` → stringified to [object Object].
expect(screen.getByText('[object Object]')).toBeInTheDocument();
});
test('strips a leading @ from username and falls back to tags for skills', async () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'agent-002', username: '@nimbus', name: 'Nimbus', tags: ['ops'] }],
});
render(<DirectorySection />);
// Leading @ stripped then re-added → single @nimbus.
expect(await screen.findByText('@nimbus')).toBeInTheDocument();
// Falls back to `tags` when `skills` is absent.
expect(screen.getByText('ops')).toBeInTheDocument();
});
test('uses `name` for the handle when `username` is absent', async () => {
listAgents.mockResolvedValueOnce({ agents: [{ agentId: 'agent-003', name: 'Solo' }] });
render(<DirectorySection />);
expect(await screen.findByText('@Solo')).toBeInTheDocument();
});
test('falls back to the first 8 chars of agentId when name and username are absent', async () => {
listAgents.mockResolvedValueOnce({ agents: [{ agentId: 'abcdef0123456789' }] });
render(<DirectorySection />);
// displayName = agentId.slice(0, 8) → 'abcdef01'; handle = '@abcdef01'.
expect(await screen.findByText('@abcdef01')).toBeInTheDocument();
// Initials = first two chars uppercased → 'AB'.
expect(screen.getByText('AB')).toBeInTheDocument();
});
test('renders an empty description and hides the skills row when no skills/tags', async () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'agent-bare', username: 'bare', name: 'Bare' }],
});
const { container } = render(<DirectorySection />);
expect(await screen.findByText('@bare')).toBeInTheDocument();
// No skills pills are rendered.
expect(container.querySelector('.rounded-full.bg-stone-100')).not.toBeInTheDocument();
});
test('renders one card per agent when several are returned', async () => {
listAgents.mockResolvedValueOnce({
agents: [
{ agentId: 'agent-005', username: 'first', name: 'First' },
{ agentId: 'agent-006', username: 'second', name: 'Second' },
],
});
render(<DirectorySection />);
expect(await screen.findByText('@first')).toBeInTheDocument();
expect(screen.getByText('@second')).toBeInTheDocument();
// Each agent has a card (role=button) plus a Follow button once the wallet and
// follow-state resolve. Use getAllByText to verify two cards rendered.
expect(screen.getAllByText(/^@(first|second)$/)).toHaveLength(2);
});
});
// ── Interactions ────────────────────────────────────────────────────────────────
describe('card selection', () => {
test('toggles the selected ring on click', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'agent-007', username: 'clicky', name: 'Clicky' }],
});
render(<DirectorySection />);
await screen.findByText('@clicky');
// The card is a div[role=button]; the Follow button is a <button type=button>.
// Select the outer card div by its data-testid class (cursor-pointer).
const card = screen.getAllByRole('button').find(el => el.tagName === 'DIV') as HTMLElement;
// Not selected initially.
expect(card.className).not.toContain('ring-1');
await user.click(card);
expect(card.className).toContain('ring-1');
// Clicking again deselects.
await user.click(card);
expect(card.className).not.toContain('ring-1');
});
test('toggles selection with the Enter key', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'agent-008', username: 'enterkey', name: 'EnterKey' }],
});
render(<DirectorySection />);
await screen.findByText('@enterkey');
const card = screen.getAllByRole('button').find(el => el.tagName === 'DIV') as HTMLElement;
card.focus();
await user.keyboard('{Enter}');
expect(card.className).toContain('ring-1');
});
test('toggles selection with the Space key', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'agent-009', username: 'spacer', name: 'Spacer' }],
});
render(<DirectorySection />);
await screen.findByText('@spacer');
const card = screen.getAllByRole('button').find(el => el.tagName === 'DIV') as HTMLElement;
card.focus();
await user.keyboard('[Space]');
expect(card.className).toContain('ring-1');
});
test('ignores other keys', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'agent-010', username: 'idle', name: 'Idle' }],
});
render(<DirectorySection />);
await screen.findByText('@idle');
const card = screen.getAllByRole('button').find(el => el.tagName === 'DIV') as HTMLElement;
card.focus();
await user.keyboard('{Escape}');
expect(card.className).not.toContain('ring-1');
});
test('selecting one card leaves the other unaffected', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [
{ agentId: 'agent-011', username: 'alpha', name: 'Alpha' },
{ agentId: 'agent-012', username: 'beta', name: 'Beta' },
],
});
render(<DirectorySection />);
await screen.findByText('@alpha');
// Card divs have role=button; Follow <button>s also have role=button.
// Filter for the outer card divs only.
const cards = screen.getAllByRole('button').filter(el => el.tagName === 'DIV');
const alphaCard = cards.find(c => within(c).queryByText('@alpha')) as HTMLElement;
const betaCard = cards.find(c => within(c).queryByText('@beta')) as HTMLElement;
await user.click(alphaCard);
expect(alphaCard.className).toContain('ring-1');
expect(betaCard.className).not.toContain('ring-1');
});
});
// ── Cleanup / cancellation ──────────────────────────────────────────────────────
describe('cancellation', () => {
test('does not update state after unmount (no act warning on late resolve)', async () => {
let resolve!: (v: { agents: [] }) => void;
listAgents.mockReturnValue(
new Promise(r => {
resolve = r;
})
);
const { unmount } = render(<DirectorySection />);
unmount();
// Resolve after unmount — the cancelled flag should swallow the update.
resolve({ agents: [] });
await waitFor(() => expect(listAgents).toHaveBeenCalled());
});
});
// ── Follow button ─────────────────────────────────────────────────────────────
describe('follow button', () => {
test('renders Follow button on agent cards when wallet is available', async () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-001', username: 'alice', name: 'Alice' }],
});
followFollowing.mockResolvedValueOnce({ following: [] });
followStats.mockResolvedValueOnce({
agentId: 'other-agent-001',
followerCount: 5,
followingCount: 3,
});
render(<DirectorySection />);
expect(await screen.findByText('@alice')).toBeInTheDocument();
// Should render a Follow button since we are NOT following.
expect(await screen.findByText('Follow')).toBeInTheDocument();
// Should render follower count.
expect(await screen.findByText(/5 followers/)).toBeInTheDocument();
});
test('renders Following button when already following', async () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-002', username: 'bob', name: 'Bob' }],
});
followFollowing.mockResolvedValueOnce({
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }],
});
followStats.mockResolvedValueOnce({
agentId: 'other-agent-002',
followerCount: 10,
followingCount: 2,
});
render(<DirectorySection />);
expect(await screen.findByText('Following')).toBeInTheDocument();
});
test('does not render Follow button on own agent card', async () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'MyWaLLetAddr123', username: 'myself', name: 'Myself' }],
});
render(<DirectorySection />);
expect(await screen.findByText('@myself')).toBeInTheDocument();
// No Follow button for self.
expect(screen.queryByText('Follow')).not.toBeInTheDocument();
expect(screen.queryByText('Following')).not.toBeInTheDocument();
});
test('clicking Follow calls follows.follow and updates to Following', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-003', username: 'carol', name: 'Carol' }],
});
followFollowing.mockResolvedValueOnce({ following: [] });
followStats.mockResolvedValueOnce({
agentId: 'other-agent-003',
followerCount: 0,
followingCount: 0,
});
followFollow.mockResolvedValueOnce({
follower: 'MyWaLLetAddr123',
followee: 'other-agent-003',
createdAt: '',
});
render(<DirectorySection />);
const followBtn = await screen.findByText('Follow');
await user.click(followBtn);
expect(followFollow).toHaveBeenCalledWith('other-agent-003');
expect(await screen.findByText('Following')).toBeInTheDocument();
});
test('fetches the following-set once for the whole directory (no per-card N+1)', async () => {
listAgents.mockResolvedValueOnce({
agents: [
{ agentId: 'other-agent-a', username: 'a', name: 'A' },
{ agentId: 'other-agent-b', username: 'b', name: 'B' },
{ agentId: 'other-agent-c', username: 'c', name: 'C' },
],
});
followFollowing.mockResolvedValueOnce({
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-b', createdAt: '' }],
});
render(<DirectorySection />);
// Card B resolves to Following from the single batched lookup.
expect(await screen.findByText('Following')).toBeInTheDocument();
// One batched following lookup regardless of card count; the old per-card
// followers lookup must not fire at all.
expect(followFollowing).toHaveBeenCalledTimes(1);
expect(followFollowing).toHaveBeenCalledWith('MyWaLLetAddr123', expect.anything());
expect(apiClient.follows.followers).not.toHaveBeenCalled();
});
test('clicking Following calls follows.unfollow and reverts to Follow', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-004', username: 'dave', name: 'Dave' }],
});
followFollowing.mockResolvedValueOnce({
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }],
});
followStats.mockResolvedValueOnce({
agentId: 'other-agent-004',
followerCount: 1,
followingCount: 0,
});
followUnfollow.mockResolvedValueOnce(undefined);
render(<DirectorySection />);
const followingBtn = await screen.findByText('Following');
await user.click(followingBtn);
expect(followUnfollow).toHaveBeenCalledWith('other-agent-004');
expect(await screen.findByText('Follow')).toBeInTheDocument();
});
});