mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 05:48:59 +08:00
feat(core): improve mobile perf (#15317)
#### PR Dependency Tree * **PR #15317** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Virtualized mobile navigation with shell navigation and interactive swipe menus; coordinated mobile back handling with interactive phases/state restoration. * Added shared auth request proxy and message-port based token handling across mobile and worker flows. * **Bug Fixes** * Hydrated remote worker error stacks for calls and observable errors. * Improved SQLite FTS/indexer and nbstore optional text handling; refined docs-search ref parsing and notification loading/retry. * **Refactor / UX** * Modal focus-preservation and pointer behavior updates; improved mobile menu controls and back gesture plugins. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,22 +1,21 @@
|
||||
import { test } from '@affine-test/kit/mobile';
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import { expandCollapsibleSection, openTab } from './utils';
|
||||
import { expandCollapsibleSection, openTab, pageBack } from './utils';
|
||||
|
||||
const locateBack = (page: Page) => page.getByTestId('page-header-back');
|
||||
|
||||
test('new doc via app tab should not show back', async ({ page }) => {
|
||||
// directly open new doc, should not show back
|
||||
test('new doc returns to its source without creating a New destination', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTab(page, 'New Page');
|
||||
await expect(locateBack(page)).not.toBeVisible();
|
||||
const docId = await page.evaluate(() => location.pathname.split('/').pop());
|
||||
await expect(locateBack(page)).toBeVisible();
|
||||
await pageBack(page);
|
||||
await expect(page).toHaveURL(/\/home$/);
|
||||
|
||||
// back to home, and open the doc, should show back
|
||||
await openTab(page, 'home');
|
||||
await expandCollapsibleSection(page, 'recent');
|
||||
const docCard = page.locator(
|
||||
`[data-testid="doc-card"][data-doc-id="${docId}"]`
|
||||
);
|
||||
const docCard = page.getByTestId('doc-card').first();
|
||||
await expect(docCard).toBeVisible();
|
||||
await docCard.click();
|
||||
await expect(locateBack(page)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,14 @@ const openDocInfoModal = async (page: Page) => {
|
||||
await page.click('[data-testid="detail-page-header-more-button"]');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'view info' }).click();
|
||||
const viewInfo = page.getByRole('button', { name: 'view info' });
|
||||
const viewInfoBox = await viewInfo.boundingBox();
|
||||
expect(viewInfoBox).not.toBeNull();
|
||||
if (!viewInfoBox) throw new Error('View info action has no layout box');
|
||||
await page.touchscreen.tap(
|
||||
viewInfoBox.x + viewInfoBox.width / 2,
|
||||
viewInfoBox.y + viewInfoBox.height / 2
|
||||
);
|
||||
await expect(page.getByTestId('mobile-menu-back-button')).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -26,9 +33,7 @@ test('switch to edgeless mode', async ({ page }) => {
|
||||
await page.click('[data-testid="detail-page-header-more-button"]');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Default to Edgeless mode' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Default to Edgeless mode' }).click();
|
||||
await expect(page.locator('.affine-edgeless-viewport')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -46,7 +51,7 @@ test('can add text property', async ({ page }) => {
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Add property' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('mobile-menu-back-button').last()
|
||||
|
||||
@@ -3,7 +3,11 @@ import { getBlockSuiteEditorTitle } from '@affine-test/kit/utils/page-logic';
|
||||
import { getCurrentDocIdFromUrl } from '@affine-test/kit/utils/url';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { expandCollapsibleSection, pageBack } from './utils';
|
||||
import {
|
||||
expandCollapsibleSection,
|
||||
openNavigationPanelNodeSwipeMenu,
|
||||
pageBack,
|
||||
} from './utils';
|
||||
|
||||
test('Create new doc in favorites', async ({ page }) => {
|
||||
const section = await expandCollapsibleSection(page, 'favorites');
|
||||
@@ -24,6 +28,17 @@ test('Create new doc in favorites', async ({ page }) => {
|
||||
const node = section2.getByTestId(`navigation-panel-doc-${docId}`);
|
||||
await expect(node).toBeVisible();
|
||||
|
||||
await node.getByTestId('navigation-panel-collapsed-button').tap();
|
||||
await expect(
|
||||
page.getByTestId('navigation-panel-doc-add-linked-page')
|
||||
).toBeVisible();
|
||||
|
||||
await openNavigationPanelNodeSwipeMenu(page, node);
|
||||
const menu = page.getByRole('dialog');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole('button', { name: 'Duplicate' })).toBeVisible();
|
||||
await expect(page.locator('#app-tabs')).toHaveCount(1);
|
||||
|
||||
// const label = node.getByText(testTitleText);
|
||||
// await expect(label).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ const createSubFolder = async (page: Page, parent: Locator, name: string) => {
|
||||
await page.keyboard.type(name);
|
||||
await dialog.getByTestId('rename-confirm').tap();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
const node = await locateFolder(parent, name);
|
||||
const node = await locateFolder(page, name);
|
||||
return node;
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ async function createRootTag(
|
||||
// set color
|
||||
await changeTagColor(dialog, color);
|
||||
// confirm
|
||||
await dialog.getByTestId('rename-input').focus();
|
||||
await dialog.getByTestId('rename-confirm').tap();
|
||||
const tag = await locateTag(section, name);
|
||||
await expect(tag).toBeVisible();
|
||||
@@ -55,7 +56,10 @@ async function createRootTag(
|
||||
}
|
||||
|
||||
test('create a tag from navigation panel', async ({ page }) => {
|
||||
await createRootTag(page, 'Test Tag');
|
||||
const tag = await createRootTag(page, 'Test Tag');
|
||||
await tag.getByTestId('navigation-panel-collapsed-button').tap();
|
||||
await expect(tag).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.getByTestId('navigation-panel-tag-add-page')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rename a tag from navigation panel', async ({ page }) => {
|
||||
@@ -64,6 +68,25 @@ test('rename a tag from navigation panel', async ({ page }) => {
|
||||
|
||||
const tag = await createRootTag(page, originalName);
|
||||
const menu = await openNavigationPanelNodeMenu(page, tag);
|
||||
const rename = menu.getByTestId('rename-tag');
|
||||
const renameBox = await rename.boundingBox();
|
||||
expect(renameBox).not.toBeNull();
|
||||
if (!renameBox) throw new Error('Rename action has no layout box');
|
||||
const touchPoint = {
|
||||
x: renameBox.x + renameBox.width / 2,
|
||||
y: renameBox.y + renameBox.height / 2,
|
||||
};
|
||||
await page.touchscreen.tap(touchPoint.x, touchPoint.y);
|
||||
await expect(menu.getByTestId('rename-input')).toBeVisible();
|
||||
const back = menu.getByTestId('mobile-menu-back-button').last();
|
||||
const backBox = await back.boundingBox();
|
||||
expect(backBox).not.toBeNull();
|
||||
if (!backBox) throw new Error('Rename back button has no layout box');
|
||||
await page.touchscreen.tap(
|
||||
backBox.x + backBox.width / 2,
|
||||
backBox.y + backBox.height / 2
|
||||
);
|
||||
await expect(menu.getByTestId('rename-tag')).toBeVisible();
|
||||
await menu.getByTestId('rename-tag').tap();
|
||||
const focusedTestid = await getAttrOfActiveElement(page);
|
||||
expect(focusedTestid).toEqual('rename-input');
|
||||
|
||||
@@ -1,14 +1,80 @@
|
||||
import { test } from '@affine-test/kit/mobile';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { expandCollapsibleSection } from './utils';
|
||||
import { expandCollapsibleSection, pageBack } from './utils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
currentWorkspace?: {
|
||||
meta: { id: string; flavour: string };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test('after loaded, will land on the home page', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/.*\/home/);
|
||||
});
|
||||
|
||||
test('stale first-open state still restores one local workspace', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext();
|
||||
await context.addInitScript(() => {
|
||||
window.localStorage.setItem('app_config', '{"onBoarding":false}');
|
||||
window.localStorage.setItem('is-first-open', 'false');
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('http://localhost:8080/');
|
||||
await page.waitForFunction(() => window.currentWorkspace !== undefined);
|
||||
const firstWorkspace = await page.evaluate(async () => {
|
||||
if (!window.currentWorkspace) {
|
||||
await new Promise<void>(resolve => {
|
||||
window.addEventListener('affine:workspace:change', () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
return window.currentWorkspace?.meta;
|
||||
});
|
||||
|
||||
expect(firstWorkspace?.flavour).toBe('local');
|
||||
await page.goto(
|
||||
`http://localhost:8080/workspace/${firstWorkspace?.id ?? ''}/home`
|
||||
);
|
||||
await expect(page.getByRole('searchbox')).toBeVisible();
|
||||
await page
|
||||
.locator('[data-testid="workspace-selector-trigger"]:visible')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('dialog').getByTestId('workspace-avatar')
|
||||
).toHaveCount(1);
|
||||
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => window.currentWorkspace !== undefined);
|
||||
const reloadedWorkspace = await page.evaluate(
|
||||
() => window.currentWorkspace?.meta
|
||||
);
|
||||
expect(reloadedWorkspace?.id).toBe(firstWorkspace?.id);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('workspace selector does not offer workspace creation', async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByRole('searchbox')).toBeVisible();
|
||||
await page
|
||||
.locator('[data-testid="workspace-selector-trigger"]:visible')
|
||||
.click();
|
||||
await expect(page.getByText('Workspace', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('new-workspace')).toHaveCount(0);
|
||||
await expect(page.getByText('Name your workspace')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('app tabs is visible', async ({ page }) => {
|
||||
const tabs = page.locator('#app-tabs');
|
||||
await expect(tabs).toHaveCount(1);
|
||||
await expect(tabs).toBeVisible();
|
||||
|
||||
await expect(tabs.getByRole('tab', { name: 'home' })).toBeVisible();
|
||||
@@ -17,6 +83,16 @@ test('app tabs is visible', async ({ page }) => {
|
||||
await expect(tabs.getByRole('tab', { name: 'new' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('notifications prompt unauthenticated users to sign in', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByTestId('notification-button').tap();
|
||||
const notifications = page.getByText('Sign in to continue');
|
||||
await expect(notifications).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page.getByTestId('continue-login-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('recent docs', async ({ page }) => {
|
||||
const recentSection = await expandCollapsibleSection(page, 'recent');
|
||||
|
||||
@@ -48,5 +124,39 @@ test('all tab', async ({ page }) => {
|
||||
await docsTab.click();
|
||||
|
||||
const todayDocs = page.getByTestId('doc-list-item');
|
||||
await expect(todayDocs.first()).toBeVisible();
|
||||
expect(await todayDocs.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('search restores query and results without reopening the keyboard', async ({
|
||||
page,
|
||||
}) => {
|
||||
await expandCollapsibleSection(page, 'recent');
|
||||
const title = await page
|
||||
.getByTestId('doc-card')
|
||||
.first()
|
||||
.getByTestId('doc-card-header')
|
||||
.getByRole('heading')
|
||||
.textContent();
|
||||
expect(title).toBeTruthy();
|
||||
await page.getByRole('searchbox').click();
|
||||
await expect(page).toHaveURL(/\/search$/);
|
||||
await expect(page.locator('#app-tabs')).toBeHidden();
|
||||
const search = page.getByRole('searchbox');
|
||||
await search.fill(title ?? '');
|
||||
const result = page.locator('[data-scroll] [data-testid="doc-card"]').first();
|
||||
await expect(result).toBeVisible();
|
||||
await result.click();
|
||||
await expect(page).not.toHaveURL(/\/search$/);
|
||||
await pageBack(page);
|
||||
await expect(page).toHaveURL(/\/search$/);
|
||||
await expect(page.getByRole('searchbox')).toHaveValue(title ?? '');
|
||||
await expect(
|
||||
page.locator('[data-scroll] [data-testid="doc-card"]').first()
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await page
|
||||
.getByRole('searchbox')
|
||||
.evaluate(el => el === document.activeElement)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const openSettings = async (page: Page) => {
|
||||
|
||||
test('can open settings', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
await expect(page.getByText('Devices', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('can change theme', async ({ page }) => {
|
||||
|
||||
@@ -2,16 +2,24 @@ import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export async function expandCollapsibleSection(page: Page, name: string) {
|
||||
const divider = page.locator(`[data-collapsible]:has-text("${name}")`);
|
||||
await divider.waitFor({ state: 'visible' });
|
||||
// oxlint-disable-next-line prefer-dom-node-dataset
|
||||
if ((await divider.getAttribute('data-collapsed')) === 'true') {
|
||||
await divider.click();
|
||||
if ((await divider.getAttribute('role')) === 'switch') {
|
||||
await divider.click();
|
||||
} else {
|
||||
await divider.getByRole('button').click();
|
||||
}
|
||||
}
|
||||
await expect(divider).toHaveAttribute('data-collapsed', 'false');
|
||||
const section = divider.locator(
|
||||
'~ [data-testid="collapsible-section-content"]'
|
||||
);
|
||||
await expect(section).toBeVisible();
|
||||
return section;
|
||||
if ((await section.count()) > 0) {
|
||||
await expect(section).toBeVisible();
|
||||
return section;
|
||||
}
|
||||
return page.locator('body');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +50,43 @@ export async function openNavigationPanelNodeMenu(page: Page, node: Locator) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
export async function openNavigationPanelNodeSwipeMenu(
|
||||
page: Page,
|
||||
node: Locator
|
||||
) {
|
||||
if (page.context().browser()?.browserType().name() !== 'chromium') {
|
||||
await node
|
||||
.getByTestId('swipe-menu-trigger')
|
||||
.evaluate(button =>
|
||||
button.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const box = await node.boundingBox();
|
||||
if (!box) throw new Error('Navigation row is not visible');
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const y = box.y + box.height / 2;
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: box.x + box.width - 20, y }],
|
||||
});
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: box.x, y }],
|
||||
});
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openTab(
|
||||
page: Page,
|
||||
name: 'home' | 'all' | 'Journal' | 'New Page'
|
||||
|
||||
Reference in New Issue
Block a user