diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml
index b2acba470..1cc4b1494 100644
--- a/.github/actions/setup-node/action.yml
+++ b/.github/actions/setup-node/action.yml
@@ -156,7 +156,7 @@ runs:
- name: Install Playwright's dependencies
shell: bash
if: inputs.playwright-install == 'true'
- run: yarn playwright install --with-deps chromium
+ run: yarn playwright install --with-deps chromium webkit
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 4d4dc4314..e83e2066e 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -143,6 +143,36 @@ jobs:
path: ./test-results
if-no-files-found: ignore
+ e2e-mobile-test:
+ name: E2E Mobile Test
+ runs-on: ubuntu-latest
+ env:
+ DISTRIBUTION: mobile
+ IN_CI_TEST: true
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4, 5]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: ./.github/actions/setup-node
+ with:
+ playwright-install: true
+ electron-install: false
+ full-cache: true
+
+ - name: Run playwright tests
+ run: yarn workspace @affine-test/affine-mobile e2e --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
+
+ - name: Upload test results
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-e2e-mobile-${{ matrix.shard }}
+ path: ./test-results
+ if-no-files-found: ignore
+
e2e-migration-test:
name: E2E Migration Test
runs-on: ubuntu-latest
diff --git a/packages/frontend/component/src/ui/menu/mobile/item.tsx b/packages/frontend/component/src/ui/menu/mobile/item.tsx
index 6c807ae12..1d74c0936 100644
--- a/packages/frontend/component/src/ui/menu/mobile/item.tsx
+++ b/packages/frontend/component/src/ui/menu/mobile/item.tsx
@@ -28,7 +28,12 @@ export const MobileMenuItem = (props: MenuItemProps) => {
);
return (
-
+
{children}
);
diff --git a/packages/frontend/core/src/bootstrap/preload.ts b/packages/frontend/core/src/bootstrap/preload.ts
index a4e6a1c8c..49a0bbac6 100644
--- a/packages/frontend/core/src/bootstrap/preload.ts
+++ b/packages/frontend/core/src/bootstrap/preload.ts
@@ -1,9 +1,9 @@
+import { ResizeObserver } from '@juggle/resize-observer';
+window.ResizeObserver = ResizeObserver;
+
import '@affine/env/constant';
import './edgeless-template';
import { setupGlobal } from '@affine/env/global';
-import { ResizeObserver } from '@juggle/resize-observer';
setupGlobal();
-
-window.ResizeObserver = ResizeObserver;
diff --git a/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx b/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx
index a459c01e6..286dcc020 100644
--- a/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx
+++ b/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx
@@ -35,6 +35,7 @@ export const CategoryDivider = forwardRef(
setCollapsed?.(!collapsed)}
data-mobile={mobile}
data-collapsed={collapsed}
diff --git a/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx b/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx
index 9fcd07b4a..3cd658798 100644
--- a/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx
+++ b/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx
@@ -81,6 +81,7 @@ export const CollapsibleSection = ({
{mobile ? null : actions}
{children}
diff --git a/packages/frontend/mobile/package.json b/packages/frontend/mobile/package.json
index a78c99afe..f15c89d1f 100644
--- a/packages/frontend/mobile/package.json
+++ b/packages/frontend/mobile/package.json
@@ -6,7 +6,8 @@
"browser": "src/index.tsx",
"scripts": {
"build": "cross-env DISTRIBUTION=mobile yarn workspace @affine/cli build",
- "dev": "yarn workspace @affine/cli dev"
+ "dev": "yarn workspace @affine/cli dev",
+ "static-server": "cross-env DISTRIBUTION=mobile yarn workspace @affine/cli dev --static"
},
"dependencies": {
"@affine/component": "workspace:*",
diff --git a/packages/frontend/mobile/src/components/app-tabs/index.tsx b/packages/frontend/mobile/src/components/app-tabs/index.tsx
index cef9cbed5..a77d389c1 100644
--- a/packages/frontend/mobile/src/components/app-tabs/index.tsx
+++ b/packages/frontend/mobile/src/components/app-tabs/index.tsx
@@ -40,7 +40,7 @@ export const AppTabs = () => {
const location = useLiveData(workbench.location$);
return (
-
+
{routes.map(route => {
const Link = route.LinkComponent || WorkbenchLink;
@@ -53,6 +53,8 @@ export const AppTabs = () => {
to={route.to}
key={route.to}
className={styles.tabItem}
+ role="tab"
+ aria-label={route.to.slice(1)}
>
-
diff --git a/packages/frontend/mobile/src/components/doc-card/index.tsx b/packages/frontend/mobile/src/components/doc-card/index.tsx
index c53eaf69f..2e226a915 100644
--- a/packages/frontend/mobile/src/components/doc-card/index.tsx
+++ b/packages/frontend/mobile/src/components/doc-card/index.tsx
@@ -1,6 +1,7 @@
import { IconButton } from '@affine/component';
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
+import { useCatchEventCallback } from '@affine/core/hooks/use-catch-event-hook';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import {
WorkbenchLink,
@@ -9,7 +10,7 @@ import {
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
-import { forwardRef, type ReactNode, useCallback } from 'react';
+import { forwardRef, type ReactNode } from 'react';
import * as styles from './styles.css';
import { DocCardTags } from './tag';
@@ -29,8 +30,11 @@ export const DocCard = forwardRef(
const favorited = useLiveData(favAdapter.isFavorite$(meta.id, 'doc'));
- const toggleFavorite = useCallback(
- () => favAdapter.toggle(meta.id, 'doc'),
+ const toggleFavorite = useCatchEventCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ favAdapter.toggle(meta.id, 'doc');
+ },
[favAdapter, meta.id]
);
@@ -39,13 +43,15 @@ export const DocCard = forwardRef(
to={`/${meta.id}`}
ref={ref}
className={clsx(styles.card, className)}
+ data-testid="doc-card"
{...attrs}
>
-
+
{meta.title || Untitled}
}
diff --git a/packages/frontend/mobile/src/components/page-header/index.tsx b/packages/frontend/mobile/src/components/page-header/index.tsx
index 39e12fde8..b2884410f 100644
--- a/packages/frontend/mobile/src/components/page-header/index.tsx
+++ b/packages/frontend/mobile/src/components/page-header/index.tsx
@@ -60,7 +60,12 @@ export const PageHeader = forwardRef(
}, []);
return (
-
+
{
<>
: }
- data-testid="editor-option-menu-edgeless"
+ data-testid="editor-option-menu-mode-switch"
onSelect={handleSwitchMode}
>
{t['Convert to ']()}
@@ -133,7 +133,7 @@ export const PageHeaderMenuButton = ({ docId }: PageMenuProps) => {
>
diff --git a/packages/frontend/mobile/src/pages/workspace/search.tsx b/packages/frontend/mobile/src/pages/workspace/search.tsx
index c59b4d13f..687aa90a7 100644
--- a/packages/frontend/mobile/src/pages/workspace/search.tsx
+++ b/packages/frontend/mobile/src/pages/workspace/search.tsx
@@ -133,7 +133,7 @@ export const Component = () => {
return (
<>
-
+
{
title="Recent"
headerClassName={styles.header}
className={styles.recentSection}
+ testId="recent-docs"
>
-
+
{cardMetas.map((doc, index) => (
-
diff --git a/tests/affine-mobile/e2e/detail.spec.ts b/tests/affine-mobile/e2e/detail.spec.ts
new file mode 100644
index 000000000..10ca9625d
--- /dev/null
+++ b/tests/affine-mobile/e2e/detail.spec.ts
@@ -0,0 +1,34 @@
+import { test } from '@affine-test/kit/mobile';
+import { expect } from '@playwright/test';
+
+test.beforeEach(async ({ page }) => {
+ const docsTab = page.locator('#app-tabs').getByRole('tab', { name: 'all' });
+ await expect(docsTab).toBeVisible();
+ await docsTab.click();
+ await page.getByTestId('doc-card').first().click();
+ await expect(page.locator('.affine-edgeless-viewport')).toBeVisible();
+});
+
+test('can open page view more menu', async ({ page }) => {
+ await page.click('[data-testid="detail-page-header-more-button"]');
+ await expect(page.getByRole('dialog')).toBeVisible();
+});
+
+test('switch to page mode', async ({ page }) => {
+ await page.click('[data-testid="detail-page-header-more-button"]');
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ await page.getByRole('menuitem', { name: 'convert to page' }).click();
+ await expect(page.locator('.doc-title-container')).toBeVisible();
+});
+
+test('doc info', async ({ 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();
+ await expect(page.getByRole('button', { name: 'Back' })).toBeVisible();
+
+ await expect(page.getByRole('dialog')).toContainText('Created');
+ await expect(page.getByRole('dialog')).toContainText('Updated');
+});
diff --git a/tests/affine-mobile/e2e/home.spec.ts b/tests/affine-mobile/e2e/home.spec.ts
new file mode 100644
index 000000000..c6591fdcb
--- /dev/null
+++ b/tests/affine-mobile/e2e/home.spec.ts
@@ -0,0 +1,63 @@
+import { test } from '@affine-test/kit/mobile';
+import { expect } from '@playwright/test';
+
+import { expandCollapsibleSection } from './utils';
+
+test('after loaded, will land on the home page', async ({ page }) => {
+ await expect(page).toHaveURL(/.*\/home/);
+});
+
+test('app tabs is visible', async ({ page }) => {
+ const tabs = page.locator('#app-tabs');
+ await expect(tabs).toBeVisible();
+
+ await expect(tabs.getByRole('tab', { name: 'home' })).toBeVisible();
+ await expect(tabs.getByRole('tab', { name: 'all' })).toBeVisible();
+ await expect(tabs.getByRole('tab', { name: 'search' })).toBeVisible();
+});
+
+test('recent docs', async ({ page }) => {
+ const recentSection = await expandCollapsibleSection(page, 'recent');
+
+ const docs = recentSection.getByTestId('doc-card');
+ const firstDoc = docs.first();
+
+ await expect(firstDoc).toBeVisible();
+
+ const title = await firstDoc
+ .getByTestId('doc-card-header')
+ .getByRole('heading')
+ .textContent();
+
+ // when click favorite icon, will show in the favorites section
+ await docs.getByRole('button', { name: 'favorite' }).first().click();
+
+ const favList = await expandCollapsibleSection(page, 'favorites');
+ await expect(favList).toBeVisible();
+
+ if (title) {
+ await expect(favList).toContainText(title);
+ }
+});
+
+test('all tab', async ({ page }) => {
+ const docsTab = page.locator('#app-tabs').getByRole('tab', { name: 'all' });
+ await expect(docsTab).toBeVisible();
+
+ await docsTab.click();
+
+ const todayDocs = page.getByTestId('doc-card');
+ expect(await todayDocs.count()).toBeGreaterThan(0);
+});
+
+test('search tab', async ({ page }) => {
+ const searchTab = page
+ .locator('#app-tabs')
+ .getByRole('tab', { name: 'search' });
+ await expect(searchTab).toBeVisible();
+
+ await searchTab.click();
+
+ const searchInput = page.getByTestId('search-header').getByRole('textbox');
+ await expect(searchInput).toBeVisible();
+});
diff --git a/tests/affine-mobile/e2e/utils.ts b/tests/affine-mobile/e2e/utils.ts
new file mode 100644
index 000000000..e70049323
--- /dev/null
+++ b/tests/affine-mobile/e2e/utils.ts
@@ -0,0 +1,15 @@
+/* eslint-disable unicorn/prefer-dom-node-dataset */
+import { expect, type Page } from '@playwright/test';
+
+export async function expandCollapsibleSection(page: Page, name: string) {
+ const divider = page.locator(`[data-collapsible]:has-text("${name}")`);
+ if ((await divider.getAttribute('data-collapsed')) === 'true') {
+ await divider.click();
+ }
+ await expect(divider).toHaveAttribute('data-collapsed', 'false');
+ const section = divider.locator(
+ '~ [data-testid="collapsible-section-content"]'
+ );
+ await expect(section).toBeVisible();
+ return section;
+}
diff --git a/tests/affine-mobile/package.json b/tests/affine-mobile/package.json
new file mode 100644
index 000000000..5dd6876a0
--- /dev/null
+++ b/tests/affine-mobile/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@affine-test/affine-mobile",
+ "private": true,
+ "scripts": {
+ "e2e": "yarn playwright test"
+ },
+ "devDependencies": {
+ "@affine-test/fixtures": "workspace:*",
+ "@affine-test/kit": "workspace:*",
+ "@playwright/test": "=1.46.1"
+ },
+ "version": "0.16.0"
+}
diff --git a/tests/affine-mobile/playwright.config.ts b/tests/affine-mobile/playwright.config.ts
new file mode 100644
index 000000000..28dc7f78a
--- /dev/null
+++ b/tests/affine-mobile/playwright.config.ts
@@ -0,0 +1,74 @@
+import { testResultDir } from '@affine-test/kit/playwright';
+import { devices, type PlaywrightTestConfig } from '@playwright/test';
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+ testDir: './e2e',
+ fullyParallel: true,
+ timeout: process.env.CI ? 60_000 : 30_000,
+ outputDir: testResultDir,
+ projects: [
+ {
+ name: 'Mobile Safari',
+ use: {
+ ...devices['iPhone 14'],
+ },
+ },
+ {
+ name: 'Mobile Chrome',
+ use: {
+ ...devices['Pixel 5'],
+ },
+ },
+ ],
+ use: {
+ baseURL: 'http://localhost:8080/',
+ actionTimeout: 10 * 1000,
+ locale: 'en-US',
+ // Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer
+ // You can open traces locally(`npx playwright show-trace trace.zip`)
+ // or in your browser on [Playwright Trace Viewer](https://trace.playwright.dev/).
+ trace: 'on-first-retry',
+ // Record video only when retrying a test for the first time.
+ video: 'on-first-retry',
+ },
+ forbidOnly: !!process.env.CI,
+ workers: 4,
+ retries: 1,
+ // 'github' for GitHub Actions CI to generate annotations, plus a concise 'dot'
+ // default 'list' when running locally
+ // See https://playwright.dev/docs/test-reporters#github-actions-annotations
+ reporter: process.env.CI ? 'github' : 'list',
+
+ webServer: [
+ {
+ command: 'yarn run serve:test-static',
+ port: 8081,
+ timeout: 120 * 1000,
+ reuseExistingServer: !process.env.CI,
+ env: {
+ COVERAGE: process.env.COVERAGE || 'false',
+ ENABLE_DEBUG_PAGE: '1',
+ },
+ },
+ // Intentionally not building the web, reminds you to run it by yourself.
+ {
+ command: 'yarn workspace @affine/mobile static-server',
+ port: 8080,
+ timeout: 120 * 1000,
+ reuseExistingServer: !process.env.CI,
+ env: {
+ COVERAGE: process.env.COVERAGE || 'false',
+ },
+ },
+ ],
+};
+
+if (process.env.CI) {
+ config.retries = 3;
+ config.workers = '50%';
+}
+
+export default config;
diff --git a/tests/affine-mobile/tsconfig.json b/tests/affine-mobile/tsconfig.json
new file mode 100644
index 000000000..828052ad7
--- /dev/null
+++ b/tests/affine-mobile/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "outDir": "lib"
+ },
+ "include": ["e2e"],
+ "references": [
+ {
+ "path": "../../tests/kit"
+ },
+ {
+ "path": "../../tests/fixtures"
+ }
+ ]
+}
diff --git a/tests/kit/mobile.ts b/tests/kit/mobile.ts
new file mode 100644
index 000000000..f88228f7b
--- /dev/null
+++ b/tests/kit/mobile.ts
@@ -0,0 +1,24 @@
+import { expect } from '@playwright/test';
+
+import { test as baseTest } from './playwright';
+
+type CurrentDocCollection = {
+ meta: { id: string; flavour: string };
+};
+
+export const test = baseTest.extend<{
+ workspace: {
+ current: () => Promise;
+ };
+}>({
+ page: async ({ page }, use) => {
+ await page.goto('/');
+ await expect(
+ page.locator('.affine-page-viewport[data-mode="edgeless"]')
+ ).toBeVisible({
+ timeout: 30 * 1000,
+ });
+ await page.goto('/');
+ await use(page);
+ },
+});
diff --git a/tsconfig.json b/tsconfig.json
index 12d348fd0..9b7a7ee95 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -133,6 +133,9 @@
{
"path": "./tests/affine-migration"
},
+ {
+ "path": "./tests/affine-mobile"
+ },
{
"path": "./tests/affine-legacy/0.7.0-canary.18"
},
diff --git a/yarn.lock b/yarn.lock
index 68b18786b..c9484f420 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -127,6 +127,16 @@ __metadata:
languageName: unknown
linkType: soft
+"@affine-test/affine-mobile@workspace:tests/affine-mobile":
+ version: 0.0.0-use.local
+ resolution: "@affine-test/affine-mobile@workspace:tests/affine-mobile"
+ dependencies:
+ "@affine-test/fixtures": "workspace:*"
+ "@affine-test/kit": "workspace:*"
+ "@playwright/test": "npm:=1.46.1"
+ languageName: unknown
+ linkType: soft
+
"@affine-test/fixtures@workspace:*, @affine-test/fixtures@workspace:tests/fixtures":
version: 0.0.0-use.local
resolution: "@affine-test/fixtures@workspace:tests/fixtures"