From 9f8e5c0eb36edd66c86c79dfb736ef47e11d36bb Mon Sep 17 00:00:00 2001 From: DarkSky Date: Tue, 7 Jul 2026 12:26:56 +0800 Subject: [PATCH] chore: improve test stability --- tests/affine-desktop/e2e/split-view.spec.ts | 48 +++++++-- tests/kit/src/electron.ts | 102 +++++++++++++++----- tests/kit/src/utils/app-tabs.ts | 8 +- 3 files changed, 122 insertions(+), 36 deletions(-) diff --git a/tests/affine-desktop/e2e/split-view.spec.ts b/tests/affine-desktop/e2e/split-view.spec.ts index d9765abad5..5b553bc375 100644 --- a/tests/affine-desktop/e2e/split-view.spec.ts +++ b/tests/affine-desktop/e2e/split-view.spec.ts @@ -13,6 +13,8 @@ import { import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; import { expect } from '@playwright/test'; +const SPLIT_VIEW_READY_TIMEOUT = 30_000; + test('open split view', async ({ page }) => { await clickNewPageButton(page); await page.waitForTimeout(500); @@ -23,11 +25,20 @@ test('open split view', async ({ page }) => { .click({ modifiers: ['ControlOrMeta', 'Alt'], }); - await expect(page.locator('.doc-title-container')).toHaveCount(2); + await expect(page.locator('.doc-title-container')).toHaveCount(2, { + timeout: SPLIT_VIEW_READY_TIMEOUT, + }); // check tab title - await expect(page.getByTestId('split-view-label')).toHaveCount(2); - await expectTabTitle(page, 0, ['Untitled', 'hi from another page']); + await expect(page.getByTestId('split-view-label')).toHaveCount(2, { + timeout: SPLIT_VIEW_READY_TIMEOUT, + }); + await expectTabTitle( + page, + 0, + ['Untitled', 'hi from another page'], + SPLIT_VIEW_READY_TIMEOUT + ); // the first split view should be active await expectActiveTab(page, 0, 0); @@ -53,7 +64,12 @@ test('open split view', async ({ page }) => { true ); - await expectTabTitle(page, 0, ['hi from another page', 'Untitled']); + await expectTabTitle( + page, + 0, + ['hi from another page', 'Untitled'], + SPLIT_VIEW_READY_TIMEOUT + ); }); test('open split view in all docs (operations button)', async ({ page }) => { @@ -65,12 +81,17 @@ test('open split view in all docs (operations button)', async ({ page }) => { .getByTestId('doc-list-operation-button') .click(); await page.getByRole('menuitem', { name: 'Open in split view' }).click(); - await expect(page.getByTestId('split-view-panel')).toHaveCount(2); + await expect(page.getByTestId('split-view-panel')).toHaveCount(2, { + timeout: SPLIT_VIEW_READY_TIMEOUT, + }); const targetPage = page.getByTestId('split-view-panel').last(); await expect(targetPage).toHaveAttribute('data-is-active', 'true'); - await expect(targetPage.locator('.doc-title-container')).toBeVisible(); + await expect(targetPage.locator('.doc-title-container')).toBeVisible({ + timeout: SPLIT_VIEW_READY_TIMEOUT, + }); await expect(targetPage.locator('.doc-title-container')).toContainText( - testTitle + testTitle, + { timeout: SPLIT_VIEW_READY_TIMEOUT } ); }); @@ -87,7 +108,12 @@ test('open split view in all docs (drag to resize handle)', async ({ const leftResizeHandle = page.getByTestId('resize-handle').first(); await dragTo(page, pageItem, leftResizeHandle, 'center'); - await expectTabTitle(page, 0, ['test-page', 'All docs']); + await expectTabTitle( + page, + 0, + ['test-page', 'All docs'], + SPLIT_VIEW_READY_TIMEOUT + ); }); test('creating split view by dragging sidebar journals', async ({ page }) => { @@ -95,7 +121,9 @@ test('creating split view by dragging sidebar journals', async ({ page }) => { const leftResizeHandle = page.getByTestId('resize-handle').first(); await dragTo(page, journalButton, leftResizeHandle, 'center'); - await expect(page.getByTestId('split-view-panel')).toHaveCount(2); + await expect(page.getByTestId('split-view-panel')).toHaveCount(2, { + timeout: SPLIT_VIEW_READY_TIMEOUT, + }); await expect( page .getByTestId('split-view-panel') @@ -103,5 +131,5 @@ test('creating split view by dragging sidebar journals', async ({ page }) => { has: page.locator('[data-is-first="true"]'), }) .getByTestId('date-today-label') - ).toBeVisible(); + ).toBeVisible({ timeout: SPLIT_VIEW_READY_TIMEOUT }); }); diff --git a/tests/kit/src/electron.ts b/tests/kit/src/electron.ts index 59bf28550f..622015bf43 100644 --- a/tests/kit/src/electron.ts +++ b/tests/kit/src/electron.ts @@ -13,6 +13,7 @@ import { test as base } from './playwright'; import { removeWithRetry } from './utils/utils'; const electronRoot = new Package('@affine/electron').path; +const initialActivePages = new WeakMap(); const treeKillAsync = (pid: number, signal: NodeJS.Signals) => new Promise((resolve, reject) => { @@ -73,6 +74,15 @@ const releaseChildProcessHandles = (child: ChildProcess) => { } }; +const waitForChildProcessExit = (child: ChildProcess) => + new Promise(resolve => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once('exit', () => resolve()); + }); + const withTimeoutFallback = async ( promise: Promise, fallback: T, @@ -157,6 +167,21 @@ const getShellPage = async (pages: Page[]) => { return null; }; +const getElectronPages = (electronApp: ElectronApplication) => { + const pages = new Set(); + for (const page of electronApp.windows()) { + if (!page.isClosed()) { + pages.add(page); + } + } + for (const page of electronApp.context().pages()) { + if (!page.isClosed()) { + pages.add(page); + } + } + return [...pages]; +}; + const waitForElectronPage = async ( electronApp: ElectronApplication, label: string, @@ -167,7 +192,7 @@ const waitForElectronPage = async ( (process.env.CI && process.platform === 'darwin' ? 25_000 : 20_000); while (Date.now() < deadline) { - const page = await getPage(electronApp.windows()); + const page = await getPage(getElectronPages(electronApp)); if (page) { return page; } @@ -188,14 +213,6 @@ const cleanupElectronApp = async (electronApp: ElectronApplication) => { } electronApp.once('close', () => resolve()); }); - const waitForProcessExit = () => - new Promise(resolve => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - child.once('exit', () => resolve()); - }); const killProcess = () => { try { @@ -205,7 +222,7 @@ const cleanupElectronApp = async (electronApp: ElectronApplication) => { const closeWithTimeout = async () => { const closeEvent = waitForAppClose(); - const processExit = waitForProcessExit(); + const processExit = waitForChildProcessExit(child); const pid = child.pid; void electronApp.close().catch(() => {}); const controller = new AbortController(); @@ -243,7 +260,7 @@ const cleanupElectronApp = async (electronApp: ElectronApplication) => { if (process.env.CI && process.platform === 'linux') { const pid = child.pid; const closeEvent = waitForAppClose(); - const processExit = waitForProcessExit(); + const processExit = waitForChildProcessExit(child); await Promise.race([ Promise.all([ @@ -273,6 +290,21 @@ const cleanupElectronApp = async (electronApp: ElectronApplication) => { await closeWithTimeout(); }; +const forceKillElectronApp = async (electronApp: ElectronApplication) => { + const child = electronApp.process(); + + const pid = child.pid; + if (pid !== undefined) { + await treeKillAsync(pid, 'SIGKILL').catch(() => child.kill()); + } else { + child.kill(); + } + await Promise.race([waitForChildProcessExit(child), setTimeout(5_000)]).catch( + () => {} + ); + releaseChildProcessHandles(child); +}; + export const test = base.extend<{ electronApp: ElectronApplication; shell: Page; @@ -298,11 +330,11 @@ export const test = base.extend<{ await use(shell); }, page: async ({ electronApp }, use) => { - const page = await waitForElectronPage( - electronApp, - 'active page', - getActivePage - ); + const cached = initialActivePages.get(electronApp); + const page = + cached && !cached.isClosed() + ? cached + : await waitForElectronPage(electronApp, 'active page', getActivePage); await page.waitForSelector('v-line'); @@ -312,7 +344,7 @@ export const test = base.extend<{ void page; await use({ getActive: async () => { - const view = await getActivePage(electronApp.windows()); + const view = await getActivePage(getElectronPages(electronApp)); return view || page; }, }); @@ -330,6 +362,7 @@ export const test = base.extend<{ electronRoot.join('package.json').value ); packageJson.name = '@affine/electron-test-' + id; + packageJson.productName = 'AFFiNE Test ' + id; packageJson.main = './main.js'; await fs.writeJSON(clonedDist + '/package.json', packageJson); @@ -342,12 +375,35 @@ export const test = base.extend<{ env.DEBUG = 'pw:browser'; env.SKIP_ONBOARDING = '1'; - electronApp = await electron.launch({ - args: [clonedDist], - env, - cwd: clonedDist, - colorScheme: 'light', - }); + const launch = () => + electron.launch({ + args: [clonedDist], + env, + cwd: clonedDist, + colorScheme: 'light', + }); + + for (let attempt = 0; attempt < 2; attempt++) { + electronApp = await launch(); + try { + const page = await waitForElectronPage( + electronApp, + 'active page', + getActivePage + ); + initialActivePages.set(electronApp, page); + break; + } catch (error) { + if (attempt > 0) { + throw error; + } + await forceKillElectronApp(electronApp); + electronApp = undefined; + } + } + if (!electronApp) { + throw new Error('Failed to launch electron app'); + } await use(electronApp); } finally { diff --git a/tests/kit/src/utils/app-tabs.ts b/tests/kit/src/utils/app-tabs.ts index 32c42b734b..edc6bb268e 100644 --- a/tests/kit/src/utils/app-tabs.ts +++ b/tests/kit/src/utils/app-tabs.ts @@ -18,11 +18,13 @@ export async function expectActiveTab( export async function expectTabTitle( page: Page, index: number, - title: string | string[] + title: string | string[], + timeout?: number ) { if (typeof title === 'string') { await expect(page.getByTestId('workbench-tab').nth(index)).toContainText( - title + title, + { timeout } ); } else { for (let i = 0; i < title.length; i++) { @@ -32,7 +34,7 @@ export async function expectTabTitle( .nth(index) .getByTestId('split-view-label') .nth(i) - ).toContainText(title[i]); + ).toContainText(title[i], { timeout }); } } }