Files
2026-07-13 13:20:22 +08:00

365 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };