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"