Compare commits

...
1 Commits
Author SHA1 Message Date
ethernet 7495155223 test(desktop): run visual E2E offscreen on macOS
Use Electron offscreen rendering for macOS visual E2E runs so they do not open windows on the user's desktop. Keep Cage for Linux, build before the visual suite, and preserve the normal headed test command.
2026-07-22 18:32:28 -04:00
6 changed files with 59 additions and 13 deletions
+2 -1
View File
@@ -33,6 +33,7 @@ import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const E2E_HEADLESS = process.env.HERMES_DESKTOP_E2E_HEADLESS === '1'
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
@@ -609,7 +610,7 @@ export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFi
// window, but the DOM can be ready before that fires. Poll until the
// window is actually visible so interactions (click, screenshot) don't
// hit a hidden surface.
if (app) {
if (app && !E2E_HEADLESS) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
+12 -8
View File
@@ -215,6 +215,10 @@ if (USER_DATA_OVERRIDE) {
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged || Boolean(process.env.HERMES_DESKTOP_IS_PACKAGED)
const IS_MAC = process.platform === 'darwin'
// Linux E2E uses Cage. macOS has no equivalent isolated compositor, so the
// visual runner opts into Electron's offscreen renderer instead. This is test
// infrastructure only: normal desktop launches remain headed.
const E2E_HEADLESS = process.env.HERMES_DESKTOP_E2E_HEADLESS === '1'
const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
// Truthful macOS kernel major (Tahoe = 25). Product version lies (16 vs 26) per
@@ -8093,7 +8097,7 @@ function focusWindow(win) {
win.restore()
}
if (!win.isVisible()) {
if (!E2E_HEADLESS && !win.isVisible()) {
win.show()
}
@@ -8123,7 +8127,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
webPreferences: chatWindowWebPreferences(PRELOAD_PATH, { offscreen: E2E_HEADLESS })
})
if (IS_MAC) {
@@ -8131,7 +8135,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) {
if (!E2E_HEADLESS && !win.isDestroyed()) {
win.show()
}
})
@@ -8200,7 +8204,7 @@ function createInstanceWindow() {
icon,
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
webPreferences: chatWindowWebPreferences(PRELOAD_PATH, { offscreen: E2E_HEADLESS })
})
instanceWindows.add(win)
@@ -8210,7 +8214,7 @@ function createInstanceWindow() {
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) {
if (!E2E_HEADLESS && !win.isDestroyed()) {
win.show()
}
})
@@ -8405,7 +8409,7 @@ function createWindow() {
// both keep `backgroundThrottling: false` — the chat transcript streams via
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
// windows, stalling the live answer until refocus. See session-windows.ts.
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
webPreferences: chatWindowWebPreferences(PRELOAD_PATH, { offscreen: E2E_HEADLESS })
})
if (IS_MAC) {
@@ -8432,7 +8436,7 @@ function createWindow() {
}
mainWindow.once('ready-to-show', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (!E2E_HEADLESS && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show()
}
@@ -8462,7 +8466,7 @@ function createWindow() {
// Under Playright testing, instantly show the window.
// `ready-to-show` doesn't fire in some testing envs.
if (process.env.TEST_WORKER_INDEX !== undefined) {
if (!E2E_HEADLESS && process.env.TEST_WORKER_INDEX !== undefined) {
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
mainWindow.show()
}
@@ -208,3 +208,8 @@ test('chatWindowWebPreferences passes the preload path through and keeps the har
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})
test('chatWindowWebPreferences enables offscreen rendering only when requested', () => {
assert.equal(chatWindowWebPreferences('/tmp/preload.cjs').offscreen, false)
assert.equal(chatWindowWebPreferences('/tmp/preload.cjs', { offscreen: true }).offscreen, true)
})
+6 -2
View File
@@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath: string) {
function chatWindowWebPreferences(preloadPath: string, { offscreen = false }: { offscreen?: boolean } = {}) {
return {
preload: preloadPath,
contextIsolation: true,
@@ -29,7 +29,11 @@ function chatWindowWebPreferences(preloadPath: string) {
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
backgroundThrottling: false,
// macOS has no Cage-equivalent compositor. Playwright's offscreen renderer
// keeps desktop E2E windows out of the user's workspace while preserving a
// real Chromium surface for interaction and screenshots.
offscreen
}
}
+2 -2
View File
@@ -56,8 +56,8 @@
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
"test:e2e": "npm run clean:e2e && playwright test e2e/",
"test:e2e:visual": "npm run clean:e2e && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "npm run clean:e2e && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- playwright test e2e/ --reporter=list --update-snapshots"
"test:e2e:visual": "npm run build && node scripts/run-e2e-visual.mjs",
"test:e2e:update-snapshots": "npm run build && node scripts/run-e2e-visual.mjs --update-snapshots"
},
"dependencies": {
"@assistant-ui/react": "^0.14.23",
+32
View File
@@ -0,0 +1,32 @@
import { spawnSync } from 'node:child_process'
const env = { ...process.env }
const extraArgs = process.argv.slice(2)
const testPaths = extraArgs.filter(arg => !arg.startsWith('-'))
const playwrightArgs = [
'test',
...(testPaths.length > 0 ? testPaths : ['e2e/']),
'--reporter=list',
...extraArgs.filter(arg => arg.startsWith('-')),
]
let command = 'playwright'
let args = playwrightArgs
if (process.platform === 'darwin') {
// macOS does not have a Cage-style headless Wayland compositor. Electron's
// offscreen renderer keeps the test BrowserWindow off the user's desktop.
env.HERMES_DESKTOP_E2E_HEADLESS = '1'
} else {
command = 'cage'
args = ['--', 'playwright', ...playwrightArgs]
env.WLR_BACKENDS = 'headless'
env.WLR_NO_HARDWARE_CURSORS = '1'
}
const result = spawnSync(command, args, { env, stdio: 'inherit' })
if (result.error) {
throw result.error
}
process.exitCode = result.status ?? 1