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:
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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` })
|
||||
})
|
||||
})
|
||||
@@ -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` })
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user