Files
tkmind_go/ui/desktop/tests/e2e/fixtures.ts
T
2026-03-11 12:46:05 +00:00

171 lines
5.9 KiB
TypeScript

import { test as base, Page, Browser, chromium } from '@playwright/test';
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
import { promisify } from 'util';
const execAsync = promisify(require('child_process').exec);
type GooseTestFixtures = {
goosePage: Page;
};
/**
* Test-scoped fixture that launches a fresh Electron app for EACH test.
*
* Isolation: ⚠️ Partial - each test gets a fresh app instance, but uses ambient user config
* Speed: ⚠️ Slow - ~3s startup overhead per test
*
* This ensures each test starts with a fresh app instance, but the app uses the
* user's existing Goose configuration (providers, models, etc.).
*
* Usage:
* import { test, expect } from './fixtures';
*
* test('my test', async ({ goosePage }) => {
* await goosePage.waitForSelector('[data-testid="chat-input"]');
* // ... test code
* });
*/
export const test = base.extend<GooseTestFixtures>({
// Test-scoped fixture: launches a fresh Electron app for each test
goosePage: async ({}, use, testInfo) => {
console.log(`Launching fresh Electron app for test: ${testInfo.title}`);
let appProcess: ChildProcess | null = null;
let browser: Browser | null = null;
try {
// Assign a unique debug port for this test to enable parallel execution
// Base port 9222, offset by worker index * 100 + parallel slot
const debugPort = 9222 + (testInfo.parallelIndex * 10);
console.log(`Using debug port ${debugPort} for parallel test execution`);
// Start the electron-forge process with Playwright remote debugging enabled
// Use detached mode on Unix to create a process group we can kill together
appProcess = spawn('pnpm', ['run', 'start-gui'], {
cwd: join(__dirname, '../..'),
stdio: 'pipe',
detached: process.platform !== 'win32',
env: {
...process.env,
ELECTRON_IS_DEV: '1',
NODE_ENV: 'development',
GOOSE_ALLOWLIST_BYPASS: 'true',
ENABLE_PLAYWRIGHT: 'true',
PLAYWRIGHT_DEBUG_PORT: debugPort.toString(), // Unique port per test for parallel execution
RUST_LOG: 'info', // Enable info-level logging for goosed backend
}
});
// Log process output for debugging
if (process.env.DEBUG_TESTS) {
appProcess.stdout?.on('data', (data) => {
console.log('App stdout:', data.toString());
});
appProcess.stderr?.on('data', (data) => {
console.log('App stderr:', data.toString());
});
}
// Wait for the app to start and remote debugging to be available
// Retry connection until it succeeds (app is ready) or timeout
console.log(`Waiting for Electron app to start on port ${debugPort}...`);
const maxRetries = 100; // 100 retries * 100ms = 10 seconds max
const retryDelay = 100; // 100ms between retries
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
browser = await chromium.connectOverCDP(`http://127.0.0.1:${debugPort}`);
console.log(`Connected to Electron app on attempt ${attempt} (~${(attempt * retryDelay) / 1000}s)`);
break;
} catch (error) {
if (attempt === maxRetries) {
throw new Error(`Failed to connect to Electron app after ${maxRetries} attempts (${(maxRetries * retryDelay) / 1000}s). Last error: ${error.message}`);
}
// Wait before next retry
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
if (!browser) {
throw new Error('Browser connection failed unexpectedly');
}
// Get the electron app context and first page
const contexts = browser.contexts();
if (contexts.length === 0) {
throw new Error('No browser contexts found');
}
const pages = contexts[0].pages();
if (pages.length === 0) {
throw new Error('No windows/pages found');
}
const page = pages[0];
// Wait for page to be ready
await page.waitForLoadState('domcontentloaded');
// Try to wait for networkidle
try {
await page.waitForLoadState('networkidle', { timeout: 10000 });
} catch (error) {
console.log('NetworkIdle timeout (likely due to MCP activity), continuing...');
}
// Wait for React app to be ready
await page.waitForFunction(() => {
const root = document.getElementById('root');
return root && root.children.length > 0;
}, { timeout: 30000 });
console.log('App ready, starting test...');
// Provide the page to the test
await use(page);
} finally {
console.log('Cleaning up Electron app for this test...');
// Close the CDP connection
if (browser) {
await browser.close().catch(console.error);
}
// Kill the npm process tree
if (appProcess && appProcess.pid) {
try {
if (process.platform === 'win32') {
// On Windows, kill the entire process tree
await execAsync(`taskkill /F /T /PID ${appProcess.pid}`);
} else {
// On Unix, kill the entire process group
try {
// First try SIGTERM for graceful shutdown
process.kill(-appProcess.pid, 'SIGTERM');
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (e) {
// Process might already be dead
}
// Then SIGKILL if still running
try {
process.kill(-appProcess.pid, 'SIGKILL');
} catch (e) {
// Process already exited
}
}
console.log('Cleaned up app process');
} catch (error) {
if (error.code !== 'ESRCH' && !error.message?.includes('No such process')) {
console.error('Error killing app process:', error);
}
}
}
}
},
});
export { expect } from '@playwright/test';