test: drop restored tests that run in no CI lane or read the bundle text

These came back in #120220 but exercise nothing in CI, so they are not
restored coverage:

- 12 Playwright specs under apps/desktop/e2e: the Desktop E2E job is
  `if: false` in ci.yaml, and the new core lane (#120287) only runs
  apps/desktop/e2e/core/*.spec.ts. Dropped rather than left for that lane.
- tests/plugins/platforms/test_discord_voice_receive.py: skips on the
  nacl/discord import (discord.py[voice] is only in the messaging extra CI
  does not install) and copied the production opus loader.
- electron/command-screenshot.test.ts: darwin-only; the JS lane is Linux.
- electron/wsl-path-bridge-gate.test.ts: faked process.platform = win32.
- test_kanban_dashboard_plugin markdown sanitiser case + probe fixture:
  sliced function text out of dist/index.js by brace counting and eval'd it.
  The plugin exposes no seam to reach MarkdownBlock behaviourally.
This commit is contained in:
teknium1
2026-09-23 08:35:24 -07:00
committed by Teknium
parent 76e08ebea5
commit 36bc4bb210
18 changed files with 2 additions and 2938 deletions

View File

@@ -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<void> {
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<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
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<void> {
const dir = path.join(hermesHome, 'profiles', name)
fs.mkdirSync(dir, { recursive: true })
writeMockProviderConfig(dir, mockUrl)
writeEnvFile(dir)
const builder = await RealSessionBuilder.start(dir)
try {
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
} finally {
await builder.close()
}
}
/** Every tab caption in the main strip (the main `workspace` tab + tiles). */
function mainStripTabTitles(page: Page): Promise<string[]> {
return page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('[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)
})

View File

@@ -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<void> {
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<void> {
const tab = page
.getByRole('button', { name: /^sessions$/i })
.or(page.getByRole('tab', { name: /^sessions$/i }))
.first()
await tab.click()
}
async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
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<string> {
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<void> {
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<HTMLElement>('.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<HTMLElement>('span')].find(s => /^next/i.test(s.textContent ?? ''))!
const title = button.querySelector<HTMLElement>('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 })
})
})

View File

@@ -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<void> {
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<void> {
await page
.getByText(/Waking up/i)
.first()
.waitFor({ state: 'hidden', timeout })
.catch(() => undefined)
await page.waitForTimeout(500)
}
async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
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<void> {
const dir = path.join(hermesHome, 'profiles', name)
fs.mkdirSync(dir, { recursive: true })
writeMockProviderConfig(dir, mockUrl)
writeEnvFile(dir)
const builder = await RealSessionBuilder.start(dir)
try {
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
} finally {
await builder.close()
}
}
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')
})

View File

@@ -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<void> {
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<void> {
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<HTMLElement>('[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)
})

View File

@@ -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<void>
}
/** 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<number> {
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<RemoteGateway> {
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<ReturnType<typeof startMockServer>>
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` })
})
})

View File

@@ -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<void> {
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<void> {
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<string, any>)
.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` })
})

View File

@@ -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<void> {
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<void> {
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()
})

View File

@@ -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 <bot>", 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<void> {
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<void> {
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')
})

View File

@@ -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<void> }
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()
}
})
}

View File

@@ -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<HTMLElement>('[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<void>(resolve => requestAnimationFrame(() => resolve()))
}
window.dispatchEvent(
new PointerEvent('pointerup', {
...pointer,
buttons: 0,
clientX: currentX,
clientY: y,
}),
)
await new Promise<void>(resolve => setTimeout(resolve, 350))
await new Promise<void>(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)
})

View File

@@ -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<void> {
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<void> {
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()
})

View File

@@ -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<number> {
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<void> {
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<void> {
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)
})

View File

@@ -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' })
})
})

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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 <path-to-bundle>
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 = [/<img/i, /<script/i, /<iframe/i, /<svg/i, /\son\w+\s*=/i, /javascript:/i];
const assertClean = (html, label) => {
for (const re of DANGER) check(!re.test(html), `${label}: ${re} survived in ${JSON.stringify(html)}`);
};
const RAW =
'<img src=x onerror=alert(1)><script>alert(1)</script><iframe src="https://evil"></iframe>' +
'<svg onload=alert(1)></svg><a href="javascript:alert(1)" onclick="steal()">y</a>' +
'<p onmouseover="steal()">z</p><a href="https://ok.example/">ok</a>';
// 1. The sanitizer itself.
const cleaned = sanitizeMarkdownHtml(RAW);
assertClean(cleaned, "sanitizeMarkdownHtml");
check(cleaned.includes("<p>z</p>"), `allowlisted <p> 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("<strong>bold</strong>"), `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");

View File

@@ -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

View File

@@ -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
# ---------------------------------------------------------------------------