255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
import {
|
|
buildAppEnv,
|
|
createSandbox,
|
|
launchDesktop,
|
|
type MockBackendFixture,
|
|
waitForAppReady,
|
|
writeEnvFile,
|
|
writeMockProviderConfig
|
|
} from './fixtures'
|
|
import { startMockServer } from './mock-server'
|
|
import { RealSessionBuilder } from './real-session-builder'
|
|
import { expect, test } from './test'
|
|
|
|
// User-made sections in the Bots roster: a bot is filed by dragging it onto a
|
|
// section or through its row menu, the section is renamed through the same
|
|
// dialog shape sessions use, and deleting a section returns its bots to
|
|
// Unassigned (with an Undo toast, no confirmation). With no sections created
|
|
// the roster is the plain list it always was.
|
|
|
|
type Page = MockBackendFixture['page']
|
|
|
|
let fixture: MockBackendFixture | null = null
|
|
|
|
// BOT_SECTIONS_SCREENSHOT_DIR=<dir> saves full-window captures at the key
|
|
// states — handy for design review; never part of the assertions.
|
|
async function capture(page: Page, name: string): Promise<void> {
|
|
const dir = process.env.BOT_SECTIONS_SCREENSHOT_DIR
|
|
|
|
if (!dir) {
|
|
return
|
|
}
|
|
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
await page.screenshot({ path: path.join(dir, `${name}.png`) })
|
|
}
|
|
|
|
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
|
|
const dir = path.join(hermesHome, 'profiles', name)
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
writeMockProviderConfig(dir, mockUrl)
|
|
writeEnvFile(dir)
|
|
|
|
const builder = await RealSessionBuilder.start(dir)
|
|
|
|
try {
|
|
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
|
|
} finally {
|
|
await builder.close()
|
|
}
|
|
}
|
|
|
|
const roster = (page: Page) => page.locator('[data-slot="bots-roster"]')
|
|
const botRow = (page: Page, name: string) => roster(page).locator(`[data-roster-key="local::${name}"]`)
|
|
|
|
/** A section's label span — the one node whose text is exactly the name. */
|
|
const sectionLabel = (page: Page, name: string) =>
|
|
page.locator('span.truncate', { hasText: new RegExp(`^${name}$`, 'i') })
|
|
|
|
/** The heading's fold button (label + count) — the ⋯ menu trigger is a sibling with no text. */
|
|
const sectionHeading = (page: Page, name: string) =>
|
|
roster(page).locator('[data-slot="bots-section"] button[aria-expanded]').filter({ has: sectionLabel(page, name) })
|
|
|
|
const sectionBlock = (page: Page, name: string) =>
|
|
roster(page).locator('[data-slot="bots-section"]').filter({ has: sectionLabel(page, name) })
|
|
|
|
/** Section name → roster keys of the rows under it (the plain list has no sections). */
|
|
async function layout(page: Page): Promise<Array<[string, string[]]>> {
|
|
return roster(page).locator('[data-slot="bots-section"]').evaluateAll(blocks =>
|
|
blocks.map(block => [
|
|
block.querySelector('button[aria-expanded] span.truncate')?.textContent?.trim() ?? '',
|
|
[...block.querySelectorAll<HTMLElement>('[data-roster-key]')].map(row => row.dataset.rosterKey ?? '')
|
|
])
|
|
)
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
const mock = await startMockServer()
|
|
const sandbox = createSandbox('bots-sections')
|
|
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
|
writeEnvFile(sandbox.hermesHome)
|
|
|
|
for (const name of ['alpha', 'beta', 'gamma']) {
|
|
await seedBot(sandbox.hermesHome, mock.url, name)
|
|
}
|
|
|
|
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
|
|
|
fixture = {
|
|
app,
|
|
page,
|
|
mock,
|
|
mockUrl: mock.url,
|
|
sandbox,
|
|
cleanup: async () => {
|
|
await app.close().catch(() => undefined)
|
|
await mock.close()
|
|
sandbox.cleanup()
|
|
}
|
|
}
|
|
await waitForAppReady(fixture, 120_000)
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await fixture?.cleanup()
|
|
fixture = null
|
|
})
|
|
|
|
test('file bots into user sections by menu and drag; rename; delete returns them to Unassigned', async () => {
|
|
test.setTimeout(300_000)
|
|
const page = fixture!.page
|
|
|
|
const tab = page
|
|
.getByRole('button', { name: 'Bots', exact: true })
|
|
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
|
.first()
|
|
|
|
await tab.click()
|
|
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
|
await expect(botRow(page, 'alpha')).toBeVisible({ timeout: 30_000 })
|
|
await expect(botRow(page, 'beta')).toBeVisible({ timeout: 30_000 })
|
|
|
|
// No sections yet: the plain list, no section chrome at all.
|
|
await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
|
|
await capture(page, '1-plain-roster')
|
|
|
|
// Right-click alpha → Move to section → New section… → name it → alpha is filed.
|
|
await botRow(page, 'alpha').click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Move to section' }).hover()
|
|
await expect(page.getByRole('menuitem', { name: 'New section…' })).toBeVisible()
|
|
await capture(page, '2-row-menu-move-to-section')
|
|
await page.getByRole('menuitem', { name: 'New section…' }).click()
|
|
const nameField = page.getByRole('textbox', { name: 'Section name' })
|
|
await expect(nameField).toBeVisible()
|
|
await nameField.fill('Clients')
|
|
await capture(page, '3-new-section-dialog')
|
|
await page.getByRole('button', { name: 'Create' }).click()
|
|
|
|
await expect(sectionHeading(page, 'Clients')).toBeVisible()
|
|
await expect(sectionBlock(page, 'Clients').locator('[data-roster-key="local::alpha"]')).toBeVisible()
|
|
// The remainder is Unassigned, drawn last.
|
|
await expect
|
|
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
|
.toEqual([
|
|
['Clients', 1],
|
|
['Unassigned', 3]
|
|
])
|
|
await capture(page, '4-alpha-filed')
|
|
|
|
// Drag beta over the Clients block: the target highlights while over it.
|
|
// Escape cancels — nothing moves, nothing stays highlighted or faded.
|
|
const target = sectionBlock(page, 'Clients')
|
|
const from = (await botRow(page, 'beta').boundingBox())!
|
|
const to = (await sectionHeading(page, 'Clients').boundingBox())!
|
|
|
|
const dragBetaOverClients = async () => {
|
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
|
await page.mouse.down()
|
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 10, { steps: 4 })
|
|
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 })
|
|
await expect(target).toHaveAttribute('data-drop-over', 'true')
|
|
}
|
|
|
|
await dragBetaOverClients()
|
|
await page.keyboard.press('Escape')
|
|
await page.mouse.up()
|
|
await expect(target).not.toHaveAttribute('data-drop-over', 'true')
|
|
await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
|
|
expect((await layout(page)).map(([name, keys]) => [name, keys.length])).toEqual([
|
|
['Clients', 1],
|
|
['Unassigned', 3]
|
|
])
|
|
|
|
// Drop it for real: the bot is filed.
|
|
await dragBetaOverClients()
|
|
await capture(page, '5-drag-over-clients')
|
|
await page.mouse.up()
|
|
|
|
await expect(target.locator('[data-roster-key="local::beta"]')).toBeVisible()
|
|
await expect(target).not.toHaveAttribute('data-drop-over', 'true')
|
|
// The moved row remounts under its new section; it must not stay faded.
|
|
await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
|
|
await expect
|
|
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
|
.toEqual([
|
|
['Clients', 2],
|
|
['Unassigned', 2]
|
|
])
|
|
await capture(page, '6-beta-dropped')
|
|
|
|
// Rename through the heading's context menu — the same Dialog + Input
|
|
// + Save shape as a session rename.
|
|
await sectionHeading(page, 'Clients').click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Rename…' }).click()
|
|
await expect(nameField).toHaveValue('Clients')
|
|
await nameField.fill('Customers')
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
await expect(sectionHeading(page, 'Customers')).toBeVisible()
|
|
await expect(sectionHeading(page, 'Clients')).toHaveCount(0)
|
|
await capture(page, '7-renamed')
|
|
|
|
// A second, empty section from the + menu shows its drop hint; collapsing
|
|
// a section folds its rows like the gateway headings do.
|
|
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
|
await page.getByRole('menuitem', { name: 'New section' }).click()
|
|
await nameField.fill('Team')
|
|
await page.getByRole('button', { name: 'Create' }).click()
|
|
await expect(sectionBlock(page, 'Team').getByText('Drag bots here')).toBeVisible()
|
|
await sectionHeading(page, 'Customers').click()
|
|
await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(0)
|
|
await capture(page, '8-empty-section-and-collapsed')
|
|
await sectionHeading(page, 'Customers').click()
|
|
await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(2)
|
|
|
|
// Delete Customers: no confirmation, its two bots return to Unassigned,
|
|
// and the toast offers Undo.
|
|
await sectionHeading(page, 'Customers').click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
|
await expect(sectionHeading(page, 'Customers')).toHaveCount(0)
|
|
const toast = page.getByRole('status').filter({ hasText: 'Deleted “Customers”' })
|
|
await expect(toast).toBeVisible()
|
|
await expect
|
|
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
|
.toEqual([
|
|
['Team', 0],
|
|
['Unassigned', 4]
|
|
])
|
|
await capture(page, '9-deleted-with-undo-toast')
|
|
|
|
await toast.getByRole('button', { name: 'Undo' }).click()
|
|
await expect(sectionHeading(page, 'Customers')).toBeVisible()
|
|
await expect
|
|
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
|
.toEqual([
|
|
['Customers', 2],
|
|
['Team', 0],
|
|
['Unassigned', 2]
|
|
])
|
|
|
|
// Membership rides the bot's profile ui_meta, so it follows profile sync.
|
|
const alphaProfile = path.join(fixture!.sandbox.hermesHome, 'profiles', 'alpha', 'profile.yaml')
|
|
await expect.poll(() => (fs.existsSync(alphaProfile) ? fs.readFileSync(alphaProfile, 'utf8') : '')).toMatch(/sectionId:\s*sec-/)
|
|
|
|
// Delete both sections: the roster is the plain list again.
|
|
for (const name of ['Customers', 'Team']) {
|
|
await sectionHeading(page, name).click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
|
}
|
|
|
|
await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
|
|
await expect(botRow(page, 'alpha')).toBeVisible()
|
|
})
|