From dcf041a3f2e83df2e179c071c650b288595dc05a Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:25:15 +0800 Subject: [PATCH] fix(editor): ci stability (#14704) ## Summary by CodeRabbit * **Chores** * Improved Electron shutdown, diagnostics and tab teardown for more reliable exits and forced cleanup on stubborn processes. * **Tests** * Added polling-based test helpers, stronger scroll/page readiness, timeout-tolerant page selection, and async cleanup/worker teardown; updated many tests to wait for UI/model updates and rendering frames. * **Bug Fixes** * Reduced flakiness by awaiting paragraph visibility, nested counts, selection/navigation stability, tab counts, post-action renders, and safer element interactions. --- .../src/__tests__/edgeless/layer.spec.ts | 2 +- .../__tests__/edgeless/turbo-renderer.spec.ts | 4 +- .../src/__tests__/utils/setup.ts | 19 +- .../src/main/windows-manager/tab-views.ts | 78 ++++- tests/affine-desktop/e2e/tabs.spec.ts | 1 + .../e2e/blocksuite/paragraph.spec.ts | 91 +++-- tests/blocksuite/e2e/format-bar.spec.ts | 41 ++- tests/blocksuite/e2e/hotkey/hotkey.spec.ts | 21 +- tests/blocksuite/e2e/hotkey/multiline.spec.ts | 3 + tests/blocksuite/e2e/list.spec.ts | 32 +- tests/blocksuite/e2e/paragraph.spec.ts | 6 +- tests/blocksuite/e2e/selection/block.spec.ts | 1 + tests/blocksuite/e2e/selection/native.spec.ts | 40 ++- .../blocksuite/e2e/utils/actions/edgeless.ts | 25 +- tests/blocksuite/e2e/utils/actions/misc.ts | 59 ++-- tests/blocksuite/e2e/utils/asserts.ts | 130 +++---- tests/kit/src/electron.ts | 322 +++++++++++++----- 17 files changed, 619 insertions(+), 256 deletions(-) diff --git a/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts index cfb1835966..a3d82ff140 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts @@ -38,7 +38,7 @@ beforeEach(async () => { return async () => { await wait(100); - cleanup(); + await cleanup(); }; }); diff --git a/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts index 9a15c141ba..f930dfa1a3 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts @@ -21,7 +21,7 @@ import { const FRAME = 16; describe('viewport turbo renderer', () => { - let cleanup: () => void; + let cleanup: () => Promise; beforeEach(async () => { cleanup = await setupEditor('edgeless', [ @@ -34,7 +34,7 @@ describe('viewport turbo renderer', () => { return cleanup; }); - afterEach(() => cleanup?.()); + afterEach(async () => await cleanup?.()); test('should access turbo renderer instance', async () => { const renderer = getRenderer(); diff --git a/blocksuite/integration-test/src/__tests__/utils/setup.ts b/blocksuite/integration-test/src/__tests__/utils/setup.ts index ed950931ed..bd187fa2f1 100644 --- a/blocksuite/integration-test/src/__tests__/utils/setup.ts +++ b/blocksuite/integration-test/src/__tests__/utils/setup.ts @@ -29,6 +29,7 @@ const viewManager = getTestViewManager(); effects(); const storeExtensions = storeManager.get('store'); +const painterWorkers = new Set(); export function getRenderer() { return editor.std.get( @@ -108,6 +109,7 @@ export function createPainterWorker() { type: 'module', } ); + painterWorkers.add(worker); return worker; } @@ -141,19 +143,30 @@ export async function setupEditor( const appElement = await createEditor(collection, mode, extensions); - return () => { + return async () => { + await cleanup(); appElement?.remove(); - cleanup(); }; } -export function cleanup() { +export async function cleanup() { window.editor?.remove(); + await window.editor?.updateComplete; + await new Promise(resolve => requestAnimationFrame(() => resolve())); + + for (const worker of painterWorkers) { + worker.terminate(); + } + painterWorkers.clear(); delete (window as any).collection; delete (window as any).editor; + delete (window as any).doc; + + delete (window as any).renderer; + delete (window as any).store; } diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts index 1fe982d342..eeb0412bda 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts @@ -391,21 +391,87 @@ export class WebContentViewsManager { this.closedWorkbenches.push(targetWorkbench); - setTimeout(() => { + globalThis.setTimeout(() => { const view = this.tabViewsMap.get(id); this.tabViewsMap.delete(id); - if (this.mainWindow && view) { - this.mainWindow.contentView.removeChildView(view); - view?.webContents.close({ - waitForBeforeUnload: true, - }); + if (!view) { + return; } + + void this.disposeTabView(id, view).catch(error => { + logger.warn('failed to dispose tab view', { + id, + error: error instanceof Error ? error.message : String(error), + }); + }); }, 500); // delay a bit to get rid of the flicker onTabClose(id); }; + private readonly disposeTabView = async ( + id: string, + view: WebContentsView + ) => { + const waitForDestroyed = () => + new Promise(resolve => { + if (view.webContents.isDestroyed()) { + resolve(true); + return; + } + + const timeout = globalThis.setTimeout(() => { + resolve(false); + }, 1_000); + + view.webContents.once('destroyed', () => { + globalThis.clearTimeout(timeout); + resolve(true); + }); + }); + + if (this.mainWindow?.contentView.children.includes(view)) { + this.mainWindow.contentView.removeChildView(view); + } + + if (view.webContents.isDestroyed()) { + return; + } + + try { + view.webContents.close({ + waitForBeforeUnload: true, + }); + } catch { + return; + } + + if (await waitForDestroyed()) return; + + view.webContents.forcefullyCrashRenderer(); + + try { + view.webContents.close({ + waitForBeforeUnload: false, + }); + } catch { + return; + } + + if (!view.webContents.isDestroyed() && !(await waitForDestroyed())) { + logger.warn('tab webContents is still alive after force close', { + id, + webContentsId: view.webContents.id, + url: view.webContents.getURL(), + }); + } + + if (this.mainWindow?.contentView.children.includes(view)) { + this.mainWindow.contentView.removeChildView(view); + } + }; + undoCloseTab = async () => { if (this.closedWorkbenches.length === 0) { return; diff --git a/tests/affine-desktop/e2e/tabs.spec.ts b/tests/affine-desktop/e2e/tabs.spec.ts index 59ebe21f38..24b653ad94 100644 --- a/tests/affine-desktop/e2e/tabs.spec.ts +++ b/tests/affine-desktop/e2e/tabs.spec.ts @@ -42,6 +42,7 @@ test('can switch & close tab by clicking', async ({ page }) => { // the first tab should be active await expectActiveTab(page, 0); + await expectTabCount(page, 1); }); test('Collapse Sidebar', async ({ page }) => { diff --git a/tests/affine-local/e2e/blocksuite/paragraph.spec.ts b/tests/affine-local/e2e/blocksuite/paragraph.spec.ts index b76e990379..cb0ecf2895 100644 --- a/tests/affine-local/e2e/blocksuite/paragraph.spec.ts +++ b/tests/affine-local/e2e/blocksuite/paragraph.spec.ts @@ -14,7 +14,7 @@ import { waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import type { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph'; -import { expect } from '@playwright/test'; +import { expect, type Locator } from '@playwright/test'; test.beforeEach(async ({ page }) => { await openHomePage(page); @@ -22,6 +22,44 @@ test.beforeEach(async ({ page }) => { await waitForEditorLoad(page); }); +async function expectParagraphState( + paragraphs: Locator, + index: number, + expectedType: string, + expectedText: string +) { + await expect + .poll(async () => { + return await paragraphs + .nth(index) + .evaluate( + ( + block: ParagraphBlockComponent, + expected: { type: string; text: string } + ) => + block.model.props.type === expected.type && + block.model.props.text.toString() === expected.text, + { + type: expectedType, + text: expectedText, + } + ); + }) + .toBeTruthy(); +} + +async function expectParagraphVisibility( + paragraphs: Locator, + index: number, + visible: boolean +) { + await expect + .poll(async () => { + return await paragraphs.nth(index).isVisible(); + }) + .toBe(visible); +} + test('heading icon should be updated after change heading level', async ({ page, }) => { @@ -320,26 +358,9 @@ test('also move children when dedent collapsed heading', async ({ page }) => { await subParagraph.nth(0).click(); await pressShiftTab(page); expect(await subParagraph.count()).toBe(0); - expect( - await paragraph - .nth(1) - .evaluate( - (block: ParagraphBlockComponent) => - block.model.props.type === 'h1' && - block.model.props.text.toString() === 'bbb' - ) - ).toBeTruthy(); - expect( - await paragraph - .nth(2) - .evaluate( - (block: ParagraphBlockComponent) => - block.model.props.type === 'text' && - block.model.props.text.toString() === 'ccc' - ) - ).toBeTruthy(); - - expect(await paragraph.nth(2).isVisible()).toBeFalsy(); + await expectParagraphState(paragraph, 1, 'h1', 'bbb'); + await expectParagraphState(paragraph, 2, 'text', 'ccc'); + await expectParagraphVisibility(paragraph, 2, false); await paragraph .nth(1) .locator('blocksuite-toggle-button .toggle-icon') @@ -349,7 +370,7 @@ test('also move children when dedent collapsed heading', async ({ page }) => { y: 5, }, }); - expect(await paragraph.nth(2).isVisible()).toBeTruthy(); + await expectParagraphVisibility(paragraph, 2, true); }); test('also move collapsed siblings when indent collapsed heading', async ({ @@ -433,23 +454,19 @@ test('unfold collapsed heading when its other blocks indented to be its sibling' */ const paragraph = page.locator('affine-note affine-paragraph'); - expect(await paragraph.nth(2).isVisible()).toBeTruthy(); - expect( - await paragraph - .nth(2) - .evaluate( - (block: ParagraphBlockComponent) => - block.model.props.type === 'text' && - block.model.props.text.toString() === 'ccc' - ) - ).toBeTruthy(); + await expectParagraphVisibility(paragraph, 2, true); + await expectParagraphState(paragraph, 2, 'text', 'ccc'); await paragraph.locator('blocksuite-toggle-button .toggle-icon').click(); - expect(await paragraph.nth(2).isVisible()).toBeFalsy(); + await expectParagraphVisibility(paragraph, 2, false); await paragraph.nth(3).click(); // ddd - expect(await paragraph.nth(2).isVisible()).toBeFalsy(); - expect(await paragraph.nth(0).locator('affine-paragraph').count()).toBe(2); + await expectParagraphVisibility(paragraph, 2, false); + await expect + .poll(() => paragraph.nth(0).locator('affine-paragraph').count()) + .toBe(2); await pressTab(page); - expect(await paragraph.nth(0).locator('affine-paragraph').count()).toBe(3); - expect(await paragraph.nth(2).isVisible()).toBeTruthy(); + await expect + .poll(() => paragraph.nth(0).locator('affine-paragraph').count()) + .toBe(3); + await expectParagraphVisibility(paragraph, 2, true); }); diff --git a/tests/blocksuite/e2e/format-bar.spec.ts b/tests/blocksuite/e2e/format-bar.spec.ts index bdf92e6d54..1e8886d778 100644 --- a/tests/blocksuite/e2e/format-bar.spec.ts +++ b/tests/blocksuite/e2e/format-bar.spec.ts @@ -38,9 +38,9 @@ import { } from './utils/actions/index.js'; import { assertAlmostEqual, - assertBlockChildrenIds, assertLocatorVisible, assertRichImage, + assertRichTextInlineDeltas, assertRichTextInlineRange, assertRichTexts, } from './utils/asserts.js'; @@ -251,6 +251,19 @@ test('should format quick bar be able to change background color', async ({ // ); await highlight.redForegroundBtn.click(); + await waitNextFrame(page, 200); + await assertRichTextInlineDeltas( + page, + [ + { + attributes: { + color: 'var(--affine-text-highlight-foreground-red)', + }, + insert: '456', + }, + ], + 1 + ); // TODO(@fundon): these recent settings should be added to the dropdown menu. // await expect(highlight.highlightBtn).toHaveAttribute( @@ -265,11 +278,26 @@ test('should format quick bar be able to change background color', async ({ // select `123` paragraph by ctrl + a await focusRichText(page); await selectAllByKeyboard(page); + await waitNextFrame(page, 200); + await assertRichTextInlineRange(page, 0, 0, 3); // // use last used color // await highlight.highlightBtn.click(); await highlight.highlightBtn.click(); await highlight.redForegroundBtn.click(); + await waitNextFrame(page, 200); + await assertRichTextInlineDeltas( + page, + [ + { + attributes: { + color: 'var(--affine-text-highlight-foreground-red)', + }, + insert: '123', + }, + ], + 0 + ); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_select_all.json` @@ -278,6 +306,8 @@ test('should format quick bar be able to change background color', async ({ await highlight.highlightBtn.click(); await expect(highlight.defaultColorBtn).toBeVisible(); await highlight.defaultColorBtn.click(); + await waitNextFrame(page, 200); + await assertRichTextInlineDeltas(page, [{ insert: '123' }], 0); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_default_color.json` @@ -929,8 +959,7 @@ test('create linked doc from block selection with format bar', async ({ await focusRichText(page, 1); await pressTab(page); await assertRichTexts(page, ['123', '456', '789']); - await assertBlockChildrenIds(page, '1', ['2', '4']); - await assertBlockChildrenIds(page, '2', ['3']); + await waitNextFrame(page, 200); await selectAllBlocksByKeyboard(page); await waitNextFrame(page, 200); @@ -980,9 +1009,9 @@ test.describe('more menu button', () => { await copyBtn.click(); await assertRichTextInlineRange(page, 1, 0, 3); - await focusRichText(page, 1); + await focusRichTextEnd(page, 1); await pasteByKeyboard(page); - await waitNextFrame(page); + await waitNextFrame(page, 200); await assertRichTexts(page, ['123', '456456', '789']); }); @@ -1000,7 +1029,7 @@ test.describe('more menu button', () => { await expect(duplicateBtn).toBeVisible(); await duplicateBtn.click(); - await waitNextFrame(page); + await waitNextFrame(page, 200); await assertRichTexts(page, ['123', '456', '456', '789']); }); diff --git a/tests/blocksuite/e2e/hotkey/hotkey.spec.ts b/tests/blocksuite/e2e/hotkey/hotkey.spec.ts index 581ca2bb7e..2064bea06f 100644 --- a/tests/blocksuite/e2e/hotkey/hotkey.spec.ts +++ b/tests/blocksuite/e2e/hotkey/hotkey.spec.ts @@ -33,6 +33,7 @@ import { } from '../utils/actions/index.js'; import { assertBlockChildrenIds, + assertRichTextInlineDeltas, assertRichTextInlineRange, assertRichTextModelType, assertRichTexts, @@ -162,20 +163,23 @@ test('use formatted cursor with hotkey', async ({ page }, testInfo) => { ); }); -test('use formatted cursor with hotkey at empty line', async ({ - page, -}, testInfo) => { +test('use formatted cursor with hotkey at empty line', async ({ page }) => { await enterPlaygroundRoom(page); await initEmptyParagraphState(page); await focusRichText(page); // format bold await page.keyboard.press(`${SHORT_KEY}+b`, { delay: 50 }); + await waitNextFrame(page, 200); await type(page, 'aaa'); - - expect(await getPageSnapshot(page, true)).toMatchSnapshot( - `${testInfo.title}_bold.json` - ); + await assertRichTextInlineDeltas(page, [ + { + attributes: { + bold: true, + }, + insert: 'aaa', + }, + ]); }); test('should single line format hotkey work', async ({ page }, testInfo) => { @@ -264,6 +268,7 @@ test('format list to h1', async ({ page }) => { await type(page, 'aa'); await focusRichText(page, 0); await updateBlockType(page, 'affine:paragraph', 'h1'); + await waitNextFrame(page, 200); await assertRichTextModelType(page, 'h1'); await undoByClick(page); await assertRichTextModelType(page, 'bulleted'); @@ -280,6 +285,8 @@ test('should cut work single line', async ({ page }, testInfo) => { await dragBetweenIndices(page, [0, 1], [0, 4]); // cut await page.keyboard.press(`${SHORT_KEY}+x`); + await waitNextFrame(page, 200); + await assertRichTexts(page, ['ho']); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_init.json` ); diff --git a/tests/blocksuite/e2e/hotkey/multiline.spec.ts b/tests/blocksuite/e2e/hotkey/multiline.spec.ts index 1a0d1f1b9a..1b2fe0cc58 100644 --- a/tests/blocksuite/e2e/hotkey/multiline.spec.ts +++ b/tests/blocksuite/e2e/hotkey/multiline.spec.ts @@ -107,12 +107,15 @@ test('should cut work multiple line', async ({ page }, testInfo) => { await dragBetweenIndices(page, [0, 1], [2, 2]); // cut await page.keyboard.press(`${SHORT_KEY}+x`); + await waitNextFrame(page, 200); + await assertRichTexts(page, ['19']); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_init.json` ); await undoByKeyboard(page); const text = await readClipboardText(page); expect(text).toBe(`23 456 78`); + await assertRichTexts(page, ['123', '456', '789']); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_undo.json` ); diff --git a/tests/blocksuite/e2e/list.spec.ts b/tests/blocksuite/e2e/list.spec.ts index e4f2f90e57..b2ad4852fc 100644 --- a/tests/blocksuite/e2e/list.spec.ts +++ b/tests/blocksuite/e2e/list.spec.ts @@ -188,9 +188,11 @@ test('unindent list block', async ({ page }) => { await assertBlockChildrenIds(page, '2', ['3']); await pressShiftTab(page); // 0(1(2,3,4)) + await waitNextFrame(page, 200); await assertBlockChildrenIds(page, '1', ['2', '3', '4']); await pressShiftTab(page); + await waitNextFrame(page, 200); await assertBlockChildrenIds(page, '1', ['2', '3', '4']); }); @@ -204,6 +206,7 @@ test('remove all indent for a list block', async ({ page }) => { await page.keyboard.press('Tab', { delay: 50 }); // 0(1(2(3(4)))) await assertBlockChildrenIds(page, '3', ['4']); await pressBackspaceWithShortKey(page); // 0(1(2(3)4)) + await waitNextFrame(page, 200); await assertBlockChildrenIds(page, '1', ['2', '4']); await assertBlockChildrenIds(page, '2', ['3']); }); @@ -231,6 +234,7 @@ test('delete at start of list block', async ({ page }) => { await enterPlaygroundWithList(page); await focusRichText(page, 1); await page.keyboard.press('Backspace'); + await waitNextFrame(page, 200); await assertBlockChildrenFlavours(page, '1', [ 'affine:list', 'affine:paragraph', @@ -252,25 +256,41 @@ test('delete at start of list block', async ({ page }) => { test('nested list blocks', async ({ page }, testInfo) => { await enterPlaygroundWithList(page); + const focusListItem = async (blockId: string) => { + await page + .locator(`[data-block-id="${blockId}"]`) + .locator('rich-text') + .first() + .click({ + force: true, + }); + }; - await focusRichText(page, 0); + await focusListItem('2'); await type(page, '123'); - await focusRichText(page, 1); + await focusListItem('3'); await pressTab(page); await type(page, '456'); - await focusRichText(page, 2); + await focusListItem('4'); await pressTab(page); + await waitNextFrame(page, 200); + await focusListItem('4'); await pressTab(page); + await waitNextFrame(page, 200); await type(page, '789'); + await assertRichTexts(page, ['123', '456', '789']); + await waitNextFrame(page, 200); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_init.json` ); - await focusRichText(page, 1); + await focusListItem('3'); await pressShiftTab(page); + await waitNextFrame(page, 200); + await assertRichTexts(page, ['123', '456', '789']); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_finial.json` @@ -595,14 +615,14 @@ test('delete list item with nested children items', async ({ page }) => { // 4 await pressBackspace(page); - await waitNextFrame(page); + await waitNextFrame(page, 200); // 1 // |2 // 3 // 4 await pressBackspace(page); - await waitNextFrame(page); + await waitNextFrame(page, 200); // 1|2 // 3 // 4 diff --git a/tests/blocksuite/e2e/paragraph.spec.ts b/tests/blocksuite/e2e/paragraph.spec.ts index 0f49f3e9f4..2e802b25f0 100644 --- a/tests/blocksuite/e2e/paragraph.spec.ts +++ b/tests/blocksuite/e2e/paragraph.spec.ts @@ -31,6 +31,7 @@ import { redoByClick, redoByKeyboard, resetHistory, + setInlineRangeInInlineEditor, setSelection, SHORT_KEY, switchReadonly, @@ -87,10 +88,9 @@ test('init paragraph by page title enter in middle', async ({ page }) => { await waitDefaultPageLoaded(page); await focusTitle(page); await type(page, 'hello'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); + await setInlineRangeInInlineEditor(page, { index: 2, length: 0 }); await pressEnter(page); + await waitNextFrame(page, 200); await assertTitle(page, 'he'); await assertRichTexts(page, ['llo', '']); diff --git a/tests/blocksuite/e2e/selection/block.spec.ts b/tests/blocksuite/e2e/selection/block.spec.ts index 275c5807f5..de4ed24cae 100644 --- a/tests/blocksuite/e2e/selection/block.spec.ts +++ b/tests/blocksuite/e2e/selection/block.spec.ts @@ -143,6 +143,7 @@ test('select all should work for multiple notes in doc mode', async ({ async function clickListIcon(page: Page, i = 0) { const locator = page.locator('.affine-list-block__prefix').nth(i); + await expect(locator).toBeVisible(); await locator.click({ force: true, position: { diff --git a/tests/blocksuite/e2e/selection/native.spec.ts b/tests/blocksuite/e2e/selection/native.spec.ts index 7b26cc68e9..4098ecb290 100644 --- a/tests/blocksuite/e2e/selection/native.spec.ts +++ b/tests/blocksuite/e2e/selection/native.spec.ts @@ -51,6 +51,7 @@ import { waitNextFrame, } from '../utils/actions/index.js'; import { + assertBlockChildrenIds, assertBlockCount, assertBlockSelections, assertClipItems, @@ -114,6 +115,13 @@ test('native range delete with indent', async ({ page }, testInfo) => { // abc // def // ghi + await assertRichTexts(page, ['123', '456', '789', 'abc', 'def', 'ghi']); + await assertBlockChildrenIds(page, '1', ['2', '5']); + await assertBlockChildrenIds(page, '2', ['3']); + await assertBlockChildrenIds(page, '3', ['4']); + await assertBlockChildrenIds(page, '5', ['6']); + await assertBlockChildrenIds(page, '6', ['7']); + await waitNextFrame(page); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_init.json` @@ -129,18 +137,21 @@ test('native range delete with indent', async ({ page }, testInfo) => { // ghi await pressBackspace(page); + await waitNextFrame(page); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_after_backspace.json` ); await waitNextFrame(page); await undoByKeyboard(page); + await waitNextFrame(page); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_after_undo.json` ); await redoByKeyboard(page); + await waitNextFrame(page); expect(await getPageSnapshot(page, true)).toMatchSnapshot( `${testInfo.title}_after_redo.json` ); @@ -280,22 +291,29 @@ test('cursor move to up and down with children block', async ({ page }) => { await page.keyboard.press('ArrowRight'); } await page.keyboard.press('ArrowUp'); - const indexOne = await getInlineSelectionIndex(page); - const textOne = await getInlineSelectionText(page); - expect(textOne).toBe('arrow down test 2'); - expect(indexOne).toBe(13); + await expect + .poll(async () => { + return { + index: await getInlineSelectionIndex(page), + text: await getInlineSelectionText(page), + }; + }) + .toEqual({ index: 13, text: 'arrow down test 2' }); for (let i = 0; i < 3; i++) { await page.keyboard.press('ArrowLeft'); } await page.keyboard.press('ArrowUp'); - const indexTwo = await getInlineSelectionIndex(page); - const textTwo = await getInlineSelectionText(page); - expect(textTwo).toBe('arrow down test 1'); - expect(indexTwo).toBeGreaterThanOrEqual(12); - expect(indexTwo).toBeLessThanOrEqual(17); + await expect + .poll(async () => { + const index = await getInlineSelectionIndex(page); + const text = await getInlineSelectionText(page); + return text === 'arrow down test 1' && index >= 12 && index <= 17; + }) + .toBe(true); await page.keyboard.press('ArrowDown'); - const textThree = await getInlineSelectionText(page); - expect(textThree).toBe('arrow down test 2'); + await expect + .poll(() => getInlineSelectionText(page)) + .toBe('arrow down test 2'); }); test('cursor move left and right', async ({ page }) => { diff --git a/tests/blocksuite/e2e/utils/actions/edgeless.ts b/tests/blocksuite/e2e/utils/actions/edgeless.ts index 3828fc8d85..f00007bd76 100644 --- a/tests/blocksuite/e2e/utils/actions/edgeless.ts +++ b/tests/blocksuite/e2e/utils/actions/edgeless.ts @@ -376,13 +376,28 @@ export async function setEdgelessTool( 'shape', false ); - // Avoid clicking on the shape-element (will trigger dragging mode) - await shapeToolButton.click({ position: { x: 5, y: 5 } }); + const shapeToolBox = await shapeToolButton.boundingBox(); + if (!shapeToolBox) { + throw new Error('shapeToolBox is not found'); + } - const squareShapeButton = page - .locator('edgeless-slide-menu edgeless-tool-icon-button') + await page.mouse.click(shapeToolBox.x + 2, shapeToolBox.y + 2); + + const shapeMenu = page.locator('edgeless-shape-menu'); + await expect(shapeMenu).toBeVisible(); + + const squareShapeButton = shapeMenu + .locator('edgeless-tool-icon-button') .filter({ hasText: shape }); - await squareShapeButton.click(); + await expect(squareShapeButton).toBeVisible(); + const squareShapeBox = await squareShapeButton.boundingBox(); + if (!squareShapeBox) { + throw new Error('squareShapeBox is not found'); + } + await page.mouse.click( + squareShapeBox.x + squareShapeBox.width / 2, + squareShapeBox.y + squareShapeBox.height / 2 + ); break; } } diff --git a/tests/blocksuite/e2e/utils/actions/misc.ts b/tests/blocksuite/e2e/utils/actions/misc.ts index f2d37644f6..edf597851a 100644 --- a/tests/blocksuite/e2e/utils/actions/misc.ts +++ b/tests/blocksuite/e2e/utils/actions/misc.ts @@ -549,6 +549,7 @@ export async function focusRichText( await page.mouse.move(0, 0); const editor = getEditorHostLocator(page); const locator = editor.locator(RICH_TEXT_SELECTOR).nth(i); + await expect(locator).toBeVisible(); // need to set `force` to true when clicking on `affine-selected-blocks` await locator.click({ force: true, position: options?.clickPosition }); } @@ -1229,43 +1230,37 @@ export async function getCurrentThemeCSSPropertyValue( } export async function scrollToTop(page: Page) { - await page.mouse.wheel(0, -1000); - - await page.waitForFunction(() => { - const scrollContainer = document.querySelector('.affine-page-viewport'); - if (!scrollContainer) { - throw new Error("Can't find scroll container"); - } - return scrollContainer.scrollTop < 10; + const scrollContainer = page.locator('.affine-page-viewport'); + await expect(scrollContainer).toBeVisible(); + await scrollContainer.evaluate(node => { + (node as HTMLElement).scrollTop = 0; }); + await expect + .poll(async () => { + return await scrollContainer.evaluate(node => { + return (node as HTMLElement).scrollTop; + }); + }) + .toBeLessThan(10); } export async function scrollToBottom(page: Page) { - // await page.mouse.wheel(0, 1000); - - await page - .locator('.affine-page-viewport') - .evaluate(node => - node.scrollTo({ left: 0, top: 1000, behavior: 'smooth' }) - ); - // TODO switch to `scrollend` - // See https://developer.chrome.com/en/blog/scrollend-a-new-javascript-event/ - await page.waitForFunction(() => { - const scrollContainer = document.querySelector('.affine-page-viewport'); - if (!scrollContainer) { - throw new Error("Can't find scroll container"); - } - - return ( - // Wait for scrolled to the bottom - // Refer to https://stackoverflow.com/questions/3898130/check-if-a-user-has-scrolled-to-the-bottom-not-just-the-window-but-any-element - Math.abs( - scrollContainer.scrollHeight - - scrollContainer.scrollTop - - scrollContainer.clientHeight - ) < 10 - ); + const scrollContainer = page.locator('.affine-page-viewport'); + await expect(scrollContainer).toBeVisible(); + await scrollContainer.evaluate(node => { + const viewport = node as HTMLElement; + viewport.scrollTop = viewport.scrollHeight; }); + await expect + .poll(async () => { + return await scrollContainer.evaluate(node => { + const viewport = node as HTMLElement; + return Math.abs( + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight + ); + }); + }) + .toBeLessThan(10); } export async function mockParseDocUrlService( diff --git a/tests/blocksuite/e2e/utils/asserts.ts b/tests/blocksuite/e2e/utils/asserts.ts index fbc4160d80..cb3f253eff 100644 --- a/tests/blocksuite/e2e/utils/asserts.ts +++ b/tests/blocksuite/e2e/utils/asserts.ts @@ -110,10 +110,13 @@ export async function assertEmpty(page: Page) { } export async function assertTitle(page: Page, text: string) { - const editor = getEditorLocator(page); - const inlineEditor = editor.locator('.doc-title-container').first(); - const vText = inlineEditorInnerTextToString(await inlineEditor.innerText()); - expect(vText).toBe(text); + await expect + .poll(async () => { + const editor = getEditorLocator(page); + const inlineEditor = editor.locator('.doc-title-container').first(); + return inlineEditorInnerTextToString(await inlineEditor.innerText()); + }) + .toBe(text); } export async function assertInlineEditorDeltas( @@ -121,13 +124,16 @@ export async function assertInlineEditorDeltas( deltas: unknown[], i = 0 ) { - const actual = await page.evaluate(i => { - const inlineRoot = document.querySelectorAll( - '[data-v-root="true"]' - )[i]; - return inlineRoot.inlineEditor.yTextDeltas; - }, i); - expect(actual).toEqual(deltas); + await expect + .poll(async () => { + return page.evaluate(i => { + const inlineRoot = document.querySelectorAll( + '[data-v-root="true"]' + )[i]; + return inlineRoot?.inlineEditor.yTextDeltas; + }, i); + }) + .toEqual(deltas); } export async function assertRichTextInlineDeltas( @@ -135,17 +141,20 @@ export async function assertRichTextInlineDeltas( deltas: unknown[], i = 0 ) { - const actual = await page.evaluate( - ([i]) => { - const editorHost = document.querySelector('editor-host'); - const inlineRoot = editorHost?.querySelectorAll( - 'rich-text [data-v-root="true"]' - )[i]; - return inlineRoot?.inlineEditor.yTextDeltas; - }, - [i] - ); - expect(actual).toEqual(deltas); + await expect + .poll(async () => { + return page.evaluate( + ([i]) => { + const editorHost = document.querySelector('editor-host'); + const inlineRoot = editorHost?.querySelectorAll( + 'rich-text [data-v-root="true"]' + )[i]; + return inlineRoot?.inlineEditor.yTextDeltas; + }, + [i] + ); + }) + .toEqual(deltas); } export async function assertText(page: Page, text: string, i = 0) { @@ -168,7 +177,8 @@ export async function assertRichTexts(page: Page, texts: string[]) { ); return richTexts.map(richText => { const editor = richText.inlineEditor as AffineInlineEditor; - return editor.yText.toString(); + const text = editor.yText.toString(); + return /^\n\s*$/u.test(text) ? '' : text; }); }); }) @@ -352,20 +362,20 @@ export async function assertRichTextModelType( type: string, index = 0 ) { - const actual = await page.evaluate( - ({ index, BLOCK_ID_ATTR }) => { - const editorHost = document.querySelector('editor-host'); - const richText = editorHost?.querySelectorAll('rich-text')[index]; - const block = richText?.closest(`[${BLOCK_ID_ATTR}]`); - - if (!block) { - throw new Error('block component is undefined'); - } - return (block.model as BlockModel<{ type: string }>).props.type; - }, - { index, BLOCK_ID_ATTR } - ); - expect(actual).toEqual(type); + await expect + .poll(async () => { + return page.evaluate( + ({ index, BLOCK_ID_ATTR }) => { + const editorHost = document.querySelector('editor-host'); + const richText = editorHost?.querySelectorAll('rich-text')[index]; + const block = richText?.closest(`[${BLOCK_ID_ATTR}]`); + return (block?.model as BlockModel<{ type: string }> | undefined) + ?.props.type; + }, + { index, BLOCK_ID_ATTR } + ); + }) + .toEqual(type); } export async function assertTextFormats(page: Page, resultObj: unknown[]) { @@ -405,16 +415,19 @@ export async function assertBlockChildrenIds( blockId: string, ids: string[] ) { - const actual = await page.evaluate( - ({ blockId }) => { - const element = document.querySelector(`[data-block-id="${blockId}"]`); - // @ts-ignore - const model = element.model as BlockModel; - return model.children.map(child => child.id); - }, - { blockId } - ); - expect(actual).toEqual(ids); + await expect + .poll(async () => { + return page.evaluate( + ({ blockId }) => { + const element = document.querySelector( + `[data-block-id="${blockId}"]` + ); + return element?.model.children.map(child => child.id); + }, + { blockId } + ); + }) + .toEqual(ids); } export async function assertBlockChildrenFlavours( @@ -422,18 +435,19 @@ export async function assertBlockChildrenFlavours( blockId: string, flavours: string[] ) { - const actual = await page.evaluate( - ({ blockId }) => { - const element = document.querySelector( - `[data-block-id="${blockId}"]` + await expect + .poll(async () => { + return page.evaluate( + ({ blockId }) => { + const element = document.querySelector( + `[data-block-id="${blockId}"]` + ); + return element?.model.children.map(child => child.flavour); + }, + { blockId } ); - // @ts-ignore - const model = element.model as BlockModel; - return model.children.map(child => child.flavour); - }, - { blockId } - ); - expect(actual).toEqual(flavours); + }) + .toEqual(flavours); } export async function assertParentBlockId( diff --git a/tests/kit/src/electron.ts b/tests/kit/src/electron.ts index 9f81c42cff..d693a7c27f 100644 --- a/tests/kit/src/electron.ts +++ b/tests/kit/src/electron.ts @@ -1,33 +1,115 @@ +import type { ChildProcess } from 'node:child_process'; import crypto from 'node:crypto'; import { setTimeout } from 'node:timers/promises'; import { Package } from '@affine-tools/utils/workspace'; -import { expect, type Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; import fs from 'fs-extra'; import type { ElectronApplication } from 'playwright'; import { _electron as electron } from 'playwright'; +import treeKill from 'tree-kill'; -import { test as base, testResultDir } from './playwright'; +import { test as base } from './playwright'; import { removeWithRetry } from './utils/utils'; const electronRoot = new Package('@affine/electron').path; +const treeKillAsync = (pid: number, signal: NodeJS.Signals) => + new Promise((resolve, reject) => { + treeKill(pid, signal, error => { + if ( + !error || + ('code' in error && + typeof error.code === 'string' && + error.code === 'ESRCH') + ) { + resolve(); + return; + } + reject(error); + }); + }); + function generateUUID() { return crypto.randomUUID(); } type RoutePath = 'setting'; +type StreamLike = { + destroyed?: boolean; + destroy?: () => void; +}; + +const tryDestroyStream = (stream: StreamLike | null | undefined) => { + if (!stream || stream.destroyed || typeof stream.destroy !== 'function') { + return; + } + + try { + stream.destroy(); + } catch {} +}; + +const releaseChildProcessHandles = (child: ChildProcess) => { + if (child.connected) { + try { + child.disconnect(); + } catch {} + } + + tryDestroyStream(child.stdin); + tryDestroyStream(child.stdout); + tryDestroyStream(child.stderr); + + for (const stream of child.stdio) { + if ( + stream !== child.stdin && + stream !== child.stdout && + stream !== child.stderr + ) { + tryDestroyStream(stream as StreamLike | null | undefined); + } + } +}; + +const withTimeoutFallback = async (promise: Promise, fallback: T) => { + try { + return await Promise.race([ + promise, + setTimeout(1_000).then(() => fallback), + ]); + } catch { + return fallback; + } +}; + const getPageId = async (page: Page) => { - return page.evaluate(() => { - return (window.__appInfo as any)?.viewId as string; - }); + return await withTimeoutFallback( + page.evaluate(() => { + return (window.__appInfo as any)?.viewId as string | undefined; + }), + undefined + ); }; const isActivePage = async (page: Page) => { - return page.evaluate(async () => { - return await (window as any).__apis?.ui.isActiveTab(); - }); + return await withTimeoutFallback( + page.evaluate(async () => { + return (await (window as any).__apis?.ui.isActiveTab()) === true; + }), + false + ); +}; + +const isEditorPage = async (page: Page) => { + return await withTimeoutFallback( + page + .locator('v-line') + .count() + .then(count => count > 0), + false + ); }; const getActivePage = async (pages: Page[]) => { @@ -36,9 +118,137 @@ const getActivePage = async (pages: Page[]) => { return page; } } + + const contentPages: Page[] = []; + for (const page of pages) { + const pageId = await getPageId(page); + if (pageId === 'shell') { + continue; + } + if (pageId || (await isEditorPage(page))) { + contentPages.push(page); + } + } + + if (contentPages.length > 0) { + return contentPages.at(-1) ?? null; + } + return null; }; +const getShellPage = async (pages: Page[]) => { + for (const page of pages) { + if ((await getPageId(page)) === 'shell') { + return page; + } + } + return null; +}; + +const waitForElectronPage = async ( + electronApp: ElectronApplication, + label: string, + getPage: (pages: Page[]) => Promise +) => { + const deadline = + Date.now() + + (process.env.CI && process.platform === 'darwin' ? 20_000 : 10_000); + + while (Date.now() < deadline) { + const page = await getPage(electronApp.windows()); + if (page) { + return page; + } + + await setTimeout(250); + } + + throw new Error(`Timed out waiting for ${label}`); +}; + +const cleanupElectronApp = async (electronApp: ElectronApplication) => { + const child = electronApp.process(); + const waitForAppClose = () => + new Promise(resolve => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + 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 { + child.kill(); + } catch {} + }; + + const closeWithTimeout = async () => { + const closeEvent = waitForAppClose(); + const controller = new AbortController(); + const killAfterTimeout = setTimeout(10_000, undefined, { + signal: controller.signal, + }) + .then(() => { + killProcess(); + }) + .catch(error => { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + throw error; + }); + + try { + await Promise.all([electronApp.close().catch(() => {}), closeEvent]); + } finally { + controller.abort(); + await killAfterTimeout; + } + }; + + if (process.env.CI && process.platform === 'linux') { + const pid = child.pid; + const closeEvent = waitForAppClose(); + const processExit = waitForProcessExit(); + + await Promise.race([ + Promise.all([ + electronApp.close().catch(() => {}), + closeEvent, + processExit, + ]), + setTimeout(2_000), + ]).catch(() => {}); + + if ( + pid !== undefined && + child.exitCode === null && + child.signalCode === null + ) { + await treeKillAsync(pid, 'SIGKILL').catch(() => {}); + } + + releaseChildProcessHandles(child); + + await Promise.race([closeEvent, processExit, setTimeout(5_000)]).catch( + () => {} + ); + return; + } + + await closeWithTimeout(); +}; + export const test = base.extend<{ electronApp: ElectronApplication; shell: Page; @@ -55,53 +265,27 @@ export const test = base.extend<{ }; }>({ shell: async ({ electronApp }, use) => { - await expect.poll(() => electronApp.windows().length > 1).toBeTruthy(); + const shell = await waitForElectronPage( + electronApp, + 'shell page', + getShellPage + ); - for (const page of electronApp.windows()) { - const viewId = await getPageId(page); - if (viewId === 'shell') { - await use(page); - break; - } - } + await use(shell); }, page: async ({ electronApp }, use) => { - await expect - .poll( - () => { - return electronApp.windows().length > 1; - }, - { - timeout: 10000, - } - ) - .toBeTruthy(); + const page = await waitForElectronPage( + electronApp, + 'active page', + getActivePage + ); - await expect - .poll( - async () => { - const page = await getActivePage(electronApp.windows()); - return !!page; - }, - { - timeout: 10000, - } - ) - .toBeTruthy(); - - const page = await getActivePage(electronApp.windows()); - - if (!page) { - throw new Error('No active page found'); - } - - // wait for blocksuite to be loaded await page.waitForSelector('v-line'); - await use(page as Page); + await use(page); }, views: async ({ electronApp, page }, use) => { - void page; // makes sure page is a dependency + void page; await use({ getActive: async () => { const view = await getActivePage(electronApp.windows()); @@ -111,20 +295,18 @@ export const test = base.extend<{ }, // oxlint-disable-next-line no-empty-pattern electronApp: async ({}, use) => { + const id = generateUUID(); + const dist = electronRoot.join('dist').value; + const clonedDist = electronRoot.join('e2e-dist-' + id).value; + let electronApp: ElectronApplication | undefined; + try { - // a random id to avoid conflicts between tests - const id = generateUUID(); - const dist = electronRoot.join('dist').value; - const clonedDist = electronRoot.join('e2e-dist-' + id).value; await fs.copy(dist, clonedDist); const packageJson = await fs.readJSON( electronRoot.join('package.json').value ); - // overwrite the app name packageJson.name = '@affine/electron-test-' + id; - // overwrite the path to the main script packageJson.main = './main.js'; - // write to the cloned dist await fs.writeJSON(clonedDist + '/package.json', packageJson); const env: Record = {}; @@ -134,41 +316,23 @@ export const test = base.extend<{ } } env.DEBUG = 'pw:browser'; - env.SKIP_ONBOARDING = '1'; - const electronApp = await electron.launch({ + electronApp = await electron.launch({ args: [clonedDist], env, cwd: clonedDist, - recordVideo: { - dir: testResultDir, - }, colorScheme: 'light', }); await use(electronApp); - const cleanup = async () => { - const pages = electronApp.windows(); - for (const page of pages) { - if (page.isClosed()) { - continue; - } - await page.close(); - } - await electronApp.close(); + } finally { + if (electronApp) { + await cleanupElectronApp(electronApp); + } + if (await fs.pathExists(clonedDist)) { await removeWithRetry(clonedDist); - }; - await Promise.race([ - // cleanup may stuck and fail the test, but it should be fine. - cleanup(), - setTimeout(10000).then(() => { - // kill the electron app if it is not closed after 10 seconds - electronApp.process().kill(); - }), - ]); - } catch (error) { - console.log(error); + } } }, appInfo: async ({ electronApp }, use) => {