Files
makelore/tests/e2e/fixtures/electron.ts
brother7 be9bc84474
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
合并 AI 设计多会话客户端
整合 Enter 发送与多会话交互,保留服务端持久 Conversation Session,并补齐迁移、回归、Electron E2E 与 canonical 文档。
2026-08-11 16:18:52 +08:00

284 lines
7.4 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 { basename, join, resolve } from 'node:path';
export type LaunchElectronOptions = {
imageWorkspaceMode?: 'local';
skipSetup?: boolean;
};
type ElectronTestResources = {
apps: Set<ElectronApplication>;
defaultSkipSetup: boolean;
homeDir: string;
rootDir: string;
userDataDir: string;
};
type ElectronFixtures = {
electronApp: ElectronApplication;
electronTestResources: ElectronTestResources;
launchElectronApp: (options?: LaunchElectronOptions) => Promise<ElectronApplication>;
page: Page;
};
const repoRoot = resolve(process.cwd());
const electronEntry = join(repoRoot, 'dist-electron/main/index.js');
const PASSTHROUGH_ENVIRONMENT_KEYS = [
'PATH',
'Path',
'PATHEXT',
'SystemRoot',
'SYSTEMROOT',
'WINDIR',
'COMSPEC',
'TEMP',
'TMP',
'TMPDIR',
'LANG',
'LC_ALL',
'DISPLAY',
'XAUTHORITY',
'WAYLAND_DISPLAY',
'XDG_RUNTIME_DIR',
'DBUS_SESSION_BUS_ADDRESS',
'CI',
] as const;
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;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolvePort(address.port);
});
});
});
}
function buildElectronEnvironment(
resources: ElectronTestResources,
skipSetup: boolean,
hostApiPort: number,
imageWorkspaceMode?: 'local',
): Record<string, string> {
const env: Record<string, string> = {};
for (const key of PASSTHROUGH_ENVIRONMENT_KEYS) {
const value = process.env[key];
if (value !== undefined) {
env[key] = value;
}
}
Object.assign(env, {
HOME: resources.homeDir,
USERPROFILE: resources.homeDir,
APPDATA: join(resources.homeDir, 'AppData', 'Roaming'),
LOCALAPPDATA: join(resources.homeDir, 'AppData', 'Local'),
XDG_CONFIG_HOME: join(resources.homeDir, '.config'),
NIANCODE_E2E: '1',
NIANCODE_USER_DATA_DIR: resources.userDataDir,
NIANCODE_PORT_NIANCODE_HOST_API: String(hostApiPort),
});
delete env.NIANCODE_E2E_SKIP_SETUP;
if (skipSetup) {
env.NIANCODE_E2E_SKIP_SETUP = '1';
}
if (imageWorkspaceMode) {
env.NIANCODE_IMAGE_WORKSPACE_MODE = imageWorkspaceMode;
}
if (process.platform === 'linux') {
env.ELECTRON_DISABLE_SANDBOX = '1';
}
return env;
}
async function launchMakeloreElectron(
resources: ElectronTestResources,
options: LaunchElectronOptions,
): Promise<ElectronApplication> {
const hostApiPort = await allocatePort();
const skipSetup = options.skipSetup ?? resources.defaultSkipSetup;
const app = await electron.launch({
executablePath: electronBinaryPath as unknown as string,
args: [electronEntry],
env: buildElectronEnvironment(
resources,
skipSetup,
hostApiPort,
options.imageWorkspaceMode,
),
timeout: 90_000,
});
resources.apps.add(app);
app.once('close', () => {
resources.apps.delete(app);
});
return app;
}
export 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.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');
}
export 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();
}),
]);
closed = closeResult.status === 'fulfilled';
})(),
new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutMs)),
]);
if (closed) {
return;
}
const playwrightClosed = await Promise.race([
app.close().then(() => true).catch(() => false),
new Promise<boolean>((resolveTimeout) => {
setTimeout(() => resolveTimeout(false), timeoutMs);
}),
]);
if (playwrightClosed) {
return;
}
const childProcess = app.process();
try {
childProcess.kill('SIGKILL');
} catch {
// The process may already have exited during teardown.
}
if (childProcess.exitCode === null) {
await Promise.race([
new Promise<void>((resolveExit) => {
childProcess.once('exit', () => resolveExit());
}),
new Promise<void>((resolveTimeout) => {
setTimeout(resolveTimeout, timeoutMs);
}),
]);
}
}
export const test = base.extend<ElectronFixtures>({
electronTestResources: async ({ browserName: _browserName }, provideResources, testInfo) => {
const rootDir = await mkdtemp(join(tmpdir(), 'makelore-e2e-'));
const homeDir = join(rootDir, 'home');
const userDataDir = join(rootDir, 'user-data');
await Promise.all([
mkdir(join(homeDir, '.config'), { recursive: true }),
mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true }),
mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true }),
mkdir(userDataDir, { recursive: true }),
]);
const resources: ElectronTestResources = {
apps: new Set(),
defaultSkipSetup: ![
'app-smoke.spec.ts',
'language-russian.spec.ts',
].includes(basename(testInfo.file)),
homeDir,
rootDir,
userDataDir,
};
try {
await provideResources(resources);
} finally {
await Promise.allSettled([...resources.apps].map((app) => closeElectronApp(app)));
await rm(rootDir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 200,
});
}
},
launchElectronApp: async ({ electronTestResources }, provideLauncher) => {
await provideLauncher(async (options = {}) => {
return await launchMakeloreElectron(electronTestResources, options);
});
},
electronApp: async ({ launchElectronApp }, provideElectronApp) => {
await provideElectronApp(await launchElectronApp());
},
page: async ({ electronApp }, providePage) => {
await providePage(await getStableWindow(electronApp));
},
});
export async function completeSetup(page: Page): Promise<void> {
const setupPage = page.getByTestId('setup-page');
if (await setupPage.isVisible()) {
await page.getByTestId('setup-skip-button').click();
}
await expect(page.getByTestId('main-layout')).toBeVisible();
}
export { expect };