mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
* nix: add `cage` to devShell
* test(desktop): add pre-filled sessions support
Exports createSandbox, writeMockProviderConfig, writeEnvFile,
buildAppEnv, findElectron, and launchDesktop from fixtures.ts so
specs can compose their own seeded-backend fixtures without duplicating
the sandbox/config/launch logic.
* test(desktop): auto-fail e2e tests on error banner
Adds a shared test fixture (e2e/test.ts) that wraps @playwright/test's
page with an error-banner guard. When any [role="alert"] element
(error notification toast) appears in the DOM during a test, the test
fails with the error message text.
The guard uses:
- A MutationObserver (injected via addInitScript) that watches for
[role="alert"] elements appearing at any point during the test
- A final DOM scan in afterEach for alerts still visible at teardown
- Deduplication so the same error text only fires once
All existing e2e specs updated to import { test, expect } from './test'
instead of '@playwright/test'. No per-spec setup needed — the guard is
auto-installed on every page via the extended fixture.
This catches issues like the "resume failed" error banner that can
appear during session loading — previously the test would pass while
an error toast was silently visible on screen.
* fix(state): parse tool_calls JSON string before re-serializing
_insert_message_rows and append_message both do json.dumps(tool_calls)
to serialize the field for SQLite storage. But when tool_calls arrives
as a JSON string (from import_sessions / export_session, which store it
as TEXT), json.dumps double-encodes it — wrapping the already-serialized
string in quotes and escaping the inner quotes.
When _rows_to_conversation later does json.loads(row['tool_calls']),
the double-encoded string parses back to a plain string (not a list).
_history_to_messages then iterates this string character-by-character,
calling tc.get('function', {}) on each char — 'str' object has no
attribute 'get'.
This was a pre-existing bug (on main), but only triggered by the
import_sessions path (the live agent always passes tool_calls as a
Python list). The e2e error-banner guard caught it via the 'Resume
failed' notification toast.
Fix: in both append_message and _insert_message_rows, parse tool_calls
with json.loads first if it's a string, then re-serialize.
* fix(desktop): exempt boot-failure from error guard
- boot-failure: add allowErrorBanners() beforeEach — these tests
deliberately trigger boot errors, so error toasts are expected
- test.ts: export allowErrorBanners() opt-out + reset flag in afterEach
680 lines
21 KiB
TypeScript
680 lines
21 KiB
TypeScript
/**
|
|
* Shared E2E fixtures for the Hermes desktop Playwright suite.
|
|
*
|
|
* Two fixture modes:
|
|
*
|
|
* 1. `mockBackend` — starts a mock inference server, writes a config.yaml
|
|
* that points at it, and launches the desktop app so the full chain
|
|
* (electron → hermes serve → provider → inference → renderer) is
|
|
* exercised with a real backend but a fake LLM.
|
|
*
|
|
* 2. `noProvider` — launches the app with an empty config (no provider
|
|
* configured). The onboarding overlay should appear. Used to test the
|
|
* first-run flow without real credentials.
|
|
*
|
|
* Both modes launch the *dev* Electron app (`electron .` against the built
|
|
* `dist/`), not the packaged binary. This avoids the multi-minute
|
|
* `electron-builder --dir` step and matches `hermes desktop --source`. The
|
|
* packaged-binary path is already covered by `launch.spec.ts`.
|
|
*
|
|
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import * as fs from 'node:fs'
|
|
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
|
|
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
|
|
|
import { startMockServer } from './mock-server'
|
|
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')
|
|
|
|
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
|
|
|
|
const CREDENTIAL_SUFFIXES: string[] = [
|
|
'_API_KEY',
|
|
'_TOKEN',
|
|
'_SECRET',
|
|
'_PASSWORD',
|
|
'_CREDENTIALS',
|
|
'_ACCESS_KEY',
|
|
'_PRIVATE_KEY',
|
|
'_OAUTH_TOKEN',
|
|
]
|
|
|
|
const CREDENTIAL_NAMES = new Set([
|
|
'ANTHROPIC_BASE_URL',
|
|
'ANTHROPIC_TOKEN',
|
|
'AWS_ACCESS_KEY_ID',
|
|
'AWS_SECRET_ACCESS_KEY',
|
|
'AWS_SESSION_TOKEN',
|
|
'CUSTOM_API_KEY',
|
|
'GEMINI_BASE_URL',
|
|
'OPENAI_BASE_URL',
|
|
'OPENROUTER_BASE_URL',
|
|
'OLLAMA_BASE_URL',
|
|
'GROQ_BASE_URL',
|
|
'XAI_BASE_URL',
|
|
])
|
|
|
|
function isCredentialEnvVar(name: string): boolean {
|
|
if (CREDENTIAL_NAMES.has(name)) {
|
|
return true
|
|
}
|
|
|
|
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
|
|
}
|
|
|
|
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
|
|
const clean: Record<string, string> = {}
|
|
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (!value) {
|
|
continue
|
|
}
|
|
|
|
if (isCredentialEnvVar(key)) {
|
|
continue
|
|
}
|
|
|
|
clean[key] = value
|
|
}
|
|
|
|
return clean
|
|
}
|
|
|
|
// ─── Sandbox creation ──────────────────────────────────────────────────
|
|
|
|
export interface Sandbox {
|
|
root: string
|
|
hermesHome: string
|
|
userDataDir: string
|
|
cleanup: () => void
|
|
}
|
|
|
|
export function createSandbox(prefix: string): Sandbox {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
|
|
const hermesHome = path.join(root, 'hermes-home')
|
|
const userDataDir = path.join(root, 'electron-user-data')
|
|
|
|
fs.mkdirSync(hermesHome, { recursive: true })
|
|
fs.mkdirSync(userDataDir, { recursive: true })
|
|
|
|
// Write a fixed window-state.json so the Electron window opens at a
|
|
// consistent size — helps with visual regression screenshots. The
|
|
// exact size is also enforced right before each screenshot (see
|
|
// expectVisualSnapshot in visual-snapshot.ts) because window managers
|
|
// may resize after launch.
|
|
fs.writeFileSync(
|
|
path.join(userDataDir, 'window-state.json'),
|
|
JSON.stringify(
|
|
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
)
|
|
|
|
return {
|
|
root,
|
|
hermesHome,
|
|
userDataDir,
|
|
cleanup: () => {
|
|
try {
|
|
fs.rmSync(root, { recursive: true, force: true })
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// ─── Config writing ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Write a config.yaml that pre-configures a mock provider pointing at the
|
|
* mock inference server. The provider is set as the active model provider so
|
|
* the desktop app skips onboarding and boots straight to the chat UI.
|
|
*/
|
|
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
|
|
const configPath = path.join(hermesHome, 'config.yaml')
|
|
|
|
const config = `# Auto-generated by E2E test fixtures
|
|
model:
|
|
default: mock-model
|
|
provider: mock
|
|
providers:
|
|
mock:
|
|
api: ${mockUrl}/v1
|
|
name: Mock
|
|
api_mode: chat_completions
|
|
key_env: MOCK_API_KEY
|
|
models:
|
|
mock-model: {}
|
|
context_length: 4096
|
|
`
|
|
|
|
fs.writeFileSync(configPath, config, 'utf8')
|
|
}
|
|
|
|
/**
|
|
* Write a minimal .env with the mock API key. The key_env in config.yaml
|
|
* references MOCK_API_KEY, so the backend resolves credentials from here.
|
|
*/
|
|
export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
|
|
const envPath = path.join(hermesHome, '.env')
|
|
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
|
|
}
|
|
|
|
/**
|
|
* Write an empty config (no providers). The desktop app should show the
|
|
* onboarding overlay because no inference provider is configured.
|
|
*/
|
|
function writeEmptyConfig(hermesHome: string): void {
|
|
const configPath = path.join(hermesHome, 'config.yaml')
|
|
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
|
|
}
|
|
|
|
// ─── Env building ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build the environment for the Electron app process.
|
|
*
|
|
* Key env vars:
|
|
* - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
|
|
* - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
|
|
* - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
|
|
* (we want the dev checkout at REPO_ROOT)
|
|
* - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
|
|
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
|
|
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
|
|
*/
|
|
export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
|
|
const clean = stripCredentials(process.env)
|
|
|
|
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
|
|
// headless/CI context — without it the zygote may fail to initialize.
|
|
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
|
|
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
|
|
}
|
|
|
|
// DISPLAY — needed for Electron to open a window.
|
|
if (!clean.DISPLAY && process.env.DISPLAY) {
|
|
clean.DISPLAY = process.env.DISPLAY
|
|
}
|
|
|
|
return {
|
|
...clean,
|
|
HERMES_HOME: sandbox.hermesHome,
|
|
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
|
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
|
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
|
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
|
|
// Clear dev-server override — we want the built dist/, not a vite server.
|
|
// The dev-server check in main.ts looks for this env var; if it's set,
|
|
// it loads from the vite URL instead of the local file.
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
// ─── Electron launch ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify that the desktop app has been built (dist/ exists). Playwright
|
|
* tests can't run without it — the Electron main process loads
|
|
* dist/electron-main.mjs and the renderer loads dist/index.html.
|
|
*/
|
|
function assertDistBuilt(): void {
|
|
const distDir = path.join(DESKTOP_ROOT, 'dist')
|
|
const electronMain = path.join(distDir, 'electron-main.mjs')
|
|
const indexHtml = path.join(distDir, 'index.html')
|
|
|
|
if (!fs.existsSync(electronMain)) {
|
|
throw new Error(
|
|
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
|
`Missing: ${electronMain}`,
|
|
)
|
|
}
|
|
|
|
if (!fs.existsSync(indexHtml)) {
|
|
throw new Error(
|
|
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
|
|
`Missing: ${indexHtml}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
|
|
* As a fallback, use the node_modules/.bin/electron from the desktop package.
|
|
*/
|
|
export function findElectron(): string {
|
|
// In dev mode, we use the `electron` binary directly (not the packaged app).
|
|
// The dev:electron script in package.json does exactly this: `electron .`
|
|
// after building. We replicate that here.
|
|
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
|
|
|
|
if (fs.existsSync(localElectron)) {
|
|
return localElectron
|
|
}
|
|
|
|
// Fall back to PATH
|
|
const result = spawnSync('which', ['electron'], {
|
|
encoding: 'utf8',
|
|
})
|
|
|
|
if (result.status === 0 && result.stdout.trim()) {
|
|
return result.stdout.trim()
|
|
}
|
|
|
|
throw new Error(
|
|
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Launch the desktop app in dev mode.
|
|
*
|
|
* @param sandbox - isolated HERMES_HOME + userData
|
|
* @param env - the process environment (already has HERMES_HOME etc.)
|
|
* @returns the ElectronApplication + first Page
|
|
*/
|
|
export async function launchDesktop(
|
|
env: Record<string, string>,
|
|
): Promise<{ app: ElectronApplication; page: Page }> {
|
|
assertDistBuilt()
|
|
|
|
const electronBin = findElectron()
|
|
|
|
// `electron .` loads from the package.json `main` field
|
|
// (dist/electron-main.mjs after build).
|
|
const app = await _electron.launch({
|
|
executablePath: electronBin,
|
|
args: [
|
|
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
|
|
'--disable-gpu',
|
|
'--no-sandbox',
|
|
],
|
|
env,
|
|
cwd: DESKTOP_ROOT,
|
|
})
|
|
|
|
const page = await app.firstWindow()
|
|
|
|
// Install the error-banner guard so any [role="alert"] that appears
|
|
// during a test is collected and surfaced in afterEach.
|
|
installErrorBannerGuard(page)
|
|
|
|
return { app, page }
|
|
}
|
|
|
|
// ─── Public fixtures ────────────────────────────────────────────────────
|
|
|
|
export interface MockBackendFixture {
|
|
app: ElectronApplication
|
|
page: Page
|
|
mockUrl: string
|
|
sandbox: Sandbox
|
|
cleanup: () => Promise<void>
|
|
}
|
|
|
|
/**
|
|
* Set up a full mock-backend E2E environment:
|
|
* 1. Start the mock inference server
|
|
* 2. Create a sandbox with config.yaml pointing at it
|
|
* 3. Launch the desktop app
|
|
* 4. Return handles for test interaction
|
|
*/
|
|
export async function setupMockBackend(): Promise<MockBackendFixture> {
|
|
// 1. Start mock server
|
|
const mock = await startMockServer()
|
|
|
|
// 2. Create sandbox + write config
|
|
const sandbox = createSandbox('mock')
|
|
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
|
writeEnvFile(sandbox.hermesHome)
|
|
|
|
// 3. Build env + launch
|
|
const env = buildAppEnv(sandbox)
|
|
const { app, page } = await launchDesktop(env)
|
|
|
|
return {
|
|
app,
|
|
page,
|
|
mockUrl: mock.url,
|
|
sandbox,
|
|
cleanup: async () => {
|
|
await app.close().catch(() => undefined)
|
|
await mock.close()
|
|
sandbox.cleanup()
|
|
},
|
|
}
|
|
}
|
|
|
|
export interface NoProviderFixture {
|
|
app: ElectronApplication
|
|
page: Page
|
|
sandbox: Sandbox
|
|
cleanup: () => Promise<void>
|
|
}
|
|
|
|
/**
|
|
* Launch the app with no provider configured. The onboarding overlay should
|
|
* appear because there's no inference provider in config.yaml.
|
|
*/
|
|
export async function setupNoProvider(): Promise<NoProviderFixture> {
|
|
const sandbox = createSandbox('noprovider')
|
|
writeEmptyConfig(sandbox.hermesHome)
|
|
|
|
const env = buildAppEnv(sandbox)
|
|
const { app, page } = await launchDesktop(env)
|
|
|
|
return {
|
|
app,
|
|
page,
|
|
sandbox,
|
|
cleanup: async () => {
|
|
await app.close().catch(() => undefined)
|
|
sandbox.cleanup()
|
|
},
|
|
}
|
|
}
|
|
|
|
export interface DeadBackendFixture {
|
|
app: ElectronApplication
|
|
page: Page
|
|
sandbox: Sandbox
|
|
cleanup: () => Promise<void>
|
|
}
|
|
|
|
export interface DeadBackendOptions {
|
|
/**
|
|
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
|
|
* so the backend resolution itself "fails" with a controlled error message.
|
|
* This is the only reliable way to trigger BootFailureOverlay in dev mode
|
|
* (the real backend always resolves via SOURCE_REPO_ROOT).
|
|
*/
|
|
fakeError?: boolean
|
|
}
|
|
|
|
/**
|
|
* Launch the app with a provider pointing at a dead endpoint (port 1, which
|
|
* nothing listens on). By default the backend still boots (`hermes serve`
|
|
* starts fine — the dead endpoint only matters at chat time). Pass
|
|
* `{ fakeError: true }` to inject a fake boot failure, triggering the
|
|
* BootFailureOverlay.
|
|
*/
|
|
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
|
|
const sandbox = createSandbox('dead')
|
|
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
|
fs.writeFileSync(
|
|
configPath,
|
|
`# Auto-generated by E2E test fixtures — dead provider
|
|
model:
|
|
default: mock-model
|
|
provider: mock
|
|
providers:
|
|
mock:
|
|
api: http://127.0.0.1:1/v1
|
|
name: Mock
|
|
api_mode: chat_completions
|
|
key_env: MOCK_API_KEY
|
|
models:
|
|
mock-model: {}
|
|
context_length: 4096
|
|
`,
|
|
'utf8',
|
|
)
|
|
writeEnvFile(sandbox.hermesHome)
|
|
|
|
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
|
|
const { app, page } = await launchDesktop(env)
|
|
|
|
return {
|
|
app,
|
|
page,
|
|
sandbox,
|
|
cleanup: async () => {
|
|
await app.close().catch(() => undefined)
|
|
sandbox.cleanup()
|
|
},
|
|
}
|
|
}
|
|
|
|
// ─── Packaged-binary fixture ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the packaged Electron binary path, per-platform, matching
|
|
* electron-builder's output layout under release/.
|
|
*/
|
|
function resolvePackagedBinaryPath(): string {
|
|
if (process.platform === 'win32') {
|
|
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
|
|
}
|
|
|
|
if (process.platform === 'darwin') {
|
|
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
|
|
|
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
|
|
}
|
|
|
|
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
|
|
}
|
|
|
|
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
|
|
|
|
export function packagedBinaryExists(): boolean {
|
|
return fs.existsSync(PACKAGED_BINARY_PATH)
|
|
}
|
|
|
|
export interface PackagedAppFixture {
|
|
app: ElectronApplication
|
|
page: Page
|
|
sandbox: Sandbox
|
|
cleanup: () => Promise<void>
|
|
}
|
|
|
|
/**
|
|
* Launch the *packaged* Electron binary (from `npm run pack` →
|
|
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
|
|
* progress without spawning a real Hermes backend.
|
|
*
|
|
* Uses the same sandbox isolation (credential stripping, isolated
|
|
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
|
|
*
|
|
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
|
*/
|
|
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
|
|
if (!packagedBinaryExists()) {
|
|
throw new Error(
|
|
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
|
)
|
|
}
|
|
|
|
const sandbox = createSandbox('packaged')
|
|
|
|
// Build the sandbox env using the shared helpers, then add the
|
|
// packaged-binary-specific overrides.
|
|
const env = buildAppEnv(sandbox, {
|
|
// Fake boot: simulates progress steps without spawning the real backend.
|
|
HERMES_DESKTOP_BOOT_FAKE: '1',
|
|
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
|
|
})
|
|
|
|
// Clear dev-server + hermes-root overrides — the packaged binary
|
|
// should use its own bundled renderer, not the dev checkout.
|
|
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
|
|
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
|
|
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
|
|
|
|
const app = await _electron.launch({
|
|
executablePath: PACKAGED_BINARY_PATH,
|
|
args: ['--disable-gpu', '--no-sandbox'],
|
|
env,
|
|
})
|
|
|
|
const page = await app.firstWindow()
|
|
installErrorBannerGuard(page)
|
|
|
|
return {
|
|
app,
|
|
page,
|
|
sandbox,
|
|
cleanup: async () => {
|
|
await app.close().catch(() => undefined)
|
|
sandbox.cleanup()
|
|
},
|
|
}
|
|
}
|
|
|
|
// ─── Wait helpers ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Wait for the desktop app to finish booting and show the main chat UI.
|
|
*
|
|
* The boot overlay disappears when `completeDesktopBoot()` fires in the
|
|
* renderer — at that point the gateway is open, config is loaded, and
|
|
* sessions are loaded. We detect this by waiting for the boot/connecting
|
|
* overlay to become invisible and the main app shell to be present.
|
|
*
|
|
* Two things must both be true before we return:
|
|
* 1. The composer (chat input) is visible — it's disabled until the
|
|
* gateway is open.
|
|
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
|
|
* boot-failure) covers the viewport center. The composer can be
|
|
* "visible" in Playwright's eyes (non-zero bounding box, not
|
|
* display:none) even when a z-1300+ overlay is painted on top of it,
|
|
* so checking the composer alone catches the app mid-boot at ~92%
|
|
* with the loading bar still showing.
|
|
*/
|
|
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
|
|
const { page, app } = fixture
|
|
|
|
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
|
|
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
|
state: 'attached',
|
|
timeout: timeoutMs,
|
|
})
|
|
|
|
// Now poll until no full-screen overlay covers the viewport center.
|
|
// elementFromPoint returns the topmost element at a point — if it's part
|
|
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
|
|
// app isn't ready yet.
|
|
await page.waitForFunction(
|
|
() => {
|
|
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
|
|
|
if (!el) {
|
|
return false
|
|
}
|
|
|
|
// Walk up to the nearest positioned ancestor — overlays are
|
|
// `position: fixed; inset: 0`. If the hit element or an ancestor
|
|
// is a full-viewport fixed overlay, we're still covered.
|
|
let node: Element | null = el
|
|
while (node) {
|
|
const cs = window.getComputedStyle(node)
|
|
|
|
if (cs.position === 'fixed') {
|
|
const rect = node.getBoundingClientRect()
|
|
|
|
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
node = node.parentElement
|
|
}
|
|
|
|
return true
|
|
},
|
|
undefined,
|
|
{ timeout: timeoutMs },
|
|
)
|
|
|
|
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
|
|
// and the window stays hidden even though the DOM is rendered. The main
|
|
// process has a TEST_WORKER_INDEX-gated fallback that force-shows the
|
|
// 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) {
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
while (Date.now() < deadline) {
|
|
const visible = await app.evaluate(({ BrowserWindow }) => {
|
|
const w = BrowserWindow.getAllWindows()[0]
|
|
|
|
return w ? w.isVisible() : false
|
|
}).catch(() => false)
|
|
|
|
if (visible) {break}
|
|
await page.waitForTimeout(500)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for the onboarding overlay to appear (no provider configured).
|
|
*/
|
|
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
|
|
// The onboarding overlay contains a heading with "Choose your provider"
|
|
// or similar text. We look for any text that indicates the picker.
|
|
await page.waitForFunction(
|
|
() => {
|
|
const root = document.getElementById('root')
|
|
|
|
if (!root) {
|
|
return false
|
|
}
|
|
|
|
const text = root.textContent ?? ''
|
|
|
|
return (
|
|
text.includes('provider') ||
|
|
text.includes('Provider') ||
|
|
text.includes('Choose') ||
|
|
text.includes('API key') ||
|
|
text.includes('Sign in')
|
|
)
|
|
},
|
|
undefined,
|
|
{ timeout: timeoutMs },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Wait for the boot failure overlay to appear.
|
|
*/
|
|
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
|
await page.waitForFunction(
|
|
() => {
|
|
// Boot failure is terminal: the backend gave up. The renderer shows
|
|
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
|
|
// falls back to the onboarding picker (z-1300) as a recovery path.
|
|
// We wait for the failure dialog itself — the Preparing component may
|
|
// still paint its progress bar (recolored red) underneath the overlay,
|
|
// which is harmless.
|
|
const text = document.body.textContent ?? ''
|
|
|
|
// BootFailureOverlay buttons.
|
|
const hasFailureUI =
|
|
text.includes('Retry') ||
|
|
text.includes('Repair') ||
|
|
text.includes('Use local gateway') ||
|
|
text.includes('Connection settings')
|
|
|
|
// The error toast / notification that fires on failDesktopBoot().
|
|
const hasErrorToast = text.includes('Desktop boot failed')
|
|
|
|
return hasFailureUI || hasErrorToast
|
|
},
|
|
undefined,
|
|
{ timeout: timeoutMs },
|
|
)
|
|
}
|