项目文件夹

文件
2026-07-13 13:20:22 +08:00

365 行
12 KiB
TypeScript

/**
* Playwright + Electron test fixtures.
*
* Launches the Electron app once and shares the window across tests.
*
* Two modes:
* 1. **Packaged mode** (CI default): Launches from electron-builder's unpacked output
* (e.g. out/linux-unpacked/aionui, out/mac-arm64/AionUi.app, out/win-unpacked/AionUi.exe).
* This validates that packaged resources are intact.
* 2. **Dev mode** (local default): Launches via `electron .` from project root with
* the Vite dev server (electron-vite dev).
*
* Set `E2E_PACKAGED=1` to force packaged mode, or `E2E_DEV=1` to force dev mode.
*/
import { test as base, expect, type ElectronApplication, type Page, type TestInfo } from '@playwright/test';
import { _electron as electron } from 'playwright';
import path from 'path';
import fs from 'fs';
import os from 'os';
type Fixtures = {
electronApp: ElectronApplication;
page: Page;
};
type RendererDiagnostic = {
type: 'console' | 'pageerror' | 'requestfailed';
text: string;
};
// Singleton – one app per test worker
let app: ElectronApplication | null = null;
let mainPage: Page | null = null;
const e2eStateSandboxDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aionui-e2e-state-'));
const e2eStateFile = path.join(e2eStateSandboxDir, 'extension-states.json');
// Disposable userData root so AionCore migrates a fresh DB per run instead of
// touching the developer's real database (a shared DB that fails migration
// blocks the whole app from booting). Consumed by configureChromium.ts.
const e2eUserDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aionui-e2e-userdata-'));
const rendererDiagnostics = new WeakMap<Page, RendererDiagnostic[]>();
function isDevToolsWindow(page: Page): boolean {
return page.url().startsWith('devtools://');
}
function attachRendererDiagnostics(page: Page): void {
if (rendererDiagnostics.has(page)) return;
const diagnostics: RendererDiagnostic[] = [];
rendererDiagnostics.set(page, diagnostics);
page.on('console', (message) => {
if (!['error', 'warning'].includes(message.type())) return;
diagnostics.push({ type: 'console', text: `${message.type()}: ${message.text()}` });
});
page.on('pageerror', (error) => {
diagnostics.push({ type: 'pageerror', text: error.stack || error.message });
});
page.on('requestfailed', (request) => {
const failure = request.failure()?.errorText ?? 'unknown';
diagnostics.push({ type: 'requestfailed', text: `${request.url()} - ${failure}` });
});
}
async function getRendererReadinessSnapshot(page: Page): Promise<Record<string, unknown>> {
return page.evaluate(() => {
const root = document.querySelector('#root');
const scripts = Array.from(document.scripts)
.map((script) => script.src || script.getAttribute('src') || '')
.filter(Boolean);
const stylesheets = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
.map((link) => (link as HTMLLinkElement).href || link.getAttribute('href') || '')
.filter(Boolean);
return {
href: window.location.href,
title: document.title,
readyState: document.readyState,
bodyTextLength: document.body?.innerText?.trim().length ?? 0,
bodyHtmlSample: document.body?.innerHTML?.slice(0, 300) ?? '',
rootExists: Boolean(root),
rootChildCount: root?.children.length ?? -1,
scriptCount: scripts.length,
stylesheetCount: stylesheets.length,
scripts,
stylesheets,
};
});
}
async function ensureRendererAppMounted(page: Page): Promise<void> {
attachRendererDiagnostics(page);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
try {
await page.waitForFunction(
() => {
const root = document.querySelector('#root');
return Boolean(root && root.children.length > 0 && document.scripts.length > 0);
},
undefined,
{ timeout: 30_000 }
);
} catch (error) {
const snapshot = await getRendererReadinessSnapshot(page).catch((snapshotError: unknown) => ({
snapshotError: snapshotError instanceof Error ? snapshotError.message : String(snapshotError),
}));
const diagnostics = rendererDiagnostics.get(page)?.slice(-20) ?? [];
throw new Error(
[
'Electron renderer did not mount a non-empty app root.',
`Wait failure: ${error instanceof Error ? error.message : String(error)}`,
`Snapshot: ${JSON.stringify(snapshot, null, 2)}`,
`Diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
].join('\n'),
{ cause: error }
);
}
}
async function resolveMainWindow(electronApp: ElectronApplication): Promise<Page> {
const existingMainWindow = electronApp.windows().find((win) => !isDevToolsWindow(win));
if (existingMainWindow) {
await ensureRendererAppMounted(existingMainWindow);
return existingMainWindow;
}
const resolveWindowBefore = async (deadline: number): Promise<Page> => {
if (Date.now() >= deadline) {
throw new Error('Failed to resolve main renderer window (non-DevTools).');
}
const win = await electronApp.waitForEvent('window', { timeout: 1_000 }).catch(() => null);
if (win && !isDevToolsWindow(win)) {
await ensureRendererAppMounted(win);
return win;
}
return resolveWindowBefore(deadline);
};
return resolveWindowBefore(Date.now() + 30_000);
}
/**
* Resolve the path to the packaged Electron executable under out/.
* Returns { executablePath, cwd } or null if not found.
*/
function resolvePackagedApp(): { executablePath: string; cwd: string } | null {
const projectRoot = path.resolve(__dirname, '../..');
const outDir = path.join(projectRoot, 'out');
if (!fs.existsSync(outDir)) return null;
const platform = process.platform;
if (platform === 'win32') {
// out/win-unpacked/AionUi.exe or out/win-x64-unpacked/AionUi.exe
for (const dir of ['win-unpacked', 'win-x64-unpacked', 'win-arm64-unpacked']) {
const exe = path.join(outDir, dir, 'AionUi.exe');
if (fs.existsSync(exe)) return { executablePath: exe, cwd: path.join(outDir, dir) };
}
} else if (platform === 'darwin') {
// out/mac-arm64/AionUi.app/Contents/MacOS/AionUi or out/mac/AionUi.app/...
for (const dir of ['mac-arm64', 'mac-x64', 'mac', 'mac-universal']) {
const macDir = path.join(outDir, dir);
if (!fs.existsSync(macDir)) continue;
const appBundle = fs.readdirSync(macDir).find((f) => f.endsWith('.app'));
if (appBundle) {
const exe = path.join(macDir, appBundle, 'Contents', 'MacOS', 'AionUi');
if (fs.existsSync(exe)) return { executablePath: exe, cwd: macDir };
}
}
} else {
// Linux: out/linux-unpacked/aionui (lowercase executable name)
for (const dir of ['linux-unpacked', 'linux-x64-unpacked', 'linux-arm64-unpacked']) {
const dirPath = path.join(outDir, dir);
if (!fs.existsSync(dirPath)) continue;
// Try common executable names
for (const name of ['aionui', 'AionUi']) {
const exe = path.join(dirPath, name);
if (fs.existsSync(exe)) return { executablePath: exe, cwd: dirPath };
}
}
}
return null;
}
function shouldUsePackagedMode(): boolean {
if (process.env.E2E_PACKAGED === '1') return true;
if (process.env.E2E_DEV === '1') return false;
// Default: packaged in CI, dev locally
return !!process.env.CI;
}
async function launchApp(): Promise<ElectronApplication> {
const projectRoot = path.resolve(__dirname, '../..');
const usePackaged = shouldUsePackagedMode();
const commonEnv = {
...process.env,
AIONUI_EXTENSIONS_PATH: process.env.AIONUI_EXTENSIONS_PATH || path.join(projectRoot, 'examples'),
AIONUI_EXTENSION_STATES_FILE: process.env.AIONUI_EXTENSION_STATES_FILE || e2eStateFile,
AIONUI_DISABLE_AUTO_UPDATE: '1',
AIONUI_DISABLE_DEVTOOLS: '1',
AIONUI_E2E_TEST: '1',
AIONUI_E2E_USER_DATA_DIR: process.env.AIONUI_E2E_USER_DATA_DIR || e2eUserDataDir,
AIONUI_CDP_PORT: '0',
};
if (usePackaged) {
const packaged = resolvePackagedApp();
if (!packaged) {
throw new Error(
'E2E packaged mode: could not find packaged app under out/. ' +
'Run `node scripts/build-with-builder.js auto --<platform> --pack-only` first.'
);
}
console.log(`[E2E] Launching PACKAGED app: ${packaged.executablePath}`);
const launchArgs: string[] = [];
if (process.platform === 'linux' && process.env.CI) {
launchArgs.push('--no-sandbox');
}
const electronApp = await electron.launch({
executablePath: packaged.executablePath,
args: launchArgs,
cwd: packaged.cwd,
env: {
...commonEnv,
NODE_ENV: 'production',
},
timeout: 60_000,
});
return electronApp;
}
// Dev mode: launch via electron .
console.log(`[E2E] Launching DEV app from: ${projectRoot}`);
const launchArgs = ['.'];
if (process.platform === 'linux' && process.env.CI) {
launchArgs.push('--no-sandbox');
}
const electronApp = await electron.launch({
args: launchArgs,
cwd: projectRoot,
env: {
...commonEnv,
NODE_ENV: 'development',
},
timeout: 60_000,
});
return electronApp;
}
export const test = base.extend<Fixtures>({
// eslint-disable-next-line no-empty-pattern
electronApp: async ({}, use) => {
if (!app) {
app = await launchApp();
}
// Verify the app process is still alive; relaunch if it crashed
try {
await app.evaluate(() => true);
} catch {
console.log('[E2E] App process lost – relaunching...');
app = await launchApp();
mainPage = null; // force window re-resolution
}
await use(app);
},
page: async ({ electronApp }, use, testInfo: TestInfo) => {
if (!mainPage || mainPage.isClosed() || isDevToolsWindow(mainPage)) {
mainPage = await resolveMainWindow(electronApp);
}
// Only wait for DOM when the page is brand-new or was replaced.
// For an already-resolved page, skip the expensive waitForLoadState
// to speed up consecutive tests sharing the same window.
try {
if (mainPage.url() === 'about:blank' || mainPage.url() === '') {
await ensureRendererAppMounted(mainPage);
}
} catch {
// Page may have been replaced – resolve again
mainPage = await resolveMainWindow(electronApp);
}
if (mainPage.isClosed()) {
mainPage = await resolveMainWindow(electronApp);
}
await ensureRendererAppMounted(mainPage);
await use(mainPage);
// Attach screenshot on failure so it appears in the HTML report.
// Playwright's built-in `screenshot: 'only-on-failure'` relies on its
// own `page` fixture, which we override for Electron — so we do it manually.
if (testInfo.status !== testInfo.expectedStatus && mainPage && !mainPage.isClosed()) {
try {
const screenshot = await mainPage.screenshot();
await testInfo.attach('screenshot-on-failure', {
body: screenshot,
contentType: 'image/png',
});
} catch {
// best-effort: page may have crashed
}
}
},
});
// ── Cleanup ──────────────────────────────────────────────────────────────────
// IMPORTANT: Do NOT use `test.afterAll` here. Playwright runs afterAll at the
// end of **every** test.describe block, which would close and relaunch the
// Electron app between describe blocks — each relaunch costs ~25-30 seconds.
//
// Instead, register a one-time process exit handler so the singleton app stays
// alive for the entire worker lifetime (all spec files, all describe blocks).
let cleanupRegistered = false;
function registerCleanup(): void {
if (cleanupRegistered) return;
cleanupRegistered = true;
// Async cleanup before the worker process exits
process.on('beforeExit', async () => {
if (app) {
try {
await app.evaluate(async ({ app: electronApp }) => {
electronApp.exit(0);
});
} catch {
// ignore: app may already be closed
}
await app.close().catch(() => {});
app = null;
mainPage = null;
}
fs.rmSync(e2eStateSandboxDir, { recursive: true, force: true });
fs.rmSync(e2eUserDataDir, { recursive: true, force: true });
});
// Synchronous fallback for abrupt termination
process.on('exit', () => {
try {
fs.rmSync(e2eStateSandboxDir, { recursive: true, force: true });
fs.rmSync(e2eUserDataDir, { recursive: true, force: true });
} catch {
// best-effort
}
});
}
registerCleanup();
export { expect };