整合 Enter 发送与多会话交互,保留服务端持久 Conversation Session,并补齐迁移、回归、Electron E2E 与 canonical 文档。
284 lines
7.4 KiB
TypeScript
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 };
|