E2E Testing Guide
Quick Start
1. Build the App
E2E tests launch Electron directly (electron .), loading pre-built files from out/. Source code changes require a rebuild before tests can pick them up.
# Full build (main + preload + renderer)
bunx electron-vite build
bun run start(electron-vite dev) uses Vite's HMR and hot-reloads automatically. E2E tests do NOT use Vite dev server — they load static files fromout/.
2. Ensure aioncore is on PATH
The Electron main process spawns the aioncore binary during startup and
exposes its port to the renderer via window.__backendPort. The binary is
located via which aioncore, so it must be reachable from the PATH
inherited by the Playwright runner. If it isn't, __backendPort will be 0
and every HTTP call from the renderer (or from e2e helpers that use
tests/e2e/helpers/httpBridge.ts) will fail with Failed to fetch.
# Install the backend binary (builds to ~/.cargo/bin/aioncore)
cd ../AionCore && cargo install --path crates/aionui-app
# Make sure it's on PATH when running tests
export PATH="$HOME/.cargo/bin:$PATH"
3. Run Tests
# All E2E tests
bun run test:e2e
# Specific test file
npx playwright test --config playwright.config.ts tests/e2e/specs/team-workspace-migration.e2e.ts --reporter=list
3. View Results
# Open HTML report
npx playwright show-report tests/e2e/report
Screenshots, traces, and videos are saved to tests/e2e/results/.
Architecture
App Lifecycle
Playwright launches Electron app (singleton per worker)
→ App loads out/main/index.js
→ Main process creates BrowserWindow
→ Renderer loads out/renderer/index.html (HashRouter)
→ Tests interact with the renderer page
→ App persists across ALL test files (no restart between describes)
→ App closes when worker exits
Key design decision: One Electron instance shared across all tests. Restarting costs ~25-30 seconds, so tests reuse the same app process.
Two Launch Modes
| Mode | Trigger | What it runs | Use case |
|---|---|---|---|
| Dev (default locally) | E2E_DEV=1 or no env var |
electron . from project root |
Local development |
| Packaged | E2E_PACKAGED=1 or CI |
Built app from out/ |
CI pipelines |
Both modes load pre-built files from out/. The difference is packaged mode uses NODE_ENV=production and the platform-specific executable.
Directory Structure
tests/e2e/
├── fixtures.ts # Electron app launch, page fixture, singleton management
├── helpers/
│ ├── index.ts # Re-exports all helpers
│ ├── bridge.ts # invokeBridge() — IPC communication with main process
│ ├── navigation.ts # Route helpers (navigateTo, goToGuid, goToSettings)
│ ├── conversation.ts # Chat helpers (sendMessage, waitForAiReply, selectAgent)
│ ├── selectors.ts # CSS selectors for UI elements
│ ├── assertions.ts # Custom assertions (expectBodyContainsAny, error collector)
│ ├── extensions.ts # Extension snapshot helpers
│ ├── assistantSettings.ts # Assistant CRUD helpers
│ ├── teamConfig.ts # TEAM_SUPPORTED_BACKENDS whitelist
│ └── screenshots.ts # Manual screenshot helper
├── specs/
│ ├── README.md # Team E2E spec (rules for team tests)
│ ├── app-launch.e2e.ts
│ ├── team-create.e2e.ts
│ ├── team-workspace-migration.e2e.ts
│ └── ... # ~30+ test files
├── results/ # Test artifacts (gitignored)
├── report/ # HTML report (gitignored)
└── screenshots/ # Manual screenshots (gitignored)
Writing Tests
Basic Pattern
import { test, expect } from '../fixtures';
import { invokeBridge, navigateTo } from '../helpers';
test.describe('Feature Name', () => {
test('what it should do', async ({ page, electronApp }) => {
// 1. Navigate
await navigateTo(page, '#/some-route');
// 2. Interact
const input = page.locator('textarea').first();
await input.fill('Hello');
await input.press('Enter');
// 3. Assert UI
await expect(page.locator('text=Hello')).toBeVisible({ timeout: 10_000 });
// 4. Assert backend (optional)
const data = await invokeBridge(page, 'some.bridge-key', { param: 'value' });
expect(data.field).toBe('expected');
});
});
Key Helpers
| Helper | Purpose | Import from |
|---|---|---|
invokeBridge(page, key, data) |
Call main process IPC | ../helpers |
navigateTo(page, hash) |
Navigate via sidebar UI | ../helpers |
waitForAiReply(page) |
Wait for AI response (handles Shadow DOM) | ../helpers |
selectAgent(page, backend) |
Select an available assistant for a backend | ../helpers |
sendMessageFromGuid(page, msg) |
Send message and get conversation ID | ../helpers |
deleteConversation(page, id) |
Delete conversation by ID (cleanup) | ../helpers |
MODE_SELECTOR |
Mode selector pill [data-testid="mode-selector"] |
../helpers |
modeMenuItemByValue(value) |
Mode dropdown item [data-mode-value="..."] |
../helpers |
invokeBridge Rules
| Allowed | Forbidden |
|---|---|
Setup: read initial state (team.list, team.get) |
Trigger operations (add member, send message) |
| Assert: verify backend matches UI | Operations MUST go through UI interaction |
Cleanup: delete test data (team.remove) |
Timeout Guidelines
| Operation | Timeout |
|---|---|
| UI element visibility | 5,000 - 15,000ms |
| Navigation + settle | 10,000ms |
| AI response (single model) | 120,000ms |
| Team operations (leader inference + MCP) | 60,000 - 120,000ms |
| Member initialization | 60,000ms |
Mocking Native Dialogs (Electron)
// Mock file open dialog
await electronApp.evaluate(async ({ dialog }, targetPath) => {
dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [targetPath] });
}, '/path/to/target');
Shadow DOM
AI message text renders inside Shadow DOM (.markdown-shadow). Use the waitForAiReply() helper which handles this automatically. If you need raw access:
const text = await page.evaluate(() => {
const el = document.querySelector('.message-item.text.justify-start:last-child');
const shadow = el?.querySelector('.markdown-shadow');
return shadow?.shadowRoot?.textContent?.trim() ?? '';
});
Screenshots
// Manual screenshot (saved to tests/e2e/results/)
await page.screenshot({ path: 'tests/e2e/results/my-step.png' });
Failed tests automatically get screenshots attached to the HTML report.
Environment Variables
| Variable | Default | Purpose |
|---|---|---|
E2E_PACKAGED=1 |
unset (dev mode) | Use packaged app from out/ |
E2E_DEV=1 |
unset | Force dev mode |
TEAM_AGENT |
all (claude,codex,gemini) |
Filter team leader types |
CI |
unset | Auto-selects packaged mode |
Variables set automatically during test launch:
| Variable | Value | Purpose |
|---|---|---|
AIONUI_E2E_TEST |
1 |
App recognizes test mode |
AIONUI_DISABLE_AUTO_UPDATE |
1 |
No update checks |
AIONUI_DISABLE_DEVTOOLS |
1 |
No DevTools windows |
AIONUI_CDP_PORT |
0 |
CDP disabled |
NPM Scripts
| Command | Scope |
|---|---|
bun run test:e2e |
All E2E tests |
bun run test:e2e:team |
All team-*.e2e.ts |
bun run test:e2e:team:create |
Team creation only |
bun run test:e2e:team:lifecycle |
Add + fire members |
bun run test:e2e:team:whitelist |
Agent whitelist dropdown |
bun run test:e2e:team:comm |
Message sending |
Examples
# Run all E2E locally (dev mode, requires build first)
bunx electron-vite build && bun run test:e2e
# Run only team tests with list reporter
bun run test:e2e:team
# Run specific test file
npx playwright test --config playwright.config.ts tests/e2e/specs/app-launch.e2e.ts
# Only test gemini leader type
TEAM_AGENT=gemini bun run test:e2e:team
# Run in packaged mode (CI-like)
E2E_PACKAGED=1 bun run test:e2e
Troubleshooting
Tests fail with stale UI / old behavior
Cause: Source changes not rebuilt.
bunx electron-vite build
Bridge invoke timeout: xxx
Cause: The IPC provider for xxx doesn't exist or wasn't registered.
- Check
src/common/adapter/ipcBridge.tsfor the endpoint definition - Check the corresponding bridge file (e.g.,
src/process/bridge/teamBridge.ts) for.provider()registration - Rebuild:
bunx electron-vite build
App launches but page is blank
Cause: Renderer build is missing or corrupted.
bunx electron-vite build
Tests are flaky with AI responses
- Increase timeout (AI inference varies by load)
- Use
expect.poll()instead of fixedwaitForTimeout() - Add retry logic for MCP confirmation dialogs (see
autoApproveMcpDialogspattern)
Leftover test data in sidebar
# Clean via database
sqlite3 "~/Library/Application Support/AionUi-Dev/aionui/aionui.db" \
"DELETE FROM teams WHERE name LIKE 'E2E%';"
Or add cleanup at test start:
const teams = await invokeBridge(page, 'team.list', { userId: 'system_default_user' });
for (const t of teams) {
if (t.name.startsWith('E2E')) {
await invokeBridge(page, 'team.remove', { id: t.id }).catch(() => {});
}
}