178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
/**
|
|
* 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<typeof HermesSdk>()
|
|
|
|
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(<BotRow bot={bot} onDelete={noop} onEdit={noop} onGroup={noop} onNewSection={noop} />)
|
|
|
|
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 } } })
|
|
})
|
|
})
|