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
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
/**
|
|
* Extended Playwright test fixture that auto-fails any test if an error
|
|
* banner (notification toast with role="alert") appears in the DOM.
|
|
*
|
|
* The desktop app surfaces errors as `[data-slot="alert"][role="alert"]`
|
|
* elements (see components/notifications.tsx). When one appears during a
|
|
* test, it means something went wrong (resume failed, boot error, etc.)
|
|
* — the test should fail with the error message, not silently pass while
|
|
* an error toast is visible on screen.
|
|
*
|
|
* Usage: import { test, expect } from './test' instead of
|
|
* '@playwright/test'. The guard is auto-installed on every page — no
|
|
* per-spec setup needed.
|
|
*/
|
|
|
|
import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test'
|
|
|
|
// Track error messages per test so afterEach can assert + report.
|
|
const seenErrors: string[] = []
|
|
let activePage: Page | null = null
|
|
// When true, the afterEach guard skips the error-banner check.
|
|
// Set by tests that deliberately trigger error states (e.g. boot-failure).
|
|
let errorBannersAllowed = false
|
|
|
|
/**
|
|
* Opt out of the error-banner guard for the current test. Call in
|
|
* test.beforeEach or at the top of a test body when error banners are
|
|
* expected (e.g. boot-failure tests that deliberately trigger errors).
|
|
*/
|
|
export function allowErrorBanners(): void {
|
|
errorBannersAllowed = true
|
|
}
|
|
|
|
/**
|
|
* Install the error-banner guard on a page. Watches for `[role="alert"]`
|
|
* elements appearing in the DOM. When one is found, records its text
|
|
* content for the afterEach assertion.
|
|
*
|
|
* Exported so e2e fixture functions (which create pages via _electron.launch)
|
|
* can install the guard on their custom pages — the default Playwright `page`
|
|
* fixture override only catches pages created by Playwright itself, not
|
|
* pages created by the test's own Electron launch.
|
|
*/
|
|
export function installErrorBannerGuard(page: Page): void {
|
|
activePage = page
|
|
|
|
// Clear any errors from a previous test when a new page is created.
|
|
seenErrors.length = 0
|
|
|
|
// Use a MutationObserver to catch error banners as they appear.
|
|
// We inject this via addInitScript so it runs before any app code.
|
|
page.addInitScript(() => {
|
|
const seen: string[] = []
|
|
;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen
|
|
|
|
const observer = new MutationObserver(() => {
|
|
const alerts = document.querySelectorAll('[role="alert"]')
|
|
|
|
for (const alert of alerts) {
|
|
const text = (alert.textContent ?? '').trim()
|
|
|
|
if (text && !seen.includes(text)) {
|
|
seen.push(text)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Start observing once the DOM is ready.
|
|
if (document.body) {
|
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
})
|
|
}
|
|
})
|
|
|
|
// Also poll via evaluate — MutationObserver via addInitScript can miss
|
|
// elements that appear during the Electron renderer's initial mount
|
|
// (before the observer is installed). A periodic poll catches those.
|
|
page.on('console', () => {
|
|
// Console messages are not errors — but we keep the listener to
|
|
// ensure the page context is active for our evaluate calls.
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Check for error banners that appeared during the test. Called in
|
|
* afterEach via the custom fixture below. Also exported so specs that
|
|
* manage their own page lifecycle can call it directly.
|
|
*/
|
|
export async function collectErrorBanners(page: Page | null): Promise<string[]> {
|
|
if (!page) {
|
|
return []
|
|
}
|
|
|
|
try {
|
|
// Read errors collected by the MutationObserver in the page context.
|
|
const pageErrors = await page.evaluate(() => {
|
|
const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] }
|
|
|
|
return [...(w.__ERROR_BANNER_GUARD__ ?? [])]
|
|
})
|
|
|
|
// Also do a final DOM scan for any alert elements still visible.
|
|
const domAlerts = await page
|
|
.locator('[role="alert"]')
|
|
.allTextContents()
|
|
.catch(() => [] as string[])
|
|
|
|
const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])]
|
|
seenErrors.push(...all)
|
|
|
|
return [...new Set(seenErrors)]
|
|
} catch {
|
|
// Page might be closed — return whatever we have.
|
|
return [...new Set(seenErrors)]
|
|
}
|
|
}
|
|
|
|
// Extended test fixture: wraps the default page with the error guard.
|
|
export const test = base.extend({
|
|
// Override the page fixture to auto-install the guard.
|
|
page: async ({ page }, use) => {
|
|
installErrorBannerGuard(page)
|
|
await use(page)
|
|
},
|
|
})
|
|
|
|
// afterEach: fail the test if any error banners appeared.
|
|
// Always fires — even if the test already failed for another reason.
|
|
// An error banner often IS the root cause (e.g. "resume failed" from a
|
|
// backend bug), and suppressing it when the test also fails on an
|
|
// assertion hides the real problem.
|
|
//
|
|
// Uses `activePage` (set by installErrorBannerGuard) instead of the
|
|
// default `page` fixture — Electron tests create their own page via
|
|
// app.firstWindow(), so the default `page` fixture is undefined.
|
|
base.afterEach(async ({}, testInfo) => {
|
|
const wasAllowed = errorBannersAllowed
|
|
// Reset for the next test.
|
|
errorBannersAllowed = false
|
|
|
|
if (wasAllowed) {
|
|
// Test opted out — clear any collected errors without asserting.
|
|
seenErrors.length = 0
|
|
return
|
|
}
|
|
|
|
const errors = await collectErrorBanners(activePage)
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
`Error banner(s) appeared during test "${testInfo.title}":\n` +
|
|
errors.map(e => ` • ${e}`).join('\n'),
|
|
)
|
|
}
|
|
})
|
|
|
|
// Reset for the next test file.
|
|
base.afterAll(async () => {
|
|
seenErrors.length = 0
|
|
activePage = null
|
|
})
|
|
|
|
export { expect, type Page, type ElectronApplication, _electron }
|