diff --git a/apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts b/apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts deleted file mode 100644 index 8eac23c5a1..0000000000 --- a/apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { - buildAppEnv, - createSandbox, - launchDesktop, - type MockBackendFixture, - waitForAppReady, - writeEnvFile, - writeMockProviderConfig -} from './fixtures' -import { MOCK_REPLY, startMockServer } from '../../../tests-js/scripts/mock-server' -import { RealSessionBuilder } from './real-session-builder' -import { expect, test } from './test' - -// Every bot's canonical chat is STORED under the same title ("Bot Chat" — the -// name the gateway resolves it by), so the main tab strip captioned every open -// bot chat identically and two bots' tabs were indistinguishable (#99152). The -// tab must read the bot's display name while the stored title stays canonical. - -type Page = MockBackendFixture['page'] - -let fixture: MockBackendFixture | null = null - -async function openBots(page: Page): Promise { - 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() -} - -async function openUntil(action: () => Promise, expected: () => Promise, attempts = 3): Promise { - for (let attempt = 1; ; attempt += 1) { - await action() - - try { - await expected() - - return - } catch (error) { - if (attempt >= attempts) { - throw error - } - } - } -} - -async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise { - 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() - } -} - -/** Every tab caption in the main strip (the main `workspace` tab + tiles). */ -function mainStripTabTitles(page: Page): Promise { - return page.evaluate(() => - [...document.querySelectorAll('[data-zone-tabstrip="grp-main"] [data-tree-tab]')].map(element => - (element.textContent ?? '').trim() - ) - ) -} - -test.beforeAll(async () => { - const mock = await startMockServer() - const sandbox = createSandbox('bots-tabname') - writeMockProviderConfig(sandbox.hermesHome, mock.url) - writeEnvFile(sandbox.hermesHome) - await seedBot(sandbox.hermesHome, mock.url, 'alpha') - await seedBot(sandbox.hermesHome, mock.url, 'beta') - - 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("an open Bot Chat's tab reads the bot's name, not the canonical 'Bot Chat' title", async () => { - test.setTimeout(300_000) - const page = fixture!.page - - await openBots(page) - - const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first() - await expect(alphaRow).toBeVisible({ timeout: 30_000 }) - - await openUntil( - () => alphaRow.click(), - () => - expect(page.getByText('Hello alpha', { exact: true }).filter({ visible: true }).first()).toBeVisible({ - timeout: 45_000 - }) - ) - - // A `+` side thread beside the Bot Chat gives the main zone a tab strip — - // the surface where every bot chat used to read "Bot Chat". - await page.keyboard.press('Control+t') - const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first() - await expect(composer).toBeVisible({ timeout: 15_000 }) - await composer.click() - await composer.fill('hello alpha thread') - await page.keyboard.press('Enter') - await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 }) - - await expect.poll(() => mainStripTabTitles(page), { timeout: 15_000 }).toHaveLength(2) - const captions = await mainStripTabTitles(page) - expect(captions.some(caption => /alpha/i.test(caption))).toBe(true) - expect(captions.some(caption => /bot chat/i.test(caption))).toBe(false) -}) diff --git a/apps/desktop/e2e/bot-routines-pane-narrow.spec.ts b/apps/desktop/e2e/bot-routines-pane-narrow.spec.ts deleted file mode 100644 index 1e758d1ed7..0000000000 --- a/apps/desktop/e2e/bot-routines-pane-narrow.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' - -import { startMockServer } from '../../../tests-js/scripts/mock-server' - -import { - buildAppEnv, - createSandbox, - launchDesktop, - type MockBackendFixture, - waitForAppReady, - writeEnvFile, - writeMockProviderConfig -} from './fixtures' -import { RealSessionBuilder } from './real-session-builder' -import { expect, test } from './test' - -// The Scheduled jobs (Routines) pane docks at 250px beside a bot chat. A job -// row is a grid item, and grid items default to `min-width: auto`, so a long -// nowrap title pinned the row wider than the pane: the enable/disable Switch -// and the delete control were clipped off the right edge and the next-run -// label was cut mid-word (#91623, #89534). Closing the pane with its ✕ also -// remembered the dismissal forever — re-entering Bot Mode never brought the -// pane back (#102224). - -type Page = MockBackendFixture['page'] - -const LONG_TITLE = '[bot:alpha] Weekly research digest with a deliberately long routine title that overflows' - -let fixture: MockBackendFixture | null = null -let alphaHome = '' - -async function openBots(page: Page): Promise { - 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() -} - -async function openSessions(page: Page): Promise { - const tab = page - .getByRole('button', { name: /^sessions$/i }) - .or(page.getByRole('tab', { name: /^sessions$/i })) - .first() - - await tab.click() -} - -async function openUntil(action: () => Promise, expected: () => Promise, attempts = 3): Promise { - for (let attempt = 1; ; attempt += 1) { - await action() - - try { - await expected() - - return - } catch (error) { - if (attempt >= attempts) { - throw error - } - } - } -} - -async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise { - 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() - } - - return dir -} - -/** A real cron job in the profile's own cron store, written by the real store code. */ -function seedRoutine(profileHome: string, title: string): void { - const python = path.resolve(process.cwd(), '../../venv/bin/python') - const repoRoot = path.resolve(process.cwd(), '../..') - - const script = [ - 'from cron.jobs import create_job', - `create_job(name=${JSON.stringify(title)}, schedule='every 4 days', prompt='say hello')` - ].join('\n') - - execFileSync(python, ['-c', script], { - cwd: repoRoot, - env: { ...process.env, HERMES_HOME: profileHome }, - stdio: 'pipe' - }) -} - -function readJobs(profileHome: string): Array<{ enabled?: boolean; name?: string; state?: string }> { - const raw = JSON.parse(fs.readFileSync(path.join(profileHome, 'cron', 'jobs.json'), 'utf8')) - - return Array.isArray(raw) ? raw : (raw.jobs ?? []) -} - -async function openAlphaChat(page: Page): Promise { - await openBots(page) - - const alphaRow = page - .getByRole('button', { name: /^alpha\b/i }) - .filter({ visible: true }) - .first() - - await expect(alphaRow).toBeVisible({ timeout: 30_000 }) - await openUntil( - () => alphaRow.click(), - () => - expect(page.getByText('Hello alpha', { exact: true }).filter({ visible: true }).first()).toBeVisible({ - timeout: 45_000 - }) - ) -} - -function routinesTab(page: Page) { - return page.locator('[data-tree-tab="hermes-bots:routines"]').filter({ visible: true }).first() -} - -/** Expand the collapsed right-edge Scheduled jobs tab (a no-op when the pane is already open); resolve the row. */ -async function expandRoutines(page: Page) { - const row = page - .getByRole('button', { name: /Weekly research digest/ }) - .filter({ visible: true }) - .first() - - if (!(await row.isVisible())) { - const tab = routinesTab(page) - await expect(tab).toBeVisible({ timeout: 30_000 }) - await tab.click() - } - - await expect(row).toBeVisible({ timeout: 30_000 }) - await page.waitForTimeout(2_500) - await expect(row).toBeVisible() - - return row -} - -test.beforeAll(async () => { - const mock = await startMockServer() - const sandbox = createSandbox('bots-routines') - writeMockProviderConfig(sandbox.hermesHome, mock.url) - writeEnvFile(sandbox.hermesHome) - alphaHome = await seedBot(sandbox.hermesHome, mock.url, 'alpha') - seedRoutine(alphaHome, LONG_TITLE) - - 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('a long routine title never pushes the Switch, delete control or next-run label out of the 250px pane', async ({}, testInfo) => { - test.setTimeout(300_000) - const page = fixture!.page - - await openAlphaChat(page) - const row = await expandRoutines(page) - await page.screenshot({ path: testInfo.outputPath('routines-pane.png') }) - - // Geometry against the pane's own scroll container, not body text: the row - // is a grid item, so its intrinsic width is what pins the controls off-edge. - const geometry = await row.evaluate(button => { - const card = button.parentElement!.parentElement as HTMLElement - const scroller = card.closest('.overflow-y-auto')! - const paneRect = scroller.getBoundingClientRect() - - const rect = (el: Element | null) => { - const r = el!.getBoundingClientRect() - - return { left: r.left, right: r.right, width: r.width } - } - - const nextRun = [...card.querySelectorAll('span')].find(s => /^next/i.test(s.textContent ?? ''))! - const title = button.querySelector('span.truncate')! - - return { - card: rect(card), - pane: rect(scroller), - switch: rect(card.querySelector('[role="switch"]')), - trash: rect(card.querySelector('button[aria-label]')), - nextRun: { ...rect(nextRun), clipped: nextRun.scrollWidth > nextRun.clientWidth + 1, text: nextRun.textContent }, - titleEllipsized: title.scrollWidth > title.clientWidth, - paneRight: paneRect.right - } - }) - - - expect.soft(geometry.card.right).toBeLessThanOrEqual(geometry.pane.right + 1) - expect.soft(geometry.switch.right).toBeLessThanOrEqual(geometry.pane.right + 1) - expect.soft(geometry.trash.right).toBeLessThanOrEqual(geometry.pane.right + 1) - expect.soft(geometry.nextRun.right).toBeLessThanOrEqual(geometry.pane.right + 1) - expect.soft(geometry.nextRun.clipped).toBe(false) - expect.soft(geometry.nextRun.text).toMatch(/next: in \d+ days?/i) - // The title is the one thing that MAY be cut, and it must be cut with an ellipsis. - expect.soft(geometry.titleEllipsized).toBe(true) - - await test.step("clicking the row's Switch toggles the job in place — the pane stays, jobs.json flips (#95031)", async () => { - const toggle = row.locator('xpath=..').getByRole('switch') - await expect(toggle).toBeVisible() - expect(readJobs(alphaHome)[0]?.enabled).not.toBe(false) - - await toggle.click() - - await expect.poll(() => readJobs(alphaHome)[0]?.enabled, { timeout: 30_000 }).toBe(false) - await expect(row).toBeVisible() - await expect(page.getByText(/Open this bot's continuous chat/i)).toHaveCount(0) - await page.screenshot({ path: testInfo.outputPath('routines-pane-toggled.png') }) - }) - - await test.step('closing Scheduled jobs with ✕ is recoverable — leaving and re-entering Bot Mode brings the pane back (#102224)', async () => { - const tab = page.locator('[data-tree-tab="hermes-bots:routines"]').first() - await expect(tab).toBeVisible({ timeout: 30_000 }) - await tab.hover() - const closer = tab.getByRole('button', { name: /^close$/i }).first() - await closer.click({ force: true }) - await expect(page.getByRole('button', { name: /Weekly research digest/ })).toHaveCount(0, { timeout: 15_000 }) - await page.screenshot({ path: testInfo.outputPath('routines-closed.png') }) - - await openSessions(page) - await openAlphaChat(page) - await page.screenshot({ path: testInfo.outputPath('routines-restored.png') }) - - await expect(page.locator('[data-tree-tab="hermes-bots:routines"]').first()).toBeVisible({ timeout: 30_000 }) - }) -}) diff --git a/apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts b/apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts deleted file mode 100644 index 5f1a95fc89..0000000000 --- a/apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts +++ /dev/null @@ -1,169 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { - buildAppEnv, - createSandbox, - launchDesktop, - type MockBackendFixture, - waitForAppReady, - writeEnvFile, - writeMockProviderConfig -} from './fixtures' -import { MOCK_REPLY, startMockServer } from '../../../tests-js/scripts/mock-server' -import { RealSessionBuilder } from './real-session-builder' -import { expect, test } from './test' - -// A manual model pick in the composer is sticky for ordinary new chats. A `+` -// chat opened beside a bot in Bot Mode targets that bot's profile without -// switching the window's ambient composer, so a pick made for some other open -// session must NOT ride into the bot's session.create: the bot profile's -// configured default model is what the new chat's inference request carries -// (#95264). - -type Page = MockBackendFixture['page'] - -const AMBIENT_PICK = 'ambient-pick-model' - -let fixture: MockBackendFixture | null = null - -async function openBots(page: Page): Promise { - 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() -} - -async function settle(page: Page, timeout = 90_000): Promise { - await page - .getByText(/Waking up/i) - .first() - .waitFor({ state: 'hidden', timeout }) - .catch(() => undefined) - await page.waitForTimeout(500) -} - -async function openUntil(action: () => Promise, expected: () => Promise, attempts = 3): Promise { - for (let attempt = 1; ; attempt += 1) { - await action() - - try { - await expected() - - return - } catch (error) { - if (attempt >= attempts) { - throw error - } - } - } -} - -async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise { - 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() - } -} - -test.beforeAll(async () => { - const mock = await startMockServer({ extraModels: [AMBIENT_PICK] }) - const sandbox = createSandbox('bot-tile-model') - writeMockProviderConfig(sandbox.hermesHome, mock.url) - writeEnvFile(sandbox.hermesHome) - await seedBot(sandbox.hermesHome, mock.url, 'alpha') - - 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("a bot's `+` chat runs on the bot profile's model, not the ambient composer pick", async ({}, testInfo) => { - test.setTimeout(300_000) - const page = fixture!.page - const mock = fixture!.mock - - await openBots(page) - - const alphaRow = page - .getByRole('button', { name: /^alpha\b/i }) - .filter({ visible: true }) - .first() - await expect(alphaRow).toBeVisible({ timeout: 30_000 }) - await openUntil( - () => alphaRow.click(), - () => - expect(page.getByText('Hello alpha', { exact: true }).filter({ visible: true }).first()).toBeVisible({ - timeout: 45_000 - }) - ) - - await settle(page, 15_000) - - // A manual pick while the bot's canonical chat is open: the window's ambient - // composer now holds a model that is NOT the profile default. Rows are - // hover-submenu triggers (reasoning/fast edit); Enter on the row commits. - await page - .getByRole('button', { name: /^Model ·/ }) - .first() - .click() - await page.getByRole('textbox', { name: 'Search models' }).fill('ambient pick') - const row = page.getByRole('menuitem', { name: /^Ambient Pick Model/ }).first() - await expect(row).toBeVisible() - await row.focus() - await page.keyboard.press('Enter') - await expect(page.getByRole('button', { name: /^Model ·/ }).first()).toHaveAccessibleName(new RegExp(AMBIENT_PICK), { - timeout: 15_000 - }) - await page.screenshot({ path: testInfo.outputPath('bot-chat-manual-pick.png') }) - - // `+` beside the bot: a fresh bots-workspace tile for alpha. - const before = mock.receivedModels.length - await page.keyboard.press('Control+t') - await settle(page) - const composer = page - .locator('[data-slot="composer-root"] [contenteditable="true"]') - .filter({ visible: true }) - .first() - await expect(composer).toBeVisible({ timeout: 15_000 }) - await composer.click() - await composer.fill('hello alpha thread') - await page.keyboard.press('Enter') - await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 }) - await page.screenshot({ path: testInfo.outputPath('bot-tile-after-turn.png') }) - - await expect.poll(() => mock.receivedModels.length, { timeout: 15_000 }).toBeGreaterThan(before) - const models = mock.receivedModels.slice(before) - // The bot profile's configured default (config.yaml `model.default`), never - // the window's manual composer pick. - expect(models).not.toContain(AMBIENT_PICK) - expect(models).toContain('mock-model') -}) diff --git a/apps/desktop/e2e/group-chat-code-block-and-media.spec.ts b/apps/desktop/e2e/group-chat-code-block-and-media.spec.ts deleted file mode 100644 index 945e52e913..0000000000 --- a/apps/desktop/e2e/group-chat-code-block-and-media.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' -import { expect, test } from './test' - -// A bot's group-chat reply was rendered through raw Streamdown, whose stock -// code block lays its header and body out as inline siblings: the code body -// sat shifted right by the header's width and the wrapper's overflow-hidden -// clipped the tail with no scrollbar (#91878). The same raw path never ran the -// Desktop's `MEDIA:` transform, so a bot's `MEDIA:/path/file.png` showed as -// a plain path instead of an inline image/player (#93728). Group replies now -// go through the same message renderer as the 1:1 chat. - -const SHOT_DIR = path.join(os.tmpdir(), 'batchbots/panes-layout-cron-tile/shots') -// One unbroken 600+ char token: it cannot wrap, so it MUST overflow the -// message column — the probe asserts that overflow exists before asserting -// nothing clips it (a line that fits proves nothing about #91878). -const LONG_LINE = `const veryLongIdentifierNameForTheGroupChatCodeBlockRepro_${'x'.repeat(600)} = 1` -// 1x1 PNG. -const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' - -let fixture: MockBackendFixture | null = null -let mediaPath = '' - -function codeReply(): string { - return ['Here is the draft:', '', '```ts', LONG_LINE, 'export {}', '```', '', `MEDIA:${mediaPath}`].join('\n') -} - -async function openBots(page: MockBackendFixture['page']): Promise { - 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() -} - -async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise { - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Bot' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Bot' }) - await dialog.getByPlaceholder('inbox-triage').fill(name) - await dialog.getByPlaceholder('Inbox Triage').fill(title) - await dialog.getByRole('button', { name: 'Create Bot' }).click() - await expect(dialog).toBeHidden({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) -} - -async function createRoom(page: MockBackendFixture['page']) { - await openBots(page) - await createAgent(page, 'writer', 'Writer') - await createAgent(page, 'editor', 'Editor') - - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Group Chat' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) - - for (const title of ['Writer', 'Editor']) { - await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() - } - - await dialog.getByRole('textbox', { name: 'Group name' }).fill('Writer, Editor') - await dialog.getByRole('button', { name: 'Create Group (2)' }).click() - - const composer = page.getByRole('textbox', { name: 'Message Writer, Editor' }).filter({ visible: true }) - await expect(composer).toBeVisible({ timeout: 20_000 }) - - return composer -} - -test.beforeEach(async () => { - fixture = await setupMockBackend({ - mockServer: { - replyForPrompt: prompt => (prompt.includes('CODE_BLOCK_REPRO') ? codeReply() : 'ok') - } - }) - mediaPath = path.join(fixture.sandbox.hermesHome, 'group-media.png') - fs.writeFileSync(mediaPath, Buffer.from(PNG_BASE64, 'base64')) - fs.mkdirSync(SHOT_DIR, { recursive: true }) - await waitForAppReady(fixture, 120_000) -}) - -test.afterEach(async () => { - await fixture?.cleanup() - fixture = null -}) - -test('a group reply renders its code block inside the message and its MEDIA: line as inline media', async () => { - test.setTimeout(300_000) - const page = fixture!.page - const composer = await createRoom(page) - - await composer.fill('@writer CODE_BLOCK_REPRO') - await composer.press('Enter') - - // A bot created moments ago runs its intro turn in the background; when it - // lands, the roster fronts that bot's chat tab and yanks the center away - // from the room. Re-select the room and read the reply from a room body. - const roomTab = page.getByRole('tab', { name: /Writer, Editor Close/ }) - - const code = page - .locator('[data-selectable-text="true"]') - .getByText(/veryLongIdentifierNameForTheGroupChatCodeBlockRepro/) - .filter({ visible: true }) - .first() - - await expect(async () => { - if ((await roomTab.getAttribute('aria-selected')) !== 'true') { - await roomTab.click() - } - - await expect(code).toBeVisible({ timeout: 5_000 }) - }).toPass({ timeout: 90_000 }) - await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0, { timeout: 60_000 }) - await page.screenshot({ path: path.join(SHOT_DIR, 'group-chat-code-media.png') }) - - const geometry = await code.evaluate(el => { - const body = el.closest('[data-selectable-text="true"]')! - const bodyRect = body.getBoundingClientRect() - const pre = el.closest('pre') ?? el - const preRect = pre.getBoundingClientRect() - // Every box between the code text and the message body: a box whose - // content is wider than itself must scroll, never clip — that is the - // "cut off with no horizontal scrollbar" the report describes. - const chain: Array<{ tag: string; overflowX: string; scrollWidth: number; clientWidth: number }> = [] - let node: HTMLElement | null = el as HTMLElement - - while (node && node !== body.parentElement) { - chain.push({ - tag: node.tagName.toLowerCase(), - overflowX: getComputedStyle(node).overflowX, - scrollWidth: node.scrollWidth, - clientWidth: node.clientWidth - }) - node = node.parentElement - } - - const clippedWithoutScroll = chain.filter( - box => box.scrollWidth > box.clientWidth + 1 && !['auto', 'scroll'].includes(box.overflowX) - ) - - const scrollingPre = chain.find( - box => box.tag === 'pre' && box.scrollWidth > box.clientWidth + 1 && ['auto', 'scroll'].includes(box.overflowX) - ) - - return { - body: { left: bodyRect.left, right: bodyRect.right }, - pre: { left: preRect.left, right: preRect.right, width: preRect.width }, - chain, - clippedWithoutScroll, - scrollingPre, - rawMedia: body.textContent?.includes('MEDIA:') ?? false, - inlineMedia: Boolean(body.querySelector('img, audio, video')) - } - }) - - console.log('GROUP CODE BLOCK GEOMETRY', JSON.stringify(geometry)) - // The fixture really overflows and the code block scrolls to show it... - expect.soft(geometry.scrollingPre).toBeDefined() - // ...while staying inside the message column... - expect.soft(geometry.pre.right).toBeLessThanOrEqual(geometry.body.right + 1) - // ...and no box between the code and the message clips text it cannot scroll. - expect.soft(geometry.clippedWithoutScroll).toEqual([]) - // MEDIA: renders as media, never as the raw directive. - expect.soft(geometry.rawMedia).toBe(false) - expect.soft(geometry.inlineMedia).toBe(true) -}) diff --git a/apps/desktop/e2e/group-create-gate-remote-roster.spec.ts b/apps/desktop/e2e/group-create-gate-remote-roster.spec.ts deleted file mode 100644 index 2c8776bb3b..0000000000 --- a/apps/desktop/e2e/group-create-gate-remote-roster.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * E2E: the New Group Chat menu gate with one local bot plus remote-connection - * bots (#101543). "This device" is the Electron-managed local backend with - * only its `default` profile; "Homelab" is a REAL second `hermes serve` - * registered as a remote URL connection. The gate must count the same - * selectable set the dialog seats — bots from every registered connection — - * so 1 local + 1 remote enables New Group Chat and the room can be created. - */ - -import { type ChildProcess, spawn, spawnSync } from 'node:child_process' -import * as fs from 'node:fs' -import * as net from 'node:net' -import * as os from 'node:os' -import * as path from 'node:path' - -import { startMockServer } from '../../../tests-js/scripts/mock-server' - -import { - buildAppEnv, - createSandbox, - launchDesktop, - type MockBackendFixture, - type Sandbox, - waitForAppReady, - writeEnvFile, - writeMockProviderConfig, -} from './fixtures' -import { type ElectronApplication, expect, type Page, test } from './test' - -const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') -const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') -const REMOTE_LABEL = 'Homelab' -const REMOTE_ID = 'homelab' -const REMOTE_TOKEN = 'e2e-group-gate-homelab-token' -const SHOTS = path.join(os.tmpdir(), 'batchbots/group-identity-members/shots') - -interface RemoteGateway { - url: string - close: () => Promise -} - -/** The worktree's own backend: `python -m hermes_cli.main` from the repo root - * (the venv's `hermes` console script resolves the package it was installed - * from, which need not be this checkout). */ -function hermesCommand(): { bin: string; args: string[] } { - const venvPython = path.join(REPO_ROOT, '.venv', 'bin', 'python') - - if (fs.existsSync(venvPython)) { - return { bin: venvPython, args: ['-m', 'hermes_cli.main'] } - } - - const result = spawnSync('which', ['hermes'], { encoding: 'utf8' }) - - if (result.status === 0 && result.stdout.trim()) { - return { bin: result.stdout.trim(), args: [] } - } - - throw new Error('hermes backend not found: create the repo venv (uv sync) or put hermes on PATH') -} - -async function freePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer() - server.unref() - server.on('error', reject) - server.listen(0, '127.0.0.1', () => { - const { port } = server.address() as net.AddressInfo - server.close(() => resolve(port)) - }) - }) -} - -function seedProfiles(home: string, names: string[]): void { - for (const name of names) { - const dir = path.join(home, 'profiles', name) - fs.mkdirSync(dir, { recursive: true }) - fs.writeFileSync(path.join(dir, 'config.yaml'), '', 'utf8') - } -} - -async function startRemoteGateway(root: string, mockUrl: string, profiles: string[]): Promise { - const home = path.join(root, 'homelab-home') - fs.mkdirSync(home, { recursive: true }) - writeMockProviderConfig(home, mockUrl) - writeEnvFile(home) - seedProfiles(home, profiles) - const port = await freePort() - const url = `http://127.0.0.1:${port}` - - const hermes = hermesCommand() - - const child: ChildProcess = spawn(hermes.bin, [...hermes.args, 'serve', '--host', '127.0.0.1', '--port', String(port), '--skip-build'], { - cwd: REPO_ROOT, - detached: true, - // HOME is pinned to the sandbox too: profile roots are HOME-anchored, and a - // HERMES_HOME that happens to sit under the developer's ~/.hermes (a TMPDIR - // there) would otherwise serve their real profiles as the remote's bots. - env: { ...process.env, HOME: root, HERMES_HOME: home, HERMES_DASHBOARD_SESSION_TOKEN: REMOTE_TOKEN, PYTHONPATH: REPO_ROOT }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - - let log = '' - child.stdout?.on('data', (chunk: Buffer) => { log += chunk.toString() }) - child.stderr?.on('data', (chunk: Buffer) => { log += chunk.toString() }) - const deadline = Date.now() + 90_000 - - while (Date.now() < deadline) { - if (child.exitCode !== null) { - throw new Error(`remote hermes serve exited early (${child.exitCode}):\n${log}`) - } - - try { - const response = await fetch(`${url}/api/status`, { headers: { 'X-Hermes-Session-Token': REMOTE_TOKEN } }) - - if (response.ok) { - break - } - } catch { - // not up yet - } - - await new Promise(resolve => setTimeout(resolve, 500)) - } - - if (Date.now() >= deadline) { - throw new Error(`remote hermes serve never became ready:\n${log}`) - } - - return { - url, - close: async () => { - if (child.pid && child.exitCode === null) { - try { - process.kill(-child.pid, 'SIGTERM') - } catch { - child.kill('SIGTERM') - } - } - - await new Promise(resolve => setTimeout(resolve, 500)) - }, - } -} - -function writeConnectionsRegistry(sandbox: Sandbox, remoteUrl: string): void { - fs.writeFileSync( - path.join(sandbox.userDataDir, 'connections.json'), - JSON.stringify( - { - version: 2, - primary: 'local', - launchMode: 'primary', - lastUsed: 'local', - connections: [ - { id: 'local', kind: 'local', label: 'This device' }, - { id: REMOTE_ID, kind: 'remote', label: REMOTE_LABEL, url: remoteUrl, authMode: 'token', token: { encoding: 'plain', value: REMOTE_TOKEN } }, - ], - }, - null, - 2, - ), - { encoding: 'utf8', mode: 0o600 }, - ) -} - -const roster = (page: Page) => page.locator('[data-slot="bots-roster"]') - -test.describe('New Group Chat gate — one local bot plus remote-connection bots', () => { - let mock: Awaited> - let sandbox: Sandbox - let remote: RemoteGateway - let app: ElectronApplication - let page: Page - - test.beforeAll(async () => { - test.setTimeout(240_000) - mock = await startMockServer() - sandbox = createSandbox('group-gate') - writeMockProviderConfig(sandbox.hermesHome, mock.url) - writeEnvFile(sandbox.hermesHome) - // This device: ONLY its primary `default` profile. Homelab: default + inbox (rendered title-cased, "Inbox"). - remote = await startRemoteGateway(sandbox.root, mock.url, ['inbox']) - writeConnectionsRegistry(sandbox, remote.url) - ;({ app, page } = await launchDesktop(buildAppEnv(sandbox))) - await waitForAppReady({ app, page } as MockBackendFixture, 120_000) - await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ timeout: 120_000 }) - }) - - test.afterAll(async () => { - await app?.close().catch(() => undefined) - await remote?.close() - await mock?.close() - sandbox?.cleanup() - }) - - test('the menu entry enables and the dialog seats the remote bot', async () => { - test.setTimeout(240_000) - 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() - // Precondition: exactly one local bot, and the remote bots have arrived. - await expect(roster(page).locator(`[data-roster-key="${REMOTE_ID}::inbox"]`)).toBeVisible({ timeout: 90_000 }) - await expect(roster(page).locator('[data-roster-key^="local::"]')).toHaveCount(1) - - await page.getByRole('button', { name: 'New bot or group chat' }).click() - const entry = page.getByRole('menuitem', { name: 'New Group Chat' }) - await expect(entry).toBeVisible() - await page.screenshot({ path: `${SHOTS}/group-create-gate-menu.png` }) - await expect(entry).toBeEnabled() - await entry.click() - - const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) - await expect(dialog).toBeVisible() - await dialog.getByText('Hermes', { exact: true }).first().locator('xpath=ancestor::label').getByRole('checkbox').click() - await dialog.getByText('Inbox', { exact: true }).first().locator('xpath=ancestor::label').getByRole('checkbox').click() - await expect(dialog.getByRole('button', { name: 'Create Group (2)' })).toBeEnabled() - await page.screenshot({ path: `${SHOTS}/group-create-gate-dialog.png` }) - }) -}) diff --git a/apps/desktop/e2e/group-room-member-editing.spec.ts b/apps/desktop/e2e/group-room-member-editing.spec.ts deleted file mode 100644 index 2dad2a4213..0000000000 --- a/apps/desktop/e2e/group-room-member-editing.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import os from 'node:os' -import path from 'node:path' - -import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' -import { expect, test } from './test' - -// #91329 / #110736 — membership of an EXISTING room is editable from Group -// settings: the picker pre-checks the current seats, Save rewrites both -// membership sources the room reads (each local Bot's groups[] metadata and -// the room's durable member descriptors), and the next unaddressed send seats -// exactly the saved roster — the removed Bot never takes a turn again. - -const ROOM = 'Programmer, Reviewer' -const SHOTS = path.join(os.tmpdir(), 'batchbots/features-groups-ui/shots') -let fixture: MockBackendFixture | null = null - -type Page = MockBackendFixture['page'] - -async function durableMembers(page: Page) { - return page.evaluate(room => { - const rooms = JSON.parse(localStorage.getItem('hermes.plugin.hermes-bots.group-chats') || '{}') - - return ((rooms[room]?.members || []) as { name: string }[]).map(member => member.name).sort() - }, ROOM) -} - -async function openBots(page: Page): Promise { - 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() -} - -async function createAgent(page: Page, name: string, title: string): Promise { - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Bot' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Bot' }) - await dialog.getByPlaceholder('inbox-triage').fill(name) - await dialog.getByPlaceholder('Inbox Triage').fill(title) - await dialog.getByRole('button', { name: 'Create Bot' }).click() - await expect(dialog).toBeHidden({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) -} - -async function createRoom(page: Page) { - await openBots(page) - await createAgent(page, 'programmer', 'Programmer') - await createAgent(page, 'reviewer', 'Reviewer') - await createAgent(page, 'planner', 'Planner') - - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Group Chat' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) - - for (const title of ['Programmer', 'Reviewer']) { - await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() - } - - await dialog.getByRole('textbox', { name: 'Group name' }).fill(ROOM) - await dialog.getByRole('button', { name: 'Create Group (2)' }).click() - - return frontRoom(page) -} - -/** A just-created Bot's canonical Bot Chat hydrates late and can front its - * "Draft" tab over the room tab; bring the room back before typing into it. */ -async function frontRoom(page: Page) { - const tab = page.getByRole('tab', { name: ROOM }).first() - await expect(tab).toBeVisible({ timeout: 20_000 }) - - if ((await tab.getAttribute('aria-selected')) !== 'true') { - await tab.click() - } - - const composer = page.getByRole('textbox', { name: `Message ${ROOM}` }).filter({ visible: true }) - await expect(composer).toBeVisible({ timeout: 20_000 }) - - return composer -} - -test.beforeEach(async () => { - fixture = await setupMockBackend({ mockServer: {} }) - await waitForAppReady(fixture, 120_000) -}) - -test.afterEach(async () => { - await fixture?.cleanup() - fixture = null -}) - -test('Group settings edits the room roster and the next round seats only the saved members', async () => { - test.setTimeout(300_000) - const page = fixture!.page - await createRoom(page) - - expect(await durableMembers(page)).toEqual(['programmer', 'reviewer']) - - // Group settings → Manage members: the checklist opens pre-checked. - await page.getByRole('button', { name: `Group settings for ${ROOM}` }).click() - await page.getByRole('dialog', { name: 'Group settings' }).getByRole('button', { name: /^Manage members \(2\)/ }).click() - - const picker = page.getByRole('dialog', { name: 'Manage members' }) - await expect(picker).toBeVisible() - const row = (title: string) => picker.getByRole('checkbox').filter({ hasText: title }) - await expect(row('Programmer')).toHaveAttribute('aria-checked', 'true') - await expect(row('Reviewer')).toHaveAttribute('aria-checked', 'true') - await expect(row('Planner')).toHaveAttribute('aria-checked', 'false') - await page.screenshot({ path: `${SHOTS}/member-picker-open.png` }) - - // Cancel changes nothing. - await row('Planner').click() - await picker.getByRole('button', { name: 'Cancel' }).click() - await expect(picker).toBeHidden() - expect(await durableMembers(page)).toEqual(['programmer', 'reviewer']) - - // Header door → swap Reviewer for Planner → Save. - await page.getByRole('button', { name: 'Manage group members' }).click() - await expect(row('Planner')).toHaveAttribute('aria-checked', 'false') - await row('Planner').click() - await row('Reviewer').click() - await page.screenshot({ path: `${SHOTS}/member-picker-edited.png` }) - await picker.getByRole('button', { name: 'Save members' }).click() - await expect(picker).toBeHidden() - - // Storage truth: the durable descriptors carry the new roster … - await expect.poll(() => durableMembers(page)).toEqual(['planner', 'programmer']) - // … and so does each local Bot's groups[] metadata (the plugin's meta snapshot). - await expect.poll(() => page.evaluate(room => { - const meta = JSON.parse(localStorage.getItem('hermes.plugin.hermes-bots.bot-meta-v2') || '{}') - - return Object.entries(meta as Record) - .filter(([, value]) => (value?.groups || []).includes(room)) - .map(([key]) => key.split('::').pop()) - .sort() - }, ROOM), { timeout: 30_000 }).toEqual(['planner', 'programmer']) - - // An unaddressed send seats exactly the saved roster. - const groupComposer = await frontRoom(page) - await groupComposer.fill('ROOM_EDIT_ROUND who is here?') - await groupComposer.press('Enter') - await expect.poll(() => fixture!.mock.receivedPrompts.filter(p => p.includes('ROOM_EDIT_ROUND')).length, { timeout: 90_000 }).toBeGreaterThanOrEqual(2) - await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0, { timeout: 90_000 }) - - const speakers = fixture!.mock.receivedPrompts - .filter(p => p.includes('ROOM_EDIT_ROUND')) - .map(p => /You are @([a-z0-9_-]+)/.exec(p)?.[1]) - console.log('ROUND SPEAKERS after roster edit:', JSON.stringify(speakers)) - expect(new Set(speakers)).toEqual(new Set(['programmer', 'planner'])) - expect(speakers).not.toContain('reviewer') - await page.screenshot({ path: `${SHOTS}/member-picker-after-round.png` }) -}) diff --git a/apps/desktop/e2e/group-to-local-bot-handoff.spec.ts b/apps/desktop/e2e/group-to-local-bot-handoff.spec.ts deleted file mode 100644 index 2f863d144f..0000000000 --- a/apps/desktop/e2e/group-to-local-bot-handoff.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' -import { expect, test } from './test' - -let fixture: MockBackendFixture | null = null - -async function openBots(page: MockBackendFixture['page']): Promise { - 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() -} - -async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise { - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Bot' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Bot' }) - await dialog.getByPlaceholder('inbox-triage').fill(name) - await dialog.getByPlaceholder('Inbox Triage').fill(title) - await dialog.getByRole('button', { name: 'Create Bot' }).click() - await expect(dialog).toBeHidden({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) -} - -test.beforeAll(async () => { - fixture = await setupMockBackend() - await waitForAppReady(fixture, 120_000) -}) - -test.afterAll(async () => { - await fixture?.cleanup() - fixture = null -}) - -test('local bot replaces an open group main workspace', async () => { - test.setTimeout(240_000) - const page = fixture!.page - - await openBots(page) - await createAgent(page, 'programmer', 'Programmer') - await createAgent(page, 'reviewer', 'Reviewer') - - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Group Chat' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) - - for (const title of ['Programmer', 'Reviewer']) { - await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() - } - - await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer') - await dialog.getByRole('button', { name: 'Create Group (2)' }).click() - - const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ }) - const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true }) - await expect(groupTab).toBeVisible({ timeout: 20_000 }) - await expect(groupTab).toHaveAttribute('aria-selected', 'true') - await expect(groupComposer).toBeVisible() - - const programmer = page.getByRole('button', { name: /^Programmer\b/ }).filter({ visible: true }).first() - await programmer.click() - - // The bot's canonical chat opens INTO the main workspace pane (post - // design-system rework); as the lone pane in the zone it renders chromeless - // — no "Bot Chat" tab exists until a second pane joins the strip. The - // handoff is observed by the group surfaces leaving and the bot's chat - // (here a fresh one: its empty-state splash asks for a first message) - // taking the main workspace. The first open also spawns the bot's own - // backend, so give the "Loading session" phase a real chance to clear. - await expect(page.getByText('Say something to get started.').filter({ visible: true })).toBeVisible({ - timeout: 120_000 - }) - await expect(groupTab).toHaveCount(0) - await expect(groupComposer).toHaveCount(0) - // No "Waking up…" assertion: the mock backend can keep a bot's wake notice - // around indefinitely (see bot-mode-row-click-mirrors-registry's settle()), - // so its presence no longer distinguishes a stranded handoff. The splash - // and composer above are the proof the bot's chat took the workspace. - await expect(page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()).toBeVisible() -}) diff --git a/apps/desktop/e2e/group-transcript-mentions-reply-to.spec.ts b/apps/desktop/e2e/group-transcript-mentions-reply-to.spec.ts deleted file mode 100644 index 54225fe50f..0000000000 --- a/apps/desktop/e2e/group-transcript-mentions-reply-to.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import os from 'node:os' -import path from 'node:path' - -import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' -import { expect, test } from './test' - -// #91359 / #89883 — group-room transcript affordances: -// - a recognized @mention in a sent message renders as an inline reference -// (`.ref[data-ref=agent|human|broadcast]`); unknown @tokens stay prose; -// - a bot's message carries "Reply to ", which seeds `@tag ` into the -// room composer so the next send routes to that member only. - -const ROOM = 'Programmer, Reviewer' -const SHOTS = path.join(os.tmpdir(), 'batchbots/features-groups-ui/shots') -let fixture: MockBackendFixture | null = null - -type Page = MockBackendFixture['page'] - -async function openBots(page: Page): Promise { - 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() -} - -async function createAgent(page: Page, name: string, title: string): Promise { - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Bot' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Bot' }) - await dialog.getByPlaceholder('inbox-triage').fill(name) - await dialog.getByPlaceholder('Inbox Triage').fill(title) - await dialog.getByRole('button', { name: 'Create Bot' }).click() - await expect(dialog).toBeHidden({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) -} - -async function createRoom(page: Page) { - await openBots(page) - await createAgent(page, 'programmer', 'Programmer') - await createAgent(page, 'reviewer', 'Reviewer') - - await page.getByRole('button', { name: 'New bot or group chat' }).click() - await page.getByRole('menuitem', { name: 'New Group Chat' }).click() - - const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) - - for (const title of ['Programmer', 'Reviewer']) { - await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() - } - - await dialog.getByRole('textbox', { name: 'Group name' }).fill(ROOM) - await dialog.getByRole('button', { name: 'Create Group (2)' }).click() - - const groupComposer = page.getByRole('textbox', { name: `Message ${ROOM}` }).filter({ visible: true }) - await expect(groupComposer).toBeVisible({ timeout: 20_000 }) - - return groupComposer -} - -test.beforeEach(async () => { - fixture = await setupMockBackend({ mockServer: {} }) - await waitForAppReady(fixture, 120_000) -}) - -test.afterEach(async () => { - await fixture?.cleanup() - fixture = null -}) - -test('mentions render as inline references and Reply-to seeds the composer', async () => { - test.setTimeout(300_000) - const page = fixture!.page - const groupComposer = await createRoom(page) - - await groupComposer.fill('@programmer MENTION_RENDER check this, then @user decides; not ops@example.com and not @nobody') - await groupComposer.press('Enter') - - // The sent user line: exactly the routed bot and the human token are refs. - // A bot created moments ago runs its intro turn in the background; when it - // lands, the roster fronts that bot's chat tab and yanks the center away - // from the room. Re-select the room and read the line from a room body. - const roomTab = page.getByRole('tab', { name: new RegExp(`${ROOM} Close`) }) - - const sent = page - .locator('[data-selectable-text="true"]') - .getByText(/MENTION_RENDER check this/) - .filter({ visible: true }) - .first() - - await expect(async () => { - if ((await roomTab.getAttribute('aria-selected')) !== 'true') { - await roomTab.click() - } - - await expect(sent).toBeVisible({ timeout: 5_000 }) - }).toPass({ timeout: 60_000 }) - await expect(sent.locator('.ref[data-ref="agent"]')).toHaveText('@programmer') - await expect(sent.locator('.ref[data-ref="human"]')).toHaveText('@user') - // Only the two mention refs carry `data-ref`; the e-mail address renders - // as the shell's ordinary (also `.ref`-styled) mailto link, not a mention. - await expect(sent.locator('.ref[data-ref]')).toHaveCount(2) - await expect(sent).toContainText('@nobody') - - // The mock reply from programmer arrives; its hover action targets that bot. - await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0, { timeout: 90_000 }) - const replyButton = page.getByRole('button', { name: 'Reply to Programmer' }).first() - - await expect(async () => { - if ((await roomTab.getAttribute('aria-selected')) !== 'true') { - await roomTab.click() - } - - await expect(replyButton).toBeAttached({ timeout: 5_000 }) - }).toPass({ timeout: 60_000 }) - await sent.hover() - await page.screenshot({ path: `${SHOTS}/mention-refs.png` }) - - await groupComposer.fill('') - await replyButton.click({ force: true }) - await expect(groupComposer).toHaveValue('@programmer ') - await page.screenshot({ path: `${SHOTS}/reply-to-seeded.png` }) - - // Idempotent: a second click never doubles the tag. - await replyButton.click({ force: true }) - await expect(groupComposer).toHaveValue('@programmer ') - - // Storage truth: the stored text is untouched by rendering. - const stored = await page.evaluate(room => { - const rooms = JSON.parse(localStorage.getItem('hermes.plugin.hermes-bots.group-chats') || '{}') - - return (rooms[room]?.log || []).map((entry: any) => entry.text)[0] - }, ROOM) - - expect(stored).toBe('@programmer MENTION_RENDER check this, then @user decides; not ops@example.com and not @nobody') -}) diff --git a/apps/desktop/e2e/onboarding-settings.spec.ts b/apps/desktop/e2e/onboarding-settings.spec.ts deleted file mode 100644 index 6fe1253cdb..0000000000 --- a/apps/desktop/e2e/onboarding-settings.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import path from 'node:path' - -import { buildAppEnv, createSandbox, launchDesktop, setupNoProvider } from './fixtures' -import { type ElectronApplication, expect, type Page, test } from './test' - -const { prepareWindowForInput } = createRequire(import.meta.url)( - '../../../tests/install/e2e-assets/window-input.cjs', -) as { prepareWindowForInput: (app: ElectronApplication, page: Page) => Promise } - -test('input setup survives a fresh-install zoom restore before onboarding', async () => { - const sandbox = createSandbox('cold-input') - unlinkSync(path.join(sandbox.userDataDir, 'zoom-state.json')) - writeFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '# no provider\n', 'utf8') - let app: ElectronApplication | undefined - - try { - const launched = await launchDesktop(buildAppEnv(sandbox)) - app = launched.app - const page = launched.page - await page.waitForSelector('button', { state: 'attached' }) - await prepareWindowForInput(app, page) - const later = page.getByRole('button', { name: /choose a provider later/i }) - await expect(later).toBeVisible({ timeout: 60_000 }) - const appWindow = await app.browserWindow(page) - await appWindow.evaluate(win => win.emit('focus')) - await expect.poll(() => appWindow.evaluate(win => win.webContents.getZoomFactor())).toBeCloseTo(1) - await later.click({ timeout: 5_000 }) - await expect(later).toBeHidden() - } finally { - await app?.close().catch(() => undefined) - sandbox.cleanup() - } -}) - -// Exercise the install driver's input setup against the real renderer/backend, -// with no installer, update, credentials, or live user data. -for (const lifecycleEvent of ['focus', 'navigation'] as const) { - test(`onboarding input zoom survives ${lifecycleEvent} and opens Settings`, async () => { - const fixture = await setupNoProvider() - const { app, page, sandbox } = fixture - - try { - await prepareWindowForInput(app, page) - const later = page.getByRole('button', { name: /choose a provider later/i }) - await expect(later).toBeVisible({ timeout: 60_000 }) - const zoomFile = path.join(sandbox.userDataDir, 'zoom-state.json') - const savedLevel = () => JSON.parse(readFileSync(zoomFile, 'utf8')).zoomLevel as number - await page.evaluate(() => { - const desktop = (window as unknown as { hermesDesktop: { zoom: { setPercent: (percent: number) => void } } }).hermesDesktop - desktop.zoom.setPercent(90) - }) - await expect.poll(savedLevel).toBeCloseTo(Math.log(0.9) / Math.log(1.2)) - - await prepareWindowForInput(app, page) - const appWindow = await app.browserWindow(page) - - // The same lifecycle callback that fires when another window takes focus - // must restore our input scale, not the original 90% preference. - if (lifecycleEvent === 'focus') { - await appWindow.evaluate(win => win.emit('focus')) - } else { - await page.evaluate(() => { window.location.hash = '#/settings' }) - } - - await expect.poll(() => appWindow.evaluate(win => win.webContents.getZoomFactor())).toBeCloseTo(1) - expect(savedLevel()).toBe(0) - await later.click({ timeout: 5_000 }) - await expect(later).toBeHidden() - - if (lifecycleEvent === 'navigation') { - await page.evaluate(() => { window.location.hash = '#/' }) - } - - await page.getByRole('button', { name: 'Open settings', exact: true }).click({ timeout: 5_000 }) - await expect(page).toHaveURL(/settings/) - } finally { - await fixture.cleanup() - } - }) -} diff --git a/apps/desktop/e2e/right-pane.spec.ts b/apps/desktop/e2e/right-pane.spec.ts deleted file mode 100644 index 5447955f45..0000000000 --- a/apps/desktop/e2e/right-pane.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { test, expect } from './test' - -import { - type MockBackendFixture, - setupMockBackend, - waitForAppReady, -} from './fixtures' - -let fixture: MockBackendFixture | null = null - -test.beforeAll(async () => { - fixture = await setupMockBackend() - await waitForAppReady(fixture, 120_000) -}) - -test.afterAll(async () => { - await fixture?.cleanup() - fixture = null -}) - -test('persistent terminal overlay follows the pane after split dragging', async () => { - const page = fixture!.page - - await page.keyboard.press('Control+`') - await page.locator('[data-terminal-slot]').waitFor({ state: 'visible', timeout: 30_000 }) - await page.locator('[data-persistent-terminal] .xterm').waitFor({ state: 'visible', timeout: 30_000 }) - - const result = await page.evaluate(async () => { - const slot = document.querySelector('[data-terminal-slot]') - const overlay = document.querySelector('[data-persistent-terminal]') - - if (!slot || !overlay) { - return { drift: -1, moved: 0, target: false } - } - - const before = slot.getBoundingClientRect() - const target = [...document.querySelectorAll('[role="separator"]')] - .map(element => { - const box = element.getBoundingClientRect() - const horizontal = box.width > box.height - const center = horizontal - ? (box.top + box.bottom) / 2 - : (box.left + box.right) / 2 - const sides = horizontal - ? [before.top, before.bottom] - : [before.left, before.right] - - return { - element, - box, - horizontal, - score: Math.min(...sides.map(side => Math.abs(center - side))), - } - }) - .filter(item => item.box.width > 0 && item.box.height > 0) - .sort((a, b) => a.score - b.score)[0] - - if (!target) { - return { drift: -1, moved: 0, target: false } - } - - const x = target.box.left + target.box.width / 2 - const y0 = target.box.top + target.box.height / 2 - const nearestSide = target.horizontal - ? Math.abs(y0 - before.top) < Math.abs(y0 - before.bottom) - ? 'top' - : 'bottom' - : Math.abs(x - before.left) < Math.abs(x - before.right) - ? 'left' - : 'right' - const deltaX = nearestSide === 'left' ? -1 : nearestSide === 'right' ? 1 : 0 - const deltaY = nearestSide === 'top' ? -1 : nearestSide === 'bottom' ? 1 : 0 - let currentX = x - let y = y0 - const pointer = { - bubbles: true, - cancelable: true, - pointerId: 71, - pointerType: 'mouse', - isPrimary: true, - button: 0, - buttons: 1, - } - - target.element.dispatchEvent( - new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }), - ) - - for (let index = 0; index < 24; index += 1) { - currentX += deltaX - y += deltaY - window.dispatchEvent( - new PointerEvent('pointermove', { - ...pointer, - clientX: currentX, - clientY: y, - }), - ) - await new Promise(resolve => requestAnimationFrame(() => resolve())) - } - - window.dispatchEvent( - new PointerEvent('pointerup', { - ...pointer, - buttons: 0, - clientX: currentX, - clientY: y, - }), - ) - await new Promise(resolve => setTimeout(resolve, 350)) - await new Promise(resolve => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - ) - - const next = slot.getBoundingClientRect() - const fixed = overlay.getBoundingClientRect() - - return { - drift: Math.max( - Math.abs(next.top - fixed.top), - Math.abs(next.left - fixed.left), - Math.abs(next.width - fixed.width), - Math.abs(next.height - fixed.height), - ), - moved: Math.max( - Math.abs(next.top - before.top), - Math.abs(next.left - before.left), - Math.abs(next.width - before.width), - Math.abs(next.height - before.height), - ), - target: true, - } - }) - - expect(result.target).toBe(true) - expect(result.moved).toBeGreaterThan(10) - expect(result.drift).toBeLessThanOrEqual(1) -}) diff --git a/apps/desktop/e2e/worktree-branch-status.spec.ts b/apps/desktop/e2e/worktree-branch-status.spec.ts deleted file mode 100644 index 33e9946e72..0000000000 --- a/apps/desktop/e2e/worktree-branch-status.spec.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { execFileSync } from 'node:child_process' -import * as fs from 'node:fs' -import * as path from 'node:path' - -import { - buildAppEnv, - createSandbox, - launchDesktop, - type MockBackendFixture, - waitForAppReady, - writeEnvFile, - writeMockProviderConfig, -} from './fixtures' -import { startMockServer } from '../../../tests-js/scripts/mock-server' -import { expect, test } from './test' - -const BRANCH_NAME = 'e2e-composer-branch' - -/** - * Enough branches to make both the base-branch popover and the convert-branch - * list taller than their default height. That is the condition in which the - * dialog's own scroll box clips the popover, and that regression is what the - * visual snapshots here guard against. - */ -const EXTRA_BRANCHES = [ - 'feature/alpha-one', - 'feature/beta-two', - 'feature/gamma-three', - 'fix/delta-four', - 'fix/epsilon-five', - 'chore/zeta-six', - 'chore/eta-seven', - 'spike/theta-eight', - 'spike/iota-nine', - 'release/kappa-ten', -] - -function createGitRepo(root: string): string { - const repo = path.join(root, 'repo') - - fs.mkdirSync(repo, { recursive: true }) - execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo }) - execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo }) - execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo }) - fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8') - execFileSync('git', ['add', 'README.md'], { cwd: repo }) - execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo }) - - for (const branch of EXTRA_BRANCHES) { - execFileSync('git', ['branch', branch], { cwd: repo }) - } - - return repo -} - -function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void { - writeMockProviderConfig(hermesHome, mockUrl) - fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8') - writeEnvFile(hermesHome) -} - -let fixture: MockBackendFixture | null = null - -/** A dialog renders as `[data-slot="dialog-content"]` (components/ui/dialog.tsx). */ -const DIALOG = '[data-slot="dialog-content"]' - -/** Open the worktree dialog with the global ⌘⇧B / ctrl+shift+B hotkey. */ -async function openWorktreeDialog(): Promise { - const page = fixture!.page - await page.keyboard.press('Control+Shift+B') - await expect(page.locator(DIALOG)).toBeVisible() -} - -/** Close the open dialog and wait until it leaves the DOM. */ -async function closeDialog(): Promise { - const page = fixture!.page - await page.keyboard.press('Escape') - await expect(page.locator(DIALOG)).toHaveCount(0) -} - -test.beforeAll(async () => { - const sandbox = createSandbox('worktree-branch-status') - const repo = createGitRepo(sandbox.root) - const mock = await startMockServer() - - configureRepoCwd(sandbox.hermesHome, mock.url, repo) - - 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) - - // The coding rail, and thus the ⌘⇧B worktree dialog, mounts only after the - // session resolves a cwd that holds a repo. This happens on the first turn. - const composer = page.locator('[contenteditable="true"]').first() - await composer.click() - await composer.type('create a repo-backed e2e session', { delay: 2 }) - await page.keyboard.press('Enter') - await page.waitForFunction( - prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt), - 'create a repo-backed e2e session', - { timeout: 15_000 }, - ) - await expect(page.locator('.coding-status-bar')).toContainText('main') -}) - -test.afterAll(async () => { - await fixture?.cleanup() - fixture = null -}) - -test('worktree dialog renders the base-branch picker over the dialog, not clipped by it', async () => { - const page = fixture!.page - - await openWorktreeDialog() - - // Open the base-branch combobox. With 11 branches, the list is taller than - // the space below the trigger. A popover that portals into the dialog's - // `overflow-y-auto` box is therefore cut off. - await page.getByRole('button', { name: /branch off/i }).click() - await expect(page.getByPlaceholder('Search branches…')).toBeVisible() - await expect(page.getByRole('option', { name: 'feature/alpha-one' })).toBeVisible() - - - // This check does not depend on pixels: the dialog's scroll box must not crop - // the painted box of the popover. Measure the geometry, so a headless run - // fails on this regression before a person looks at a diff image. - const clipped = await page.evaluate(() => { - const popover = document.querySelector('[data-slot="popover-content"]') - const dialog = document.querySelector('[data-slot="dialog-content"]') - - if (!popover || !dialog) { - return { reason: 'missing', clipped: true } - } - - const p = popover.getBoundingClientRect() - const d = dialog.getBoundingClientRect() - const scrolls = window.getComputedStyle(dialog).overflowY - - return { - reason: 'measured', - // Only a clipping ancestor can crop the popover. The popover is cut when - // the dialog scrolls its overflow AND the popover goes past the box of - // the dialog. - clipped: (scrolls === 'auto' || scrolls === 'scroll' || scrolls === 'hidden') && - (p.bottom > d.bottom + 1 || p.top < d.top - 1 || p.right > d.right + 1 || p.left < d.left - 1), - } - }) - - expect(clipped.clipped, `base-branch popover is clipped by the dialog (${clipped.reason})`).toBe(false) - - await page.keyboard.press('Escape') - await closeDialog() -}) - -test('worktree dialog convert-an-existing-branch sub-view lists the repo branches', async () => { - const page = fixture!.page - - await openWorktreeDialog() - await page.getByRole('button', { name: 'Convert an existing branch' }).click() - - await expect(page.getByPlaceholder('Search branches…')).toBeVisible() - await expect(page.getByRole('option', { name: /feature\/alpha-one/ })).toBeVisible() - - - await closeDialog() -}) - -test('creating a branch with ctrl-shift-b updates the composer git-status branch and leaves no dialog behind', async ({}, testInfo) => { - const page = fixture!.page - const codingRow = page.locator('.coding-status-bar') - - await openWorktreeDialog() - // Exactly one dialog instance. A second dialog here, hidden or empty, is the - // symptom of the double-open bug. - await expect(page.locator(DIALOG)).toHaveCount(1) - - const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first() - await expect(branchInput).toBeVisible() - await branchInput.fill(BRANCH_NAME) - // Select a base branch, so this test uses the same path as the user: open the - // picker, select a branch, then submit. It does not use the default value. - // The keyboard drives this step. The dialog still clips the popover, so a - // mouse click on an option is not reliable until that bug is corrected. The - // double-open check below is therefore independent of the clipping bug. - await page.getByRole('button', { name: /branch off/i }).click() - await page.getByPlaceholder('Search branches…').fill('main') - await expect(page.getByRole('option', { name: 'main' }).first()).toBeVisible() - await page.keyboard.press('Enter') - await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) - - await page.getByRole('button', { name: 'New worktree' }).click() - - await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 }) - - // The dialog must close and stay closed. No empty second dialog can remain - // after the new worktree session starts. - await expect(page.locator(DIALOG)).toHaveCount(0) - await page.waitForTimeout(2000) - await expect(page.locator(DIALOG)).toHaveCount(0) - - await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') }) -}) - -test('ctrl-shift-b opens exactly one worktree dialog when a second composer is on screen', async ({}, testInfo) => { - const page = fixture!.page - - // ⌘T / ctrl+T stacks a second session tile. That gives a second live composer - // and therefore a second coding rail. Each rail mounted its own - // WorktreeDialog, and each rail subscribed to the same global token. One - // keypress therefore opened two stacked dialogs, and the dialog the user - // dismissed showed an identical empty one behind it. One mount in the sidebar - // makes that impossible by structure. - await page.keyboard.press('Control+T') - await expect(page.locator('.coding-status-bar')).toHaveCount(2, { timeout: 20_000 }) - - await page.keyboard.press('Control+Shift+B') - await expect(page.locator(DIALOG).first()).toBeVisible() - // Wait: let the effect of every subscriber flush before the count. - await page.waitForTimeout(500) - - const count = await page.locator(DIALOG).count() - await page.screenshot({ path: testInfo.outputPath('worktree-dialog-two-composers.png') }) - - expect(count, 'one hotkey press must open exactly one worktree dialog').toBe(1) - - // A dismissal then leaves nothing behind. - await closeDialog() -}) diff --git a/apps/desktop/e2e/zoom-preservation.spec.ts b/apps/desktop/e2e/zoom-preservation.spec.ts deleted file mode 100644 index f852c04a8a..0000000000 --- a/apps/desktop/e2e/zoom-preservation.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * E2E regression: in-page route navigation must preserve the chosen UI scale. - * - * Desktop is a HashRouter over one file:// document, so every route is a - * distinct URL to Chromium's per-URL zoom store, and a route with no record of - * its own resolves to the host default (100%). In-page navigation fires no load - * or window event, so nothing re-asserted the persisted level: switching - * sessions dropped the window to 100% while Appearance kept reading the chosen - * scale (#48658, #38854, #79863). - * - * Prerequisite: `npm run build` must have been run so dist/ exists. - */ - -import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' -import { expect, test } from './test' - -const SCALE = 110 - -let fixture: MockBackendFixture | null = null - -async function readZoomPercent(): Promise { - return fixture!.page.evaluate(async () => { - const desktop = window as unknown as { - hermesDesktop: { zoom: { get: () => Promise<{ percent: number }> } } - } - - return (await desktop.hermesDesktop.zoom.get()).percent - }) -} - -async function setZoomPercent(percent: number): Promise { - await fixture!.page.evaluate(target => { - const desktop = window as unknown as { - hermesDesktop: { zoom: { setPercent: (percent: number) => void } } - } - - desktop.hermesDesktop.zoom.setPercent(target) - }, percent) - await expect.poll(readZoomPercent).toBe(percent) -} - -async function gotoRoute(route: string): Promise { - const page = fixture!.page - - const before = await page.evaluate(() => window.location.hash) - - await page.evaluate(target => { - window.location.hash = target - }, route) - // The router may normalise or redirect the hash (an unknown route, a settings - // tab it rewrites), so wait for the navigation itself rather than an exact - // hash — every in-page hop must keep the chosen scale either way. - await page.waitForFunction(previous => window.location.hash !== previous, before) -} - -test.beforeAll(async () => { - fixture = await setupMockBackend() - await waitForAppReady(fixture, 120_000) -}) - -test.afterAll(async () => { - await fixture?.cleanup() - fixture = null -}) - -test('a non-default UI scale survives navigation to never-zoomed routes', async () => { - await setZoomPercent(SCALE) - - // Routes Chromium has no zoom record for — what opening a new session looks - // like to the per-URL store. Pre-fix, the first hop reports 100%. - const fresh = `/e2e-zoom-${Date.now()}` - - for (const route of [`${fresh}-one`, `${fresh}-two`, '/settings?tab=config%3Aappearance']) { - await gotoRoute(route) - await expect.poll(readZoomPercent, { message: `UI scale after navigating to ${route}` }).toBe(SCALE) - } -}) - -test('Cmd/Ctrl+N preserves a non-default UI scale', async () => { - const page = fixture!.page - - await gotoRoute('/settings') - await setZoomPercent(SCALE) - - await page.evaluate(() => { - ;(document.activeElement as HTMLElement | null)?.blur() - }) - await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N') - await page.waitForFunction(() => window.location.hash === '#/') - - await expect.poll(readZoomPercent).toBe(SCALE) -}) diff --git a/apps/desktop/electron/command-screenshot.test.ts b/apps/desktop/electron/command-screenshot.test.ts deleted file mode 100644 index 64acadb495..0000000000 --- a/apps/desktop/electron/command-screenshot.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { EventEmitter } from 'node:events' -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import os from 'node:os' -import path from 'node:path' - -import { afterEach, describe, expect, it, vi } from 'vitest' - -const native = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn() })) - -const electron = vi.hoisted(() => ({ - handlers: new Map(), - windows: [] as any[], - focused: null as any, - screenPermission: 'granted', - directory: '' -})) - -vi.mock('./command-screenshot-monitor', () => ({ - CommandScreenshotMonitor: class { - start = native.start - stop = native.stop - } -})) -vi.mock('electron', async () => { - const { EventEmitter } = await import('node:events') - const ipcMain = new EventEmitter() as any - ipcMain.handle = (channel: string, handler: unknown) => electron.handlers.set(channel, handler) - ipcMain.removeHandler = (channel: string) => electron.handlers.delete(channel) - - return { - app: Object.assign(new EventEmitter(), { getPath: () => electron.directory, getAppPath: () => '/app' }), - ipcMain, - BrowserWindow: { - fromWebContents: (wc: unknown) => electron.windows.find(win => win.webContents === wc), - getFocusedWindow: () => electron.focused, - getAllWindows: () => electron.windows - }, - desktopCapturer: { - getSources: vi.fn(async () => [ - { id: 'window:42:0', thumbnail: { isEmpty: () => false, toPNG: () => new Uint8Array([1]) } } - ]) - }, - systemPreferences: { getMediaAccessStatus: () => electron.screenPermission }, - shell: { openExternal: vi.fn() } - } -}) - -import { app, desktopCapturer, ipcMain } from 'electron' - -import { installCommandScreenshot } from './command-screenshot' - -const cleanups: (() => void)[] = [] -afterEach(async () => { - cleanups.splice(0).forEach(fn => fn()) - await rm(electron.directory, { recursive: true, force: true }) - electron.windows = [] - electron.focused = null - electron.screenPermission = 'granted' - vi.clearAllMocks() -}) - -function window(id: number, url = 'http://127.0.0.1:5174/') { - const frame = { url } - - const wc = Object.assign(new EventEmitter(), { - id, - mainFrame: frame, - getURL: () => url, - isDestroyed: () => false, - send: vi.fn() - }) - - const win = { webContents: wc, isDestroyed: () => false } - electron.windows.push(win) - - return { win, event: { sender: wc, senderFrame: frame }, wc } -} - -async function setup() { - electron.directory = await mkdtemp(path.join(os.tmpdir(), 'hermes-screenshot-')) - cleanups.push(installCommandScreenshot({ rendererUrl: 'http://127.0.0.1:5174/' })) -} - -const call = (channel: string, event: unknown, ...args: unknown[]) => - electron.handlers.get(`hermes:screenshot:${channel}`)(event, ...args) - -describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge', () => { - it('persists opt-in, routes to the last focused subscribed window while backgrounded, and revokes on disable', async () => { - await setup() - const first = window(1) - const second = window(2) - expect(await call('settings:get', first.event)).toEqual({ enabled: false, state: 'disabled' }) - expect(native.start).not.toHaveBeenCalled() - ipcMain.emit('subscribe', first.event) // unrelated IPC grants nothing - ipcMain.emit('hermes:screenshot:subscribe', first.event, true) - ipcMain.emit('hermes:screenshot:subscribe', second.event, true) - app.emit('browser-window-focus', {}, second.win) - electron.focused = null - await call('settings:set', first.event, true) - expect(JSON.parse(await readFile(path.join(electron.directory, 'screenshot.json'), 'utf8'))).toEqual({ - enabled: true - }) - const [capture, status] = native.start.mock.calls.at(-1)! - status({ type: 'ready' }) - capture({ type: 'capture', windowId: 42, width: 600, height: 400 }) - const requests = second.wc.send.mock.calls.filter(([channel]) => channel === 'hermes:screenshot:request') - expect(requests).toHaveLength(1) - expect(first.wc.send.mock.calls.some(([channel]) => channel === 'hermes:screenshot:request')).toBe(false) - expect(await call('capture', first.event, requests[0]![1])).toEqual({ ok: false, reason: 'expired' }) - await call('settings:set', second.event, false) - expect(await call('capture', second.event, requests[0]![1])).toEqual({ ok: false, reason: 'expired' }) - expect(native.stop).toHaveBeenCalled() - - for (let i = 0; i < 3; i += 1) { - ipcMain.emit('hermes:screenshot:subscribe', second.event, false) - ipcMain.emit('hermes:screenshot:subscribe', second.event, true) - } - - expect(second.wc.listenerCount('destroyed')).toBe(1) - ipcMain.emit('hermes:screenshot:subscribe', second.event, false) - expect(second.wc.listenerCount('destroyed')).toBe(0) - }) - - it('rejects guest/foreign frames and reports denied screen access rather than ready', async () => { - await setup() - const trusted = window(3) - const foreign = window(4, 'https://example.org/') - await expect(call('settings:set', foreign.event, true)).rejects.toThrow() - await expect( - call('settings:set', { ...trusted.event, senderFrame: { url: 'https://example.org/' } }, true) - ).rejects.toThrow() - expect(native.start).not.toHaveBeenCalled() - electron.screenPermission = 'denied' - await call('settings:set', trusted.event, true) - native.start.mock.calls.at(-1)![1]({ type: 'ready' }) - expect(desktopCapturer.getSources).toHaveBeenCalledWith({ - types: ['window'], - thumbnailSize: { width: 1, height: 1 }, - fetchWindowIcons: false - }) - expect(await call('settings:get', trusted.event)).toEqual({ enabled: true, state: 'screen-permission' }) - }) -}) diff --git a/apps/desktop/electron/wsl-path-bridge-gate.test.ts b/apps/desktop/electron/wsl-path-bridge-gate.test.ts deleted file mode 100644 index 1b864f922e..0000000000 --- a/apps/desktop/electron/wsl-path-bridge-gate.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Windows-platform regression for the WSL path-bridge gate (#66433). - * - * The behavioural tests in wsl-path-bridge.test.ts prove the no-op contract - * (paths pass through unchanged when the bridge is inactive). This file goes - * one rung further: with `process.platform` stubbed to `win32` and - * `child_process.execFileSync` mocked, it proves the actual `wsl.exe` spawn is - * suppressed — not just that the return value looks right. - * - * Each test re-imports the module fresh (vi.resetModules) so IS_WINDOWS is - * re-evaluated against the stubbed platform. - */ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' - -const execFileSyncMock = vi.fn(() => 'Ubuntu\n') - -vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) - -describe('WSL bridge gate on Windows (#66433)', () => { - const realPlatform = process.platform - - beforeEach(() => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) - vi.resetModules() - execFileSyncMock.mockClear() - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) - }) - - test('wsl.exe IS probed for a POSIX path when the bridge is active (control)', async () => { - const { resolveLocalReadPath } = await import('./wsl-path-bridge') - resolveLocalReadPath('/home/ubuntu/project') - expect(execFileSyncMock).toHaveBeenCalled() - // Sanity: it really was wsl.exe, not some other binary. - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 1, - 'wsl.exe', - expect.arrayContaining(['-l', '-q']), - expect.anything() - ) - }) - - test('wsl.exe is NEVER probed when the bridge is inactive — even for POSIX paths', async () => { - const { resolveLocalReadPath, setWslBridgeActive } = await import('./wsl-path-bridge') - setWslBridgeActive(false) - // A POSIX path that WOULD trigger bridging (and the wsl.exe probe) when - // active — but with the bridge off, resolveDefaultWslDistro is never - // reached because resolveLocalReadPath returns before it. - const result = resolveLocalReadPath('/home/ubuntu/project') - expect(execFileSyncMock).not.toHaveBeenCalled() - expect(result).toBe('/home/ubuntu/project') - }) - - test('the picker default-path also skips the wsl.exe probe when inactive', async () => { - const { resolvePickerDefaultPath, setWslBridgeActive } = await import('./wsl-path-bridge') - setWslBridgeActive(false) - const result = resolvePickerDefaultPath('/home/ubuntu') - expect(execFileSyncMock).not.toHaveBeenCalled() - expect(result).toBe('/home/ubuntu') - }) - - test('re-enabling the bridge restores wsl.exe probing', async () => { - const { resolveLocalReadPath, setWslBridgeActive } = await import('./wsl-path-bridge') - setWslBridgeActive(false) - resolveLocalReadPath('/home/ubuntu/project') - expect(execFileSyncMock).not.toHaveBeenCalled() - - setWslBridgeActive(true) - execFileSyncMock.mockClear() - resolveLocalReadPath('/home/ubuntu/project') - expect(execFileSyncMock).toHaveBeenCalled() - }) -}) diff --git a/apps/desktop/electron/wsl-path-bridge-profile.test.ts b/apps/desktop/electron/wsl-path-bridge-profile.test.ts index 8f83e0fa00..f1119ea626 100644 --- a/apps/desktop/electron/wsl-path-bridge-profile.test.ts +++ b/apps/desktop/electron/wsl-path-bridge-profile.test.ts @@ -1,9 +1,8 @@ /** * Profile-scoped eligibility for the WSL path bridge (#66447). * - * The single-profile tests in wsl-path-bridge.test.ts and the Windows-platform - * gate tests in wsl-path-bridge-gate.test.ts cover the *what* (paths pass - * through unchanged when bridging is disabled) but not the *which profile*. The + * The single-profile tests in wsl-path-bridge.test.ts cover the *what* (paths + * pass through unchanged when bridging is disabled) but not the *which profile*. The * desktop is multi-profile: the renderer can swap the live gateway onto any * profile (primary or pool) without reloading the window — so the bridge * eligibility MUST be keyed off the **currently active profile's** backend diff --git a/tests/plugins/fixtures/kanban_markdown_sanitize_probe.js b/tests/plugins/fixtures/kanban_markdown_sanitize_probe.js deleted file mode 100644 index 66df53fa3a..0000000000 --- a/tests/plugins/fixtures/kanban_markdown_sanitize_probe.js +++ /dev/null @@ -1,66 +0,0 @@ -// Behavioral probe for the dashboard markdown XSS guard: extracts the markdown -// helpers + MarkdownBlock from the shipped bundle (no build step — the bundle IS -// the source) and runs them. Exits 0 and prints "PASS" when -// 1. sanitizeMarkdownHtml strips non-allowlisted tags, event-handler attributes -// and non-http(s)/mailto hrefs from raw HTML; -// 2. MarkdownBlock routes its HTML through the sanitizer before -// dangerouslySetInnerHTML (proved by swapping renderMarkdown for an identity -// function that passes raw HTML straight through); -// 3. ordinary markdown still renders (bold, safe links). -// Run via: node kanban_markdown_sanitize_probe.js -const fs = require("fs"); - -const src = fs.readFileSync(process.argv[2], "utf8"); -const start = src.indexOf("function escapeHtml("); -const blockStart = src.indexOf("function MarkdownBlock(", start); -if (start === -1 || blockStart === -1) { - console.error("markdown helpers / MarkdownBlock not found in bundle"); - process.exit(1); -} -const bodyStart = src.indexOf("{", blockStart); -let depth = 0; -let end = bodyStart; -for (; end < src.length; end++) { - if (src[end] === "{") depth++; - else if (src[end] === "}") { depth--; if (depth === 0) break; } -} -const code = src.slice(start, end + 1); - -const h = (tag, props, ...children) => ({ tag, props, children }); -eval(code); // sloppy-mode direct eval: function declarations land in this scope - -const failures = []; -const check = (cond, msg) => { if (!cond) failures.push(msg); }; -const DANGER = [/ { - for (const re of DANGER) check(!re.test(html), `${label}: ${re} survived in ${JSON.stringify(html)}`); -}; - -const RAW = - '' + - 'y' + - '

