/** * The bot row's two side effects: pre-warming and opening. * * Pre-warm is per-row and hover-scoped. Warming the whole roster on paint * spun up every profile backend the moment the Bots rail rendered, so the row * warms exactly one bot and only once a pointer is actually over it — and a * source-scoped row pre-dials its OWN source rather than the active gateway. * * Opening is delegated whole: the row hands its exact roster row to * openRosterBot and does nothing else. It never activates a connection * itself, which is what keeps a remote row from resolving into the same-named * local bot. * * Ported from tests/profile-prewarm.test.mjs, which sliced BotRow out of the * old plugin.js bundle and rendered it against a hand-built jsx stub. */ import type * as HermesSdk from '@hermes/plugin-sdk' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { BotRow } from './bot-row' import { translateBots } from './i18n-test-helper' import type { RosterRow } from './types' const { ensureAgent, ensureBotMetadata, notifyError, openRosterBot, requestProfile, warmAgent, warmProfile } = vi.hoisted(() => ({ ensureAgent: vi.fn(), ensureBotMetadata: vi.fn(), notifyError: vi.fn(), openRosterBot: vi.fn(), requestProfile: vi.fn(), warmAgent: vi.fn(), warmProfile: vi.fn() })) vi.mock('@hermes/plugin-sdk', async importOriginal => { const sdk = await importOriginal() return { ...sdk, host: { ...sdk.host, ensureAgent, notifyError, requestProfile, warmAgent, warmProfile }, // The plugin bundle normally lands via `ctx.i18n.register` at load, so // without this every localized label in the row renders empty. usePluginI18n: () => translateBots } }) vi.mock('./canonical-chat', () => ({ ensureBotMetadata, notifyBotOpenFailure: vi.fn(), openBotCanonicalChat: vi.fn(), prepareBotSource: vi.fn(), PROFILE_SESSION_LIST_LIMIT: 200 })) vi.mock('./roster-actions', () => ({ openRosterBot })) const noop = () => undefined function renderRow(bot: RosterRow) { render() return screen.getByRole('button') } beforeEach(() => { vi.clearAllMocks() ensureBotMetadata.mockResolvedValue({ pinned: true }) openRosterBot.mockResolvedValue(true) requestProfile.mockResolvedValue({}) }) describe('pre-warm is hover-scoped, never roster-wide', () => { it('warms nothing on paint and exactly the hovered bot on pointer entry', async () => { const row = renderRow({ name: 'alpha' } as RosterRow) expect(warmProfile).not.toHaveBeenCalled() fireEvent.pointerEnter(row) expect(warmProfile.mock.calls).toEqual([['alpha']]) expect(warmAgent).not.toHaveBeenCalled() }) it('pre-dials a source-scoped row on its own source', async () => { const row = renderRow({ connectionId: 'work', connectionLabel: 'Work', name: 'research', remoteSource: true, sourceScoped: true } as RosterRow) fireEvent.pointerEnter(row) expect(warmAgent.mock.calls).toEqual([['work', 'research']]) expect(warmProfile).not.toHaveBeenCalled() }) }) describe('the row delegates the open and claims no activation authority', () => { it('hands a remote Connections row to openRosterBot without activating it', async () => { const bot = { connectionId: 'work', connectionLabel: 'Work', name: 'research', remoteSource: true, sourceScoped: true } as RosterRow fireEvent.click(renderRow(bot)) expect(ensureAgent).not.toHaveBeenCalled() expect(openRosterBot.mock.calls).toEqual([[bot]]) }) it('never resolves a remote default into the same-named local bot', async () => { const bot = { connectionId: 'mac-mini', connectionLabel: 'Mac Mini', name: 'default', remoteSource: true, sourceScoped: true } as RosterRow fireEvent.click(renderRow(bot)) expect(ensureAgent).not.toHaveBeenCalled() expect(openRosterBot.mock.calls[0][0].connectionId).toBe('mac-mini') expect(notifyError).not.toHaveBeenCalled() }) }) describe('the menu opens the same forever-chat a row click does', () => { it('opens the canonical chat', async () => { const bot = { name: 'alpha' } as RosterRow fireEvent.contextMenu(renderRow(bot)) fireEvent.click(await screen.findByText('Open Bot Chat')) expect(openRosterBot.mock.calls).toEqual([[bot]]) }) }) describe('context-menu mutations hydrate the alias first', () => { it('reads the backend row before toggling pin, and writes to the alias target', async () => { // A non-identity alias (Desktop calls it `worker`, the backend calls it // `backend-worker`) must have its CURRENT state hydrated from its own // source before the toggle — flipping a locally-assumed value would // fight whatever the backend actually holds. const bot = { connectionId: 'remote-a', name: 'worker', remoteSource: true, route: { connectionId: 'remote-a', mode: 'remote', profile: 'worker', targetProfile: 'backend-worker' }, sourceScoped: true } as RosterRow fireEvent.contextMenu(renderRow(bot)) // The label reads from LOCAL meta (unpinned here); the toggle reads from // the hydrated backend row, which says pinned. That divergence is the // point — an alias whose state lives elsewhere must not be flipped // against a locally-assumed value. fireEvent.click(await screen.findByText('Pin to top')) await vi.waitFor(() => expect(requestProfile.mock.calls.some(([, method]) => method === 'profiles.configure')).toBe(true) ) expect(ensureBotMetadata).toHaveBeenCalledWith(bot) const [route, , params] = requestProfile.mock.calls.find(([, method]) => method === 'profiles.configure')! expect(route.profile).toBe('worker') expect(params).toMatchObject({ name: 'backend-worker', ui_meta: { 'hermes-bots': { pinned: false } } }) }) })