257 lines
7.9 KiB
TypeScript
257 lines
7.9 KiB
TypeScript
import electronBinaryPath from 'electron';
|
|
import { _electron as electron, expect, test as base, type ElectronApplication, type Page } from '@playwright/test';
|
|
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
import { createServer } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
|
|
type LaunchElectronOptions = {
|
|
skipSetup?: boolean;
|
|
};
|
|
|
|
type IpcMockConfig = {
|
|
gatewayStatus?: Record<string, unknown>;
|
|
gatewayRpc?: Record<string, unknown>;
|
|
hostApi?: Record<string, unknown>;
|
|
};
|
|
|
|
type ElectronFixtures = {
|
|
electronApp: ElectronApplication;
|
|
page: Page;
|
|
homeDir: string;
|
|
userDataDir: string;
|
|
launchElectronApp: (options?: LaunchElectronOptions) => Promise<ElectronApplication>;
|
|
};
|
|
|
|
const repoRoot = resolve(process.cwd());
|
|
const electronEntry = join(repoRoot, 'dist-electron/main/index.js');
|
|
|
|
async function allocatePort(): Promise<number> {
|
|
return await new Promise((resolvePort, reject) => {
|
|
const server = createServer();
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
server.close(() => reject(new Error('Failed to allocate an ephemeral port')));
|
|
return;
|
|
}
|
|
const { port } = address;
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolvePort(port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function getStableWindow(app: ElectronApplication): Promise<Page> {
|
|
const deadline = Date.now() + 30_000;
|
|
let page = await app.firstWindow();
|
|
|
|
while (Date.now() < deadline) {
|
|
const openWindows = app.windows().filter((candidate) => !candidate.isClosed());
|
|
const currentWindow = openWindows.at(-1) ?? page;
|
|
|
|
if (currentWindow && !currentWindow.isClosed()) {
|
|
try {
|
|
await currentWindow.waitForLoadState('domcontentloaded', { timeout: 2_000 });
|
|
return currentWindow;
|
|
} catch (error) {
|
|
if (!String(error).includes('has been closed')) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
page = await app.waitForEvent('window', { timeout: 2_000 });
|
|
} catch {
|
|
// Keep polling until a stable window is available or the deadline expires.
|
|
}
|
|
}
|
|
|
|
throw new Error('No stable Electron window became available');
|
|
}
|
|
|
|
async function closeElectronApp(app: ElectronApplication, timeoutMs = 5_000): Promise<void> {
|
|
let closed = false;
|
|
|
|
await Promise.race([
|
|
(async () => {
|
|
const [closeResult] = await Promise.allSettled([
|
|
app.waitForEvent('close', { timeout: timeoutMs }),
|
|
app.evaluate(({ app: electronApp }) => {
|
|
electronApp.quit();
|
|
}),
|
|
]);
|
|
|
|
if (closeResult.status === 'fulfilled') {
|
|
closed = true;
|
|
}
|
|
})(),
|
|
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
]);
|
|
|
|
if (closed) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await app.close();
|
|
return;
|
|
} catch {
|
|
// Fall through to process kill if Playwright cannot close the app cleanly.
|
|
}
|
|
|
|
try {
|
|
app.process().kill('SIGKILL');
|
|
} catch {
|
|
// Ignore process kill failures during e2e teardown.
|
|
}
|
|
}
|
|
|
|
async function launchClawXElectron(
|
|
homeDir: string,
|
|
userDataDir: string,
|
|
options: LaunchElectronOptions = {},
|
|
): Promise<ElectronApplication> {
|
|
const hostApiPort = await allocatePort();
|
|
const electronEnv = process.platform === 'linux'
|
|
? { ELECTRON_DISABLE_SANDBOX: '1' }
|
|
: {};
|
|
return await electron.launch({
|
|
executablePath: electronBinaryPath,
|
|
args: [electronEntry],
|
|
env: {
|
|
...process.env,
|
|
...electronEnv,
|
|
HOME: homeDir,
|
|
USERPROFILE: homeDir,
|
|
APPDATA: join(homeDir, 'AppData', 'Roaming'),
|
|
LOCALAPPDATA: join(homeDir, 'AppData', 'Local'),
|
|
XDG_CONFIG_HOME: join(homeDir, '.config'),
|
|
CLAWX_E2E: '1',
|
|
CLAWX_USER_DATA_DIR: userDataDir,
|
|
...(options.skipSetup ? { CLAWX_E2E_SKIP_SETUP: '1' } : {}),
|
|
CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort),
|
|
},
|
|
timeout: 90_000,
|
|
});
|
|
}
|
|
|
|
export const test = base.extend<ElectronFixtures>({
|
|
homeDir: async ({ browserName: _browserName }, provideHomeDir) => {
|
|
const homeDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-home-'));
|
|
await mkdir(join(homeDir, '.config'), { recursive: true });
|
|
await mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true });
|
|
await mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true });
|
|
try {
|
|
await provideHomeDir(homeDir);
|
|
} finally {
|
|
await rm(homeDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
|
|
userDataDir: async ({ browserName: _browserName }, provideUserDataDir) => {
|
|
const userDataDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-user-data-'));
|
|
try {
|
|
await provideUserDataDir(userDataDir);
|
|
} finally {
|
|
await rm(userDataDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
|
|
launchElectronApp: async ({ homeDir, userDataDir }, provideLauncher) => {
|
|
await provideLauncher(async (options?: LaunchElectronOptions) => await launchClawXElectron(homeDir, userDataDir, options));
|
|
},
|
|
|
|
electronApp: async ({ launchElectronApp }, provideElectronApp) => {
|
|
const app = await launchElectronApp();
|
|
let appClosed = false;
|
|
app.once('close', () => {
|
|
appClosed = true;
|
|
});
|
|
|
|
try {
|
|
await provideElectronApp(app);
|
|
} finally {
|
|
if (!appClosed) {
|
|
await closeElectronApp(app);
|
|
}
|
|
}
|
|
},
|
|
|
|
page: async ({ electronApp }, providePage) => {
|
|
const page = await getStableWindow(electronApp);
|
|
await providePage(page);
|
|
},
|
|
});
|
|
|
|
export async function completeSetup(page: Page): Promise<void> {
|
|
await expect(page.getByTestId('setup-page')).toBeVisible();
|
|
await page.getByTestId('setup-skip-button').click();
|
|
await expect(page.getByTestId('main-layout')).toBeVisible();
|
|
}
|
|
|
|
export { closeElectronApp };
|
|
export { getStableWindow };
|
|
export { expect };
|
|
|
|
export async function installIpcMocks(
|
|
app: ElectronApplication,
|
|
config: IpcMockConfig,
|
|
): Promise<void> {
|
|
await app.evaluate(
|
|
async ({ app: _app }, mockConfig) => {
|
|
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
|
const stableStringify = (value: unknown): string => {
|
|
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
|
return `{${entries.join(',')}}`;
|
|
};
|
|
|
|
if (mockConfig.gatewayRpc) {
|
|
ipcMain.removeHandler('gateway:rpc');
|
|
ipcMain.handle('gateway:rpc', async (_event: unknown, method: string, payload: unknown) => {
|
|
const key = stableStringify([method, payload ?? null]);
|
|
if (key in mockConfig.gatewayRpc!) {
|
|
return mockConfig.gatewayRpc![key];
|
|
}
|
|
const fallbackKey = stableStringify([method, null]);
|
|
if (fallbackKey in mockConfig.gatewayRpc!) {
|
|
return mockConfig.gatewayRpc![fallbackKey];
|
|
}
|
|
return { success: true, result: {} };
|
|
});
|
|
}
|
|
|
|
if (mockConfig.hostApi) {
|
|
ipcMain.removeHandler('hostapi:fetch');
|
|
ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string }) => {
|
|
const key = stableStringify([request?.path ?? '', request?.method ?? 'GET']);
|
|
if (key in mockConfig.hostApi!) {
|
|
return mockConfig.hostApi![key];
|
|
}
|
|
return {
|
|
ok: true,
|
|
data: { status: 200, ok: true, json: {} },
|
|
};
|
|
});
|
|
}
|
|
|
|
if (mockConfig.gatewayStatus) {
|
|
ipcMain.removeHandler('gateway:status');
|
|
ipcMain.handle('gateway:status', async () => mockConfig.gatewayStatus);
|
|
}
|
|
},
|
|
config,
|
|
);
|
|
}
|