z

ok'; - -// 1. The sanitizer itself. -const cleaned = sanitizeMarkdownHtml(RAW); -assertClean(cleaned, "sanitizeMarkdownHtml"); -check(cleaned.includes("

z

"), `allowlisted

lost: ${cleaned}`); -check(cleaned.includes('href="https://ok.example/"'), `safe https href lost: ${cleaned}`); - -// 2. MarkdownBlock must sanitize whatever the renderer produces. -const realRender = renderMarkdown; -renderMarkdown = (s) => s; -const wired = MarkdownBlock({ source: RAW }).props.dangerouslySetInnerHTML.__html; -assertClean(wired, "MarkdownBlock(raw renderer)"); -renderMarkdown = realRender; - -// 3. Ordinary markdown still renders through the real renderer. -const md = MarkdownBlock({ source: "**bold** and [link](https://example.com)" }).props.dangerouslySetInnerHTML.__html; -check(md.includes("bold"), `bold lost: ${md}`); -check(md.includes('href="https://example.com"'), `link lost: ${md}`); - -if (failures.length) { - console.error(failures.join("\n")); - process.exit(1); -} -console.log("PASS"); diff --git a/tests/plugins/platforms/test_discord_voice_receive.py b/tests/plugins/platforms/test_discord_voice_receive.py deleted file mode 100644 index e993a55a98..0000000000 --- a/tests/plugins/platforms/test_discord_voice_receive.py +++ /dev/null @@ -1,761 +0,0 @@ -"""Integration tests for Discord voice channel audio flow. - -Uses real NaCl encryption and Opus codec (no mocks for crypto/codec). -Does NOT require a Discord connection — tests the VoiceReceiver -packet processing pipeline end-to-end. - -Requires: PyNaCl>=1.5.0, discord.py[voice] (opus codec) -""" - -import struct -import time -import pytest - - - -# Skip entire module if voice deps are missing -pytest.importorskip("nacl.secret", reason="PyNaCl required for voice integration tests") -discord = pytest.importorskip("discord", reason="discord.py required for voice integration tests") - -import nacl.secret - -try: - if not discord.opus.is_loaded(): - import ctypes.util - opus_path = ctypes.util.find_library("opus") - if not opus_path: - for p in ("/opt/homebrew/lib/libopus.dylib", "/usr/local/lib/libopus.dylib"): - import os - if os.path.isfile(p): - opus_path = p - break - if opus_path: - discord.opus.load_opus(opus_path) - OPUS_AVAILABLE = discord.opus.is_loaded() -except Exception: - OPUS_AVAILABLE = False - -from types import SimpleNamespace -from unittest.mock import MagicMock -from plugins.platforms.discord.adapter import VoiceReceiver - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _make_secret_key(): - """Generate a random 32-byte key.""" - import os - return os.urandom(32) - - -def _build_encrypted_rtp_packet(secret_key, opus_payload, ssrc=100, seq=1, timestamp=960): - """Build a real NaCl-encrypted RTP packet matching Discord's format. - - Format: RTP header (12 bytes) + encrypted(opus) + 4-byte nonce - Encryption: aead_xchacha20_poly1305 with RTP header as AAD. - """ - # RTP header: version=2, payload_type=0x78, no extension, no CSRC - header = struct.pack(">BBHII", 0x80, 0x78, seq, timestamp, ssrc) - - # Encrypt with NaCl AEAD - box = nacl.secret.Aead(secret_key) - nonce_counter = struct.pack(">I", seq) # 4-byte counter as nonce seed - # Full 24-byte nonce: counter in first 4 bytes, rest zeros - full_nonce = nonce_counter + b'\x00' * 20 - - enc_msg = box.encrypt(opus_payload, header, full_nonce) - ciphertext = enc_msg.ciphertext # without nonce prefix - - # Discord format: header + ciphertext + 4-byte nonce - return header + ciphertext + nonce_counter - - -def _build_padded_rtp_packet( - secret_key, opus_payload, pad_len, ssrc=100, seq=1, timestamp=960, - declared_pad_len=None, ext_words=0, -): - """Build a NaCl-encrypted RTP packet with the P bit set and padding appended. - - Per RFC 3550 §5.1, the last padding byte declares how many trailing bytes - (including itself) to discard. ``pad_len`` is the actual padding appended; - ``declared_pad_len`` lets a test forge a mismatched declared length to - exercise the validation path. ``ext_words`` > 0 also sets the X bit and - prepends a synthetic extension block (4-byte preamble in cleartext header, - ext_words*4 bytes of encrypted extension data prepended to the payload). - """ - if pad_len < 1: - raise ValueError("pad_len must be >= 1 (last byte includes itself)") - declared = pad_len if declared_pad_len is None else declared_pad_len - if declared < 0 or declared > 255: - raise ValueError("declared_pad_len must fit in one byte") - - has_extension = ext_words > 0 - first_byte = 0xA0 | (0x10 if has_extension else 0) # V=2, P=1, [X=?], CC=0 - fixed_header = struct.pack(">BBHII", first_byte, 0x78, seq, timestamp, ssrc) - if has_extension: - # 4-byte extension preamble: 2 bytes "defined by profile" + 2 bytes length-in-words - ext_preamble = struct.pack(">HH", 0xBEDE, ext_words) - header = fixed_header + ext_preamble - ext_data = b"\xab" * (ext_words * 4) - else: - header = fixed_header - ext_data = b"" - - padding = b"\x00" * (pad_len - 1) + bytes([declared]) - plaintext = ext_data + opus_payload + padding - - box = nacl.secret.Aead(secret_key) - nonce_counter = struct.pack(">I", seq) - full_nonce = nonce_counter + b"\x00" * 20 - - enc_msg = box.encrypt(plaintext, header, full_nonce) - ciphertext = enc_msg.ciphertext - - return header + ciphertext + nonce_counter - - -def _make_voice_receiver(secret_key, dave_session=None, bot_ssrc=9999, - allowed_user_ids=None, members=None): - """Create a VoiceReceiver with real secret key.""" - vc = MagicMock() - vc._connection.secret_key = list(secret_key) - vc._connection.dave_session = dave_session - vc._connection.ssrc = bot_ssrc - vc._connection.add_socket_listener = MagicMock() - vc._connection.remove_socket_listener = MagicMock() - vc._connection.hook = None - vc.user = SimpleNamespace(id=bot_ssrc) - vc.channel = MagicMock() - vc.channel.members = members or [] - receiver = VoiceReceiver(vc, allowed_user_ids=allowed_user_ids) - receiver.start() - return receiver - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestRealNaClDecrypt: - """End-to-end: real NaCl encrypt → _on_packet decrypt → buffer.""" - - def test_valid_encrypted_packet_buffered(self): - """Real NaCl encrypted packet → decrypted → buffered.""" - key = _make_secret_key() - opus_silence = b'\xf8\xff\xfe' - receiver = _make_voice_receiver(key) - - packet = _build_encrypted_rtp_packet(key, opus_silence, ssrc=100) - receiver._on_packet(packet) - - assert 100 in receiver._buffers - assert len(receiver._buffers[100]) > 0 - - def test_wrong_key_packet_dropped(self): - """Packet encrypted with wrong key → NaCl fails → not buffered.""" - real_key = _make_secret_key() - wrong_key = _make_secret_key() - opus_silence = b'\xf8\xff\xfe' - receiver = _make_voice_receiver(real_key) - - packet = _build_encrypted_rtp_packet(wrong_key, opus_silence, ssrc=100) - receiver._on_packet(packet) - - assert len(receiver._buffers.get(100, b"")) == 0 - - def test_bot_ssrc_ignored(self): - """Packet from bot's own SSRC → ignored.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key, bot_ssrc=9999) - - packet = _build_encrypted_rtp_packet(key, b'\xf8\xff\xfe', ssrc=9999) - receiver._on_packet(packet) - - assert len(receiver._buffers) == 0 - - def test_multiple_packets_accumulate(self): - """Multiple valid packets → buffer grows.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - - for seq in range(1, 6): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - assert 100 in receiver._buffers - buf_size = len(receiver._buffers[100]) - assert buf_size > 0, "Multiple packets should accumulate in buffer" - - def test_different_ssrcs_separate_buffers(self): - """Packets from different SSRCs → separate buffers.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - - for ssrc in [100, 200, 300]: - packet = _build_encrypted_rtp_packet(key, b'\xf8\xff\xfe', ssrc=ssrc) - receiver._on_packet(packet) - - assert len(receiver._buffers) == 3 - for ssrc in [100, 200, 300]: - assert ssrc in receiver._buffers - - -class TestRealNaClWithDAVE: - """NaCl decrypt + DAVE passthrough scenarios with real crypto.""" - - def test_dave_unknown_ssrc_passthrough(self): - """DAVE enabled but SSRC unknown → skip DAVE, buffer audio.""" - key = _make_secret_key() - dave = MagicMock() # DAVE session present but SSRC not mapped - receiver = _make_voice_receiver(key, dave_session=dave) - - packet = _build_encrypted_rtp_packet(key, b'\xf8\xff\xfe', ssrc=100) - receiver._on_packet(packet) - - # DAVE decrypt not called (SSRC unknown) - dave.decrypt.assert_not_called() - # Audio still buffered via passthrough - assert 100 in receiver._buffers - assert len(receiver._buffers[100]) > 0 - - def test_dave_unencrypted_error_passthrough(self): - """DAVE raises 'Unencrypted' → use NaCl-decrypted data as-is.""" - key = _make_secret_key() - dave = MagicMock() - dave.decrypt.side_effect = Exception( - "DecryptionFailed(UnencryptedWhenPassthroughDisabled)" - ) - receiver = _make_voice_receiver(key, dave_session=dave) - receiver.map_ssrc(100, 42) - - packet = _build_encrypted_rtp_packet(key, b'\xf8\xff\xfe', ssrc=100) - receiver._on_packet(packet) - - # DAVE was called but failed → passthrough - dave.decrypt.assert_called_once() - assert 100 in receiver._buffers - assert len(receiver._buffers[100]) > 0 - - def test_dave_real_error_drops(self): - """DAVE raises non-Unencrypted error → packet dropped.""" - key = _make_secret_key() - dave = MagicMock() - dave.decrypt.side_effect = Exception("KeyRotationFailed") - receiver = _make_voice_receiver(key, dave_session=dave) - receiver.map_ssrc(100, 42) - - packet = _build_encrypted_rtp_packet(key, b'\xf8\xff\xfe', ssrc=100) - receiver._on_packet(packet) - - assert len(receiver._buffers.get(100, b"")) == 0 - - -class TestRTPPaddingStrip: - """RFC 3550 §5.1 — strip RTP padding before DAVE/Opus decode.""" - - def test_padded_packet_stripped_and_buffered(self): - """P bit set → trailing padding stripped → opus payload decoded.""" - key = _make_secret_key() - opus_silence = b"\xf8\xff\xfe" - receiver = _make_voice_receiver(key) - - # 5 bytes of padding (4 zeros + count byte = 5) - packet = _build_padded_rtp_packet(key, opus_silence, pad_len=5, ssrc=100) - receiver._on_packet(packet) - - assert 100 in receiver._buffers - assert len(receiver._buffers[100]) > 0 - - def test_padded_packet_matches_unpadded_output(self): - """Same opus payload with/without padding → same decoded PCM.""" - key = _make_secret_key() - opus_silence = b"\xf8\xff\xfe" - - recv_plain = _make_voice_receiver(key) - recv_plain._on_packet( - _build_encrypted_rtp_packet(key, opus_silence, ssrc=100) - ) - - recv_padded = _make_voice_receiver(key) - recv_padded._on_packet( - _build_padded_rtp_packet(key, opus_silence, pad_len=7, ssrc=100) - ) - - assert bytes(recv_plain._buffers[100]) == bytes(recv_padded._buffers[100]) - - def test_padding_with_dave_passthrough(self): - """Padding stripped before DAVE → passthrough buffers cleanly.""" - key = _make_secret_key() - opus_silence = b"\xf8\xff\xfe" - dave = MagicMock() # SSRC unmapped → DAVE skipped, passthrough used - receiver = _make_voice_receiver(key, dave_session=dave) - - packet = _build_padded_rtp_packet(key, opus_silence, pad_len=4, ssrc=100) - receiver._on_packet(packet) - - dave.decrypt.assert_not_called() - assert 100 in receiver._buffers - assert len(receiver._buffers[100]) > 0 - - def test_invalid_padding_length_zero_dropped(self): - """Declared pad_len=0 is invalid (RFC requires count includes itself).""" - key = _make_secret_key() - opus_silence = b"\xf8\xff\xfe" - receiver = _make_voice_receiver(key) - - packet = _build_padded_rtp_packet( - key, opus_silence, pad_len=4, declared_pad_len=0, ssrc=100 - ) - receiver._on_packet(packet) - - assert len(receiver._buffers.get(100, b"")) == 0 - - def test_invalid_padding_length_overflow_dropped(self): - """Declared pad_len > payload size → packet dropped.""" - key = _make_secret_key() - opus_silence = b"\xf8\xff\xfe" - receiver = _make_voice_receiver(key) - - packet = _build_padded_rtp_packet( - key, opus_silence, pad_len=4, declared_pad_len=255, ssrc=100 - ) - receiver._on_packet(packet) - - assert len(receiver._buffers.get(100, b"")) == 0 - - def test_padding_consuming_entire_payload_dropped(self): - """Padding consumes entire payload → no opus data → dropped.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - - # Empty opus payload, 6 bytes of padding (count byte declares 6) - packet = _build_padded_rtp_packet(key, b"", pad_len=6, ssrc=100) - receiver._on_packet(packet) - - assert len(receiver._buffers.get(100, b"")) == 0 - - def test_padding_with_extension_stripped_correctly(self): - """X+P bits both set → strip extension from start, padding from end.""" - key = _make_secret_key() - opus_silence = b"\xf8\xff\xfe" - - # Same opus payload sent two ways: plain, and with both ext+padding - recv_plain = _make_voice_receiver(key) - recv_plain._on_packet( - _build_encrypted_rtp_packet(key, opus_silence, ssrc=100) - ) - - recv_ext_pad = _make_voice_receiver(key) - recv_ext_pad._on_packet( - _build_padded_rtp_packet( - key, opus_silence, pad_len=5, ext_words=2, ssrc=100 - ) - ) - - # Both must yield identical decoded PCM — ext data and padding both - # stripped before opus decode. - assert bytes(recv_plain._buffers[100]) == bytes(recv_ext_pad._buffers[100]) - - -class TestFullVoiceFlow: - """End-to-end: encrypt → receive → buffer → silence detect → complete.""" - - def test_single_utterance_flow(self): - """Encrypt packets → buffer → silence → check_silence returns utterance.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - receiver.map_ssrc(100, 42) - - # Send enough packets to exceed MIN_SPEECH_DURATION (0.5s) - # At 48kHz stereo 16-bit, each Opus silence frame decodes to ~3840 bytes - # Need 96000 bytes = ~25 frames - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - # Simulate silence by setting last_packet_time in the past - receiver._last_packet_time[100] = time.monotonic() - 3.0 - - completed = receiver.check_silence() - assert len(completed) == 1 - user_id, pcm_data = completed[0] - assert user_id == 42 - assert len(pcm_data) > 0 - - def test_utterance_with_ssrc_automap(self): - """No SPEAKING event → auto-map sole allowed user → utterance processed.""" - key = _make_secret_key() - members = [ - SimpleNamespace(id=9999, name="Bot"), - SimpleNamespace(id=42, name="Alice"), - ] - receiver = _make_voice_receiver( - key, allowed_user_ids={"42"}, members=members - ) - # No map_ssrc call — simulating missing SPEAKING event - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - receiver._last_packet_time[100] = time.monotonic() - 3.0 - - completed = receiver.check_silence() - assert len(completed) == 1 - assert completed[0][0] == 42 # auto-mapped to sole allowed user - - def test_pause_blocks_during_playback(self): - """Pause receiver → packets ignored → resume → packets accepted.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - - # Pause (echo prevention during TTS playback) - receiver.pause() - packet = _build_encrypted_rtp_packet(key, b'\xf8\xff\xfe', ssrc=100) - receiver._on_packet(packet) - assert len(receiver._buffers.get(100, b"")) == 0 - - # Resume - receiver.resume() - receiver._on_packet(packet) - assert 100 in receiver._buffers - assert len(receiver._buffers[100]) > 0 - - def test_corrupted_packet_ignored(self): - """Corrupted/truncated packet → silently ignored.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - - # Too short - receiver._on_packet(b"\x00" * 5) - assert len(receiver._buffers) == 0 - - # Wrong RTP version - bad_header = struct.pack(">BBHII", 0x00, 0x78, 1, 960, 100) - receiver._on_packet(bad_header + b"\x00" * 20) - assert len(receiver._buffers) == 0 - - # Wrong payload type - bad_pt = struct.pack(">BBHII", 0x80, 0x00, 1, 960, 100) - receiver._on_packet(bad_pt + b"\x00" * 20) - assert len(receiver._buffers) == 0 - - def test_stop_cleans_everything(self): - """stop() clears all state cleanly.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - receiver.map_ssrc(100, 42) - - for seq in range(1, 10): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - assert len(receiver._buffers[100]) > 0 - - receiver.stop() - assert receiver._running is False - assert len(receiver._buffers) == 0 - assert len(receiver._ssrc_to_user) == 0 - assert len(receiver._decoders) == 0 - - -class TestSPEAKINGHook: - """SPEAKING event hook correctly maps SSRC to user_id.""" - - def test_speaking_hook_installed(self): - """start() installs speaking hook on connection.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - conn = receiver._vc._connection - # hook should be set (wrapped) - assert conn.hook is not None - - def test_map_ssrc_via_speaking(self): - """SPEAKING op 5 event maps SSRC to user_id.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - receiver.map_ssrc(500, 12345) - assert receiver._ssrc_to_user[500] == 12345 - - def test_map_ssrc_overwrites(self): - """New SPEAKING event for same SSRC overwrites old mapping.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - receiver.map_ssrc(500, 111) - receiver.map_ssrc(500, 222) - assert receiver._ssrc_to_user[500] == 222 - - def test_speaking_mapped_audio_processed(self): - """After SSRC is mapped, audio from that SSRC gets correct user_id.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - receiver.map_ssrc(100, 42) - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - receiver._last_packet_time[100] = time.monotonic() - 3.0 - completed = receiver.check_silence() - assert len(completed) == 1 - assert completed[0][0] == 42 - - -class TestAuthFiltering: - """Only allowed users' audio should be processed.""" - - def test_allowed_user_audio_processed(self): - """Allowed user's utterance is returned by check_silence.""" - key = _make_secret_key() - members = [ - SimpleNamespace(id=9999, name="Bot"), - SimpleNamespace(id=42, name="Alice"), - ] - receiver = _make_voice_receiver( - key, allowed_user_ids={"42"}, members=members, - ) - receiver.map_ssrc(100, 42) - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - receiver._last_packet_time[100] = time.monotonic() - 3.0 - completed = receiver.check_silence() - assert len(completed) == 1 - assert completed[0][0] == 42 - - def test_automap_rejects_unallowed_user(self): - """Auto-map refuses to map SSRC to user not in allowed list.""" - key = _make_secret_key() - members = [ - SimpleNamespace(id=9999, name="Bot"), - SimpleNamespace(id=42, name="Alice"), - ] - receiver = _make_voice_receiver( - key, allowed_user_ids={"99"}, # Alice not allowed - members=members, - ) - # No map_ssrc — SSRC unknown, auto-map should reject - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - receiver._last_packet_time[100] = time.monotonic() - 3.0 - completed = receiver.check_silence() - assert len(completed) == 0 - - def test_empty_allowlist_allows_all(self): - """Empty allowed_user_ids means no restriction.""" - key = _make_secret_key() - members = [ - SimpleNamespace(id=9999, name="Bot"), - SimpleNamespace(id=42, name="Alice"), - ] - receiver = _make_voice_receiver( - key, allowed_user_ids=None, members=members, - ) - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - receiver._last_packet_time[100] = time.monotonic() - 3.0 - completed = receiver.check_silence() - # Auto-mapped to sole non-bot member - assert len(completed) == 1 - assert completed[0][0] == 42 - - -class TestRejoinFlow: - """Leave and rejoin: state cleanup and fresh receiver.""" - - def test_stop_then_new_receiver_clean_state(self): - """After stop(), a new receiver starts with empty state.""" - key = _make_secret_key() - receiver1 = _make_voice_receiver(key) - receiver1.map_ssrc(100, 42) - - for seq in range(1, 10): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver1._on_packet(packet) - - assert len(receiver1._buffers[100]) > 0 - receiver1.stop() - - # New receiver (simulates rejoin) - receiver2 = _make_voice_receiver(key) - assert len(receiver2._buffers) == 0 - assert len(receiver2._ssrc_to_user) == 0 - assert len(receiver2._decoders) == 0 - - def test_rejoin_new_ssrc_works(self): - """After rejoin, user may get new SSRC — still works.""" - key = _make_secret_key() - receiver1 = _make_voice_receiver(key) - receiver1.map_ssrc(100, 42) # old SSRC - receiver1.stop() - - receiver2 = _make_voice_receiver(key) - receiver2.map_ssrc(200, 42) # new SSRC after rejoin - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=200, seq=seq, timestamp=960 * seq - ) - receiver2._on_packet(packet) - - receiver2._last_packet_time[200] = time.monotonic() - 3.0 - completed = receiver2.check_silence() - assert len(completed) == 1 - assert completed[0][0] == 42 - - def test_rejoin_without_speaking_event_automap(self): - """Rejoin without SPEAKING event — auto-map sole allowed user.""" - key = _make_secret_key() - members = [ - SimpleNamespace(id=9999, name="Bot"), - SimpleNamespace(id=42, name="Alice"), - ] - - # First session - receiver1 = _make_voice_receiver( - key, allowed_user_ids={"42"}, members=members, - ) - receiver1.stop() - - # Rejoin — new key (Discord may assign new secret_key) - new_key = _make_secret_key() - receiver2 = _make_voice_receiver( - new_key, allowed_user_ids={"42"}, members=members, - ) - # No map_ssrc — simulating missing SPEAKING event - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - new_key, b'\xf8\xff\xfe', ssrc=300, seq=seq, timestamp=960 * seq - ) - receiver2._on_packet(packet) - - receiver2._last_packet_time[300] = time.monotonic() - 3.0 - completed = receiver2.check_silence() - assert len(completed) == 1 - assert completed[0][0] == 42 - - -class TestMultiGuildIsolation: - """Each guild has independent voice state.""" - - def test_separate_receivers_independent(self): - """Two receivers (different guilds) don't interfere.""" - key1 = _make_secret_key() - key2 = _make_secret_key() - - receiver1 = _make_voice_receiver(key1, bot_ssrc=1111) - receiver2 = _make_voice_receiver(key2, bot_ssrc=2222) - - receiver1.map_ssrc(100, 42) - receiver2.map_ssrc(200, 99) - - # Send to receiver1 - for seq in range(1, 10): - packet = _build_encrypted_rtp_packet( - key1, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver1._on_packet(packet) - - # receiver2 should be empty - assert len(receiver2._buffers) == 0 - assert 100 in receiver1._buffers - - def test_stop_one_doesnt_affect_other(self): - """Stopping one receiver doesn't affect another.""" - key1 = _make_secret_key() - key2 = _make_secret_key() - - receiver1 = _make_voice_receiver(key1) - receiver2 = _make_voice_receiver(key2) - - receiver1.map_ssrc(100, 42) - receiver2.map_ssrc(200, 99) - - for seq in range(1, 10): - packet = _build_encrypted_rtp_packet( - key2, b'\xf8\xff\xfe', ssrc=200, seq=seq, timestamp=960 * seq - ) - receiver2._on_packet(packet) - - receiver1.stop() - - # receiver2 still has data - assert receiver2._running is True - assert len(receiver2._buffers[200]) > 0 - - -class TestEchoPreventionFlow: - """Receiver pause/resume during TTS playback prevents echo.""" - - def test_audio_during_pause_ignored(self): - """Audio arriving while paused is completely ignored.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - receiver.map_ssrc(100, 42) - receiver.pause() - - for seq in range(1, 30): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - assert len(receiver._buffers.get(100, b"")) == 0 - - def test_audio_after_resume_processed(self): - """Audio arriving after resume is processed normally.""" - key = _make_secret_key() - receiver = _make_voice_receiver(key) - receiver.map_ssrc(100, 42) - - # Pause → send packets → resume → send more packets - receiver.pause() - for seq in range(1, 5): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - assert len(receiver._buffers.get(100, b"")) == 0 - - receiver.resume() - for seq in range(5, 35): - packet = _build_encrypted_rtp_packet( - key, b'\xf8\xff\xfe', ssrc=100, seq=seq, timestamp=960 * seq - ) - receiver._on_packet(packet) - - assert len(receiver._buffers[100]) > 0 - receiver._last_packet_time[100] = time.monotonic() - 3.0 - completed = receiver.check_silence() - assert len(completed) == 1 - assert completed[0][0] == 42 diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index b0c77f02fb..0fb37f46ae 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -179,26 +179,6 @@ def test_tenant_filter(client): assert total == 1 -def test_dashboard_markdown_html_is_sanitized_before_render(): - """Task markdown reaches ``dangerouslySetInnerHTML``, so the rendered HTML must - pass through ``sanitizeMarkdownHtml`` (tag allowlist, no event handlers, only - http(s)/mailto hrefs). Runs the real bundle functions under node — including a - raw-HTML renderer swap that proves MarkdownBlock applies the sanitizer — rather - than substring-matching the bundle text. - """ - node = shutil.which("node") - if not node: - pytest.skip("node not available") - bundle = Path(__file__).resolve().parents[2] / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" - probe = Path(__file__).parent / "fixtures" / "kanban_markdown_sanitize_probe.js" - result = subprocess.run( - [node, str(probe), str(bundle)], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, - ) - assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" - assert "PASS" in result.stdout - - # ---------------------------------------------------------------------------