iofficeai--aionui
365 行
12 KiB
TypeScript
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 };
|