Files
hermes-agent/apps/desktop/scripts/tasks-scroll.test.mjs
ethernet 6756d11b5f fix(pm): ship full chromium without headless shell
Full Chromium serves both headed and headless sessions. The separate
shell duplicates the browser payload and is not needed for either mode.

Remove the shell from PM and Docker. Select the managed Chromium
executable for agent-browser and the full Chromium channel for direct
Playwright callers. Route setup through PM and remove retired packages
from cached bundle stores without changing the user's tool store.

Update signing, architecture checks, launch probes and install guidance.
Leave llama packages and Docker archive cleanup unchanged.

Verification:
- Real agent-browser navigation, clicks, DOM reads and screenshots pass
  in headed and headless modes with the same Chromium executable.
- The direct Playwright doctor probe passes.
- Focused Python and desktop packaging tests pass, as do six Docker
  checks and both real-browser task-scroll tests.
- The built linux/amd64 image is 1.393 GB compressed, 223.6 MB smaller.
- The broader PM suite and two unrelated setup tests still fail.
  Those failures reproduce on unchanged HEAD.
- Five updated eval scripts parse; their full scenarios were not run.
2026-09-10 16:08:03 -04:00

93 lines
4.3 KiB
JavaScript

import assert from 'node:assert/strict'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { after, before, test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { createServer } from 'vite'
const app = fileURLToPath(new URL('../', import.meta.url))
const scratch = await mkdtemp(path.join(tmpdir(), 'hermes-task-scroll-'))
const output = process.env.TASK_SCROLL_OUTPUT ?? scratch
let server
let browser
let url
before(async () => {
await mkdir(output, { recursive: true })
console.log(`Task-scroll evidence: ${output}`)
// The runner loader avoids writing config bundles to shared node_modules.
globalThis.__dirname = app
server = await createServer({
root: app,
configFile: path.join(app, 'vite.config.ts'),
configLoader: 'runner',
cacheDir: path.join(scratch, 'node_modules/.vite'),
server: { host: '127.0.0.1', port: 18120, strictPort: true },
optimizeDeps: { entries: ['scripts/fixtures/tasks-scroll.html'] }
})
await server.listen()
url = 'http://127.0.0.1:18120/scripts/fixtures/tasks-scroll.html'
browser = await chromium.launch({ channel: 'chromium', executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, headless: true, args: ['--no-sandbox'] })
})
after(async () => {
await browser?.close()
await server?.close()
if (output !== scratch) await rm(scratch, { recursive: true, force: true })
})
for (const count of [14, 20]) {
test(`${count} tasks: wheel reveals the final row above the composer`, async () => {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, reducedMotion: 'reduce' })
const errors = []
page.on('pageerror', error => errors.push(error.message))
try {
await page.goto(`${url}?tasks=${count}`, { timeout: 120_000 })
const stack = page.locator('[data-slot="composer-status-stack"]')
const last = page.getByText(`Phase ${count}:`, { exact: false })
await last.waitFor()
await page.evaluate(() => document.fonts.ready)
const measure = () => stack.evaluate(el => {
const rect = el.getBoundingClientRect()
return { height: el.clientHeight, scrollHeight: el.scrollHeight, top: el.scrollTop,
bounds: { top: rect.top, bottom: rect.bottom }, cardHeight: el.firstElementChild.getBoundingClientRect().height }
})
const before = await measure()
await stack.hover()
await page.mouse.wheel(0, 10000)
// Wheel is asynchronous; wait for animation frames to deliver it, without
// scrollIntoView/locator.click silently repairing the broken layout.
await page.waitForTimeout(400)
const after = await measure()
const tail = await last.evaluate(el => {
const r = el.getBoundingClientRect()
return { top: r.top, bottom: r.bottom,
hit: el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }
})
const composer = await page.locator('[data-slot="fixture-composer"]').boundingBox()
const receipt = { count, before, after, tail, composer, errors }
console.log(JSON.stringify(receipt))
await writeFile(path.join(output, `tasks-${count}.json`), JSON.stringify(receipt, null, 2))
await page.screenshot({ path: path.join(output, `tasks-${count}.png`) })
assert.deepEqual(errors, [])
assert.ok(after.scrollHeight > after.height && after.top > before.top, 'Long task list must scroll')
assert.ok(tail.top >= after.bounds.top && tail.bottom <= after.bounds.bottom, 'Final row must be fully inside viewport')
assert.ok(tail.bottom <= composer.y && tail.hit, 'Final row must not be occluded by composer or card')
// Collapse/expand must recover the same bounded, scrollable list.
await page.mouse.wheel(0, -10000)
await page.waitForTimeout(400)
const header = stack.getByRole('button').first()
await header.click()
assert.ok((await measure()).height < before.height, 'Collapsed list must release its height')
await header.click()
await stack.hover()
await page.mouse.wheel(0, 10000)
await page.waitForTimeout(400)
assert.ok((await measure()).top > 0, 'Re-expanded list must remain scrollable')
} finally {
await page.close()
}
})
}