From 93e4a3b48f2bb804c0cd3b5b21189c2f7bd2d383 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 10 Mar 2026 11:32:00 +0800 Subject: [PATCH] feat: add cross-platform e2e coverage (#4901) * feat: add cross-platform e2e coverage * fix: stabilize desktop e2e navigation * fix: harden desktop e2e read flow * fix: harden desktop e2e auth and follow flows * fix: stabilize desktop e2e discover entry flows * fix: run android e2e in a single shell * fix: speed up mobile e2e builds in ci * fix: build android e2e app from mobile workspace * fix: invoke android gradle build from repo root * fix: use absolute path for android ci build * fix: restore android eas build in ci * fix: target iOS simulator by udid in ci * fix: align desktop e2e with discover card flow * fix: reorder web e2e after relogin * fix: navigate web e2e via real hash url * fix: close stale settings modal before reopening * fix: track android maestro flows * fix: wait for settings router in web e2e * fix: wait for web settings sync propagation * fix: stabilize desktop e2e settings flows * fix: harden desktop e2e regressions * fix: stabilize desktop e2e selectors * fix: read desktop entry state from locators * fix: relax desktop e2e entry assertions * fix: avoid waiting on missing desktop feed ids --- .github/workflows/e2e.yml | 241 +++++++ .gitignore | 13 + apps/desktop/e2e/playwright.config.ts | 54 ++ apps/desktop/e2e/support/account.ts | 44 ++ apps/desktop/e2e/support/app.ts | 673 ++++++++++++++++++ apps/desktop/e2e/support/electron.ts | 70 ++ apps/desktop/e2e/support/env.ts | 83 +++ apps/desktop/e2e/tests/electron/core.spec.ts | 59 ++ apps/desktop/e2e/tests/web/core.spec.ts | 78 ++ .../e2e/tests/web/settings-sync.spec.ts | 88 +++ .../layer/main/src/before-bootstrap.ts | 9 +- .../ui/button/CommandActionButton.tsx | 2 + .../ui/modal/stacked/components.tsx | 1 + .../src/components/ui/modal/stacked/modal.tsx | 1 + .../src/hooks/common/useRecaptchaToken.ts | 10 + .../layer/renderer/src/modules/auth/Form.tsx | 22 +- .../src/modules/auth/LoginModalContent.tsx | 4 + .../src/modules/discover/DiscoverForm.tsx | 8 +- .../src/modules/discover/FeedForm.tsx | 9 +- .../modules/discover/UnifiedDiscoverForm.tsx | 13 +- .../entry-column/layouts/EntryItemWrapper.tsx | 7 +- .../modal/ConfirmDestroyModalContent.tsx | 2 +- .../src/modules/settings/helper/sync-queue.ts | 50 +- .../src/modules/settings/modal/layout.tsx | 1 + .../src/modules/settings/tabs/feeds.tsx | 2 + .../src/modules/settings/tabs/general.tsx | 1 + .../SubscriptionTabButton.tsx | 5 + .../renderer/src/modules/user/LoginButton.tsx | 1 + .../src/modules/user/ProfileButton.tsx | 3 + apps/desktop/layer/renderer/src/router.tsx | 6 +- .../desktop/layer/renderer/src/router.web.tsx | 6 +- apps/desktop/package.json | 7 + apps/desktop/vite.config.ts | 22 +- apps/mobile/.gitignore | 2 + apps/mobile/app.config.ts | 2 + apps/mobile/e2e/README.md | 32 + apps/mobile/e2e/flows/android/core.yaml | 17 + .../e2e/flows/android/timeline-entry.yaml | 52 ++ apps/mobile/e2e/flows/ios/auth.yaml | 11 + apps/mobile/e2e/flows/ios/content.yaml | 11 + apps/mobile/e2e/flows/ios/core.yaml | 19 + .../ios/ensure-onboarding-unfollowed.yaml | 43 ++ .../e2e/flows/ios/follow-onboarding.yaml | 34 + apps/mobile/e2e/flows/ios/login.yaml | 37 + apps/mobile/e2e/flows/ios/register.yaml | 48 ++ apps/mobile/e2e/flows/ios/sign-out.yaml | 23 + apps/mobile/e2e/flows/ios/timeline-entry.yaml | 80 +++ .../e2e/flows/ios/unfollow-onboarding.yaml | 35 + apps/mobile/e2e/flows/shared/core.yaml | 9 + .../shared/dismiss-ios-system-modal.yaml | 9 + .../shared/ensure-onboarding-unfollowed.yaml | 23 + .../e2e/flows/shared/follow-onboarding.yaml | 27 + apps/mobile/e2e/flows/shared/login.yaml | 38 + apps/mobile/e2e/flows/shared/open-auth.yaml | 73 ++ apps/mobile/e2e/flows/shared/register.yaml | 41 ++ apps/mobile/e2e/flows/shared/sign-out.yaml | 20 + .../e2e/flows/shared/timeline-entry.yaml | 48 ++ .../e2e/flows/shared/unfollow-onboarding.yaml | 24 + apps/mobile/e2e/run-maestro.sh | 144 ++++ apps/mobile/eas.json | 19 + apps/mobile/ios/Folo/Supporting/Expo.plist | 2 +- apps/mobile/ios/Podfile | 29 +- apps/mobile/package.json | 17 + apps/mobile/plugins/with-follow-assets.js | 4 +- .../src/components/common/NoLoginInfo.tsx | 1 + .../layouts/header/HeaderElements.tsx | 16 +- .../layouts/header/NavigationHeader.tsx | 4 + .../src/components/layouts/tabbar/Tabbar.tsx | 100 +-- .../src/components/ui/form/TextField.tsx | 2 + .../src/components/ui/grouped/GroupedList.tsx | 15 +- apps/mobile/src/lib/api-client.ts | 41 +- apps/mobile/src/lib/auth.ts | 60 +- apps/mobile/src/lib/e2e-config.ts | 16 + apps/mobile/src/lib/i18n.ts | 13 +- apps/mobile/src/lib/proxy-env.ts | 38 +- apps/mobile/src/lib/secure-store.ts | 84 +++ .../mobile/src/modules/context-menu/entry.tsx | 2 +- .../src/modules/discover/FeedSummary.tsx | 5 +- .../discover/search-tabs/SearchFeed.tsx | 26 +- .../discover/search-tabs/SearchFeedCard.tsx | 67 +- apps/mobile/src/modules/discover/search.tsx | 2 + .../entry-list/EntryListContentArticle.tsx | 1 + .../entry-list/templates/EntryNormalItem.tsx | 3 + apps/mobile/src/modules/feed/FollowFeed.tsx | 63 +- apps/mobile/src/modules/login/email.tsx | 265 +++++-- apps/mobile/src/modules/login/index.tsx | 4 +- apps/mobile/src/modules/login/social.tsx | 1 + .../modules/screen/TimelineViewSelector.tsx | 4 + apps/mobile/src/modules/screen/action.tsx | 2 +- .../src/modules/settings/SettingsList.tsx | 70 +- .../src/modules/settings/UserHeaderBanner.tsx | 1 + .../mobile/src/modules/settings/sync-queue.ts | 73 +- .../items/ListSubscriptionItem.tsx | 1 + .../subscription/items/SubscriptionItem.tsx | 7 + .../src/screens/(modal)/LoginScreen.tsx | 44 +- apps/mobile/src/screens/OnboardingScreen.tsx | 6 +- .../components/src/ui/select/responsive.tsx | 8 +- pnpm-lock.yaml | 85 ++- 98 files changed, 3413 insertions(+), 285 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 apps/desktop/e2e/playwright.config.ts create mode 100644 apps/desktop/e2e/support/account.ts create mode 100644 apps/desktop/e2e/support/app.ts create mode 100644 apps/desktop/e2e/support/electron.ts create mode 100644 apps/desktop/e2e/support/env.ts create mode 100644 apps/desktop/e2e/tests/electron/core.spec.ts create mode 100644 apps/desktop/e2e/tests/web/core.spec.ts create mode 100644 apps/desktop/e2e/tests/web/settings-sync.spec.ts create mode 100644 apps/mobile/e2e/README.md create mode 100644 apps/mobile/e2e/flows/android/core.yaml create mode 100644 apps/mobile/e2e/flows/android/timeline-entry.yaml create mode 100644 apps/mobile/e2e/flows/ios/auth.yaml create mode 100644 apps/mobile/e2e/flows/ios/content.yaml create mode 100644 apps/mobile/e2e/flows/ios/core.yaml create mode 100644 apps/mobile/e2e/flows/ios/ensure-onboarding-unfollowed.yaml create mode 100644 apps/mobile/e2e/flows/ios/follow-onboarding.yaml create mode 100644 apps/mobile/e2e/flows/ios/login.yaml create mode 100644 apps/mobile/e2e/flows/ios/register.yaml create mode 100644 apps/mobile/e2e/flows/ios/sign-out.yaml create mode 100644 apps/mobile/e2e/flows/ios/timeline-entry.yaml create mode 100644 apps/mobile/e2e/flows/ios/unfollow-onboarding.yaml create mode 100644 apps/mobile/e2e/flows/shared/core.yaml create mode 100644 apps/mobile/e2e/flows/shared/dismiss-ios-system-modal.yaml create mode 100644 apps/mobile/e2e/flows/shared/ensure-onboarding-unfollowed.yaml create mode 100644 apps/mobile/e2e/flows/shared/follow-onboarding.yaml create mode 100644 apps/mobile/e2e/flows/shared/login.yaml create mode 100644 apps/mobile/e2e/flows/shared/open-auth.yaml create mode 100644 apps/mobile/e2e/flows/shared/register.yaml create mode 100644 apps/mobile/e2e/flows/shared/sign-out.yaml create mode 100644 apps/mobile/e2e/flows/shared/timeline-entry.yaml create mode 100644 apps/mobile/e2e/flows/shared/unfollow-onboarding.yaml create mode 100755 apps/mobile/e2e/run-maestro.sh create mode 100644 apps/mobile/src/lib/e2e-config.ts create mode 100644 apps/mobile/src/lib/secure-store.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..39fccd9d0 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,241 @@ +name: ✅ E2E + +on: + pull_request: + paths: + - "apps/desktop/**" + - "apps/mobile/**" + - "packages/**" + - "pnpm-lock.yaml" + - ".github/workflows/e2e.yml" + push: + branches: + - main + - dev + paths: + - "apps/desktop/**" + - "apps/mobile/**" + - "packages/**" + - "pnpm-lock.yaml" + - ".github/workflows/e2e.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} + +env: + NODE_OPTIONS: --max-old-space-size=8192 + +jobs: + desktop-web: + name: Desktop Web + runs-on: ubuntu-latest + env: + FOLO_E2E_PROFILE: prod + FOLO_E2E_WEB_DEBUG_PROXY_PATH: /__debug_proxy.html + FOLO_E2E_WEB_DEV_API_URL: https://api.folo.is + FOLO_E2E_WEB_DEV_WEB_URL: https://app.folo.is + steps: + - name: 📦 Checkout code + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Install Playwright browsers + working-directory: apps/desktop + run: pnpm exec playwright install --with-deps chromium + + - name: Run web E2E + working-directory: apps/desktop + run: pnpm run e2e:web + + - name: Upload desktop web artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: desktop-web-e2e + path: | + apps/desktop/e2e/playwright-report + apps/desktop/e2e/test-results + retention-days: 14 + + desktop-electron: + name: Desktop Electron + runs-on: macos-latest + env: + FOLO_E2E_PROFILE: prod + steps: + - name: 📦 Checkout code + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Run electron E2E + working-directory: apps/desktop + run: pnpm run e2e:electron + + - name: Upload desktop electron artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: desktop-electron-e2e + path: | + apps/desktop/e2e/playwright-report + apps/desktop/e2e/test-results + retention-days: 14 + + mobile-android: + name: Mobile Android + if: github.secret_source != 'None' + runs-on: ubuntu-latest + steps: + - name: 📦 Checkout code + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: "17" + distribution: "zulu" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: 📱 Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install Maestro CLI + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Install dependencies + run: pnpm install + + - name: Build Android E2E app + working-directory: apps/mobile + run: eas build --platform android --profile e2e-android --local --output=${{ runner.temp }}/folo-e2e.apk + + - name: Run Android E2E + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_7 + emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect + script: | + adb install -r "${{ runner.temp }}/folo-e2e.apk" + bash -lc 'cd apps/mobile && MAESTRO_DEBUG_OUTPUT="${{ github.workspace }}/apps/mobile/e2e/artifacts/android" pnpm run e2e:android' + + - name: Upload mobile android artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: mobile-android-e2e + path: | + apps/mobile/e2e/artifacts/android + apps/mobile/report.xml + retention-days: 14 + + mobile-ios: + name: Mobile iOS + if: github.secret_source != 'None' + runs-on: macos-latest + steps: + - name: 📦 Checkout code + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install Maestro CLI + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Install dependencies + run: pnpm install + + - name: Boot iOS simulator + run: | + device_name="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ && $2 ~ /^[A-F0-9-]+$/ { gsub(/[[:space:]]+$/, "", $1); print $1; exit }')" + device_id="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ && $2 ~ /^[A-F0-9-]+$/ { print $2; exit }')" + if [ -z "$device_id" ]; then + echo "No available iPhone simulator found" + xcrun simctl list devices available + exit 1 + fi + echo "Using simulator: ${device_name} (${device_id})" + echo "MAESTRO_IOS_DEVICE_ID=${device_id}" >> "$GITHUB_ENV" + xcrun simctl boot "$device_id" || true + xcrun simctl bootstatus "$device_id" -b + + - name: Build iOS simulator E2E app + working-directory: apps/mobile + env: + PROFILE: e2e-ios-simulator + EXPO_PUBLIC_E2E_ENV_PROFILE: prod + EXPO_PUBLIC_E2E_LANGUAGE: en + run: | + pnpm exec expo run:ios --configuration Release -d "$MAESTRO_IOS_DEVICE_ID" --no-bundler + app_path="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n 1)" + if [ -z "$app_path" ]; then + echo "Unable to find built .app bundle" + exit 1 + fi + echo "MAESTRO_IOS_APP_PATH=$app_path" >> "$GITHUB_ENV" + + - name: Run iOS E2E + run: | + export MAESTRO_DEBUG_OUTPUT="${{ github.workspace }}/apps/mobile/e2e/artifacts/ios" + cd apps/mobile + pnpm run e2e:ios + + - name: Upload mobile ios artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: mobile-ios-e2e + path: | + apps/mobile/e2e/artifacts/ios + apps/mobile/report.xml + retention-days: 14 diff --git a/.gitignore b/.gitignore index 0c5c4949e..5c4fa992f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,16 @@ apps/desktop/resources/cli .serena .wrangler + +# Local agent artifacts +.codex/ + +# E2E outputs +/apps/desktop/e2e/playwright-report/ +/apps/desktop/e2e/test-results/ +/apps/mobile/e2e/artifacts/ +/apps/mobile/report.xml +/report.xml + +# Mobile local E2E build artifacts +apps/mobile/build-*.tar.gz diff --git a/apps/desktop/e2e/playwright.config.ts b/apps/desktop/e2e/playwright.config.ts new file mode 100644 index 000000000..99c52ed33 --- /dev/null +++ b/apps/desktop/e2e/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from "@playwright/test" + +import { resolveDesktopE2EEnv } from "./support/env" + +const env = resolveDesktopE2EEnv() + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + workers: 1, + timeout: 120_000, + expect: { + timeout: 15_000, + }, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], + outputDir: "test-results", + use: { + baseURL: env.webBaseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + serviceWorkers: "block", + }, + webServer: { + command: "pnpm run dev:web", + cwd: env.desktopAppDir, + env: { + ...process.env, + VITE_API_URL: process.env.FOLO_E2E_WEB_DEV_API_URL ?? env.apiURL, + VITE_WEB_URL: process.env.FOLO_E2E_WEB_DEV_WEB_URL ?? env.webURL, + }, + url: env.webDevServerURL, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, + projects: [ + { + name: "web", + testMatch: /tests\/web\/.*\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + channel: "chromium", + ignoreHTTPSErrors: true, + launchOptions: { + args: ["--disable-web-security"], + }, + }, + }, + { + name: "electron", + testMatch: /tests\/electron\/.*\.spec\.ts/, + }, + ], +}) diff --git a/apps/desktop/e2e/support/account.ts b/apps/desktop/e2e/support/account.ts new file mode 100644 index 000000000..327907a7b --- /dev/null +++ b/apps/desktop/e2e/support/account.ts @@ -0,0 +1,44 @@ +import type { Page } from "@playwright/test" + +import type { DesktopE2EEnv } from "./env" + +export interface TestAccount { + email: string + password: string +} + +export const createTestAccount = (name: string): TestAccount => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + return { + email: `folo-e2e-${name}-${suffix}@example.com`, + password: process.env.FOLO_E2E_PASSWORD ?? "Password123!", + } +} + +export const tryDeleteCurrentUser = async (page: Page, env: DesktopE2EEnv) => { + return page.evaluate(async ({ apiURL }) => { + try { + const response = await fetch(`${apiURL}/better-auth/delete-user-custom`, { + method: "POST", + credentials: "include", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), + }) + + return { + ok: response.ok, + status: response.status, + text: await response.text(), + } + } catch (error) { + return { + ok: false, + status: -1, + text: error instanceof Error ? error.message : String(error), + } + } + }, env) +} diff --git a/apps/desktop/e2e/support/app.ts b/apps/desktop/e2e/support/app.ts new file mode 100644 index 000000000..4be585d95 --- /dev/null +++ b/apps/desktop/e2e/support/app.ts @@ -0,0 +1,673 @@ +import type { Locator, Page } from "@playwright/test" +import { expect } from "@playwright/test" + +import type { TestAccount } from "./account" +import type { DesktopE2EEnv } from "./env" +import { buildHashRoute, buildWebAppURL } from "./env" + +const ONBOARDING_FEED_URL = "folo://onboarding" + +const isVisible = async (locator: Locator) => locator.isVisible().catch(() => false) +const visibleByTestId = (page: Page, testId: string) => + page.locator(`[data-testid="${testId}"]:visible`).last() + +export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => { + await page.addInitScript( + (nextEnv) => { + window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token" + + if (!nextEnv) { + return + } + + const fixedEnv = { + VITE_API_URL: nextEnv.apiURL, + VITE_EXTERNAL_API_URL: nextEnv.apiURL, + VITE_WEB_URL: nextEnv.webURL, + } + + const target = + (globalThis as typeof globalThis & { __followEnv?: Record }).__followEnv ?? + {} + + const proxy = new Proxy(target, { + get(currentTarget, property, receiver) { + if (typeof property === "string" && property in fixedEnv) { + return fixedEnv[property as keyof typeof fixedEnv] + } + + return Reflect.get(currentTarget, property, receiver) + }, + set(currentTarget, property, value, receiver) { + if (typeof property === "string" && property in fixedEnv) { + return true + } + + return Reflect.set(currentTarget, property, value, receiver) + }, + ownKeys(currentTarget) { + return Array.from(new Set([...Reflect.ownKeys(currentTarget), ...Object.keys(fixedEnv)])) + }, + getOwnPropertyDescriptor(currentTarget, property) { + if (typeof property === "string" && property in fixedEnv) { + return { + configurable: true, + enumerable: true, + writable: false, + value: fixedEnv[property as keyof typeof fixedEnv], + } + } + + return Reflect.getOwnPropertyDescriptor(currentTarget, property) + }, + }) + + Object.defineProperty(globalThis, "__followEnv", { + configurable: true, + enumerable: false, + get() { + return proxy + }, + set() {}, + }) + }, + env ? { apiURL: env.apiURL, webURL: env.webURL } : undefined, + ) +} + +export const openWebApp = async (page: Page, env: DesktopE2EEnv, route = "/") => { + await injectRecaptchaToken(page, env) + await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" }) +} + +export const navigateInApp = async ( + page: Page, + env: DesktopE2EEnv, + route: string, + options?: { electron?: boolean }, +) => { + if (options?.electron) { + await page.evaluate((nextRoute) => { + window.location.hash = nextRoute + }, buildHashRoute(route)) + return + } + + await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" }) +} + +export const waitForAuthenticated = async (page: Page) => { + const isAuthenticatedUiReady = async () => { + const profileVisible = await page + .getByTestId("profile-menu-trigger") + .isVisible() + .catch(() => false) + const timelineVisible = await page + .getByTestId("timeline-tab-articles") + .isVisible() + .catch(() => false) + return profileVisible || timelineVisible + } + + try { + await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true) + } catch { + await page.reload({ waitUntil: "domcontentloaded" }) + await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true) + } +} + +export const waitForLoggedOut = async (page: Page) => { + await expect + .poll( + async () => { + const loginButtonVisible = await page + .getByTestId("login-button") + .last() + .isVisible() + .catch(() => false) + const loginModalVisible = await page + .getByTestId("login-modal") + .last() + .isVisible() + .catch(() => false) + const loginInputVisible = await page + .getByTestId("login-email-input") + .last() + .isVisible() + .catch(() => false) + const registerInputVisible = await page + .getByTestId("register-email-input") + .last() + .isVisible() + .catch(() => false) + + return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible + }, + { timeout: 30_000 }, + ) + .toBe(true) +} + +export const ensureLoginModal = async (page: Page) => { + await expect + .poll( + async () => { + const loginModalVisible = await page + .getByTestId("login-modal") + .last() + .isVisible() + .catch(() => false) + const loginButtonVisible = await page + .getByTestId("login-button") + .last() + .isVisible() + .catch(() => false) + const loginInputVisible = await page + .getByTestId("login-email-input") + .last() + .isVisible() + .catch(() => false) + const registerInputVisible = await page + .getByTestId("register-email-input") + .last() + .isVisible() + .catch(() => false) + + return loginModalVisible || loginButtonVisible || loginInputVisible || registerInputVisible + }, + { timeout: 30_000 }, + ) + .toBe(true) +} + +const ensureCredentialForm = async (page: Page, mode: "register" | "login") => { + const attemptEnsureCredentialForm = async () => { + await ensureLoginModal(page) + + const targetInput = visibleByTestId( + page, + mode === "register" ? "register-email-input" : "login-email-input", + ) + const loginButton = visibleByTestId(page, "login-button") + const loginModal = visibleByTestId(page, "login-modal") + const credentialProvider = visibleByTestId(page, "login-provider-credential") + const targetForm = visibleByTestId(page, mode === "register" ? "register-form" : "login-form") + const oppositeForm = visibleByTestId(page, mode === "register" ? "login-form" : "register-form") + const oppositeFormSwitcher = visibleByTestId( + page, + mode === "register" ? "login-switch-register" : "register-switch-login", + ) + + if (await isVisible(targetInput)) { + return + } + + if ((await isVisible(loginButton)) && !(await isVisible(loginModal))) { + await loginButton.click({ force: true }) + } + + if (await isVisible(targetInput)) { + return + } + + if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) { + await credentialProvider.click({ force: true, timeout: 30_000 }) + await expect + .poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), { + timeout: 30_000, + }) + .toBe(true) + } + + if (await isVisible(oppositeForm)) { + await oppositeFormSwitcher.click({ force: true, timeout: 30_000 }) + } + + await expect(targetInput).toBeVisible({ timeout: 30_000 }) + } + + let lastError: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await attemptEnsureCredentialForm() + return + } catch (error) { + lastError = error + if (attempt === 2) { + throw error + } + + await page.keyboard.press("Escape").catch(() => {}) + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}) + } + } + + throw lastError +} + +export const registerWithCredential = async (page: Page, account: TestAccount) => { + await ensureCredentialForm(page, "register") + await visibleByTestId(page, "register-email-input").fill(account.email) + await visibleByTestId(page, "register-password-input").fill(account.password) + await visibleByTestId(page, "register-confirm-password-input").fill(account.password) + await visibleByTestId(page, "register-submit").click({ force: true }) + await waitForAuthenticated(page) +} + +export const loginWithCredential = async (page: Page, account: TestAccount) => { + await ensureCredentialForm(page, "login") + await visibleByTestId(page, "login-email-input").fill(account.email) + await visibleByTestId(page, "login-password-input").fill(account.password) + await visibleByTestId(page, "login-submit").click({ force: true }) + await waitForAuthenticated(page) +} + +export const logoutFromProfileMenu = async (page: Page) => { + await page.keyboard.press("Escape").catch(() => {}) + await page.getByTestId("profile-menu-trigger").click() + + const signOutResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && response.url().includes("/better-auth/sign-out"), + { timeout: 30_000 }, + ) + + await page.getByTestId("profile-menu-logout").click() + await signOutResponse + + await expect + .poll( + async () => + page + .getByTestId("profile-menu-trigger") + .isVisible() + .catch(() => false), + { timeout: 30_000 }, + ) + .toBe(false) +} + +const deleteWithSession = async ( + page: Page, + env: DesktopE2EEnv, + path: string, + body: Record, +) => { + return page.evaluate( + async ({ url, payload }) => { + const response = await fetch(url, { + method: "DELETE", + credentials: "include", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }) + + return { + ok: response.ok, + status: response.status, + text: await response.text(), + } + }, + { + url: `${env.apiURL}${path}`, + payload: body, + }, + ) +} + +const waitForSettingsTabContent = async (page: Page, tab: "general" | "feeds") => { + if (tab === "general") { + await expect(page.getByTestId("settings-language-select")).toBeVisible({ timeout: 15_000 }) + return + } + + await expect + .poll(async () => page.locator('[data-testid^="settings-feed-row-"]').count(), { + timeout: 15_000, + }) + .toBeGreaterThan(0) +} + +export const openSettings = async (page: Page, tab: "general" | "feeds" = "general") => { + const settingsTab = page.getByTestId(`settings-tab-${tab}`) + const settingsModal = page.locator("#setting-modal").first() + + if (await settingsModal.isVisible().catch(() => false)) { + await page.keyboard.press("Escape").catch(() => {}) + await expect + .poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 }) + .toBe(false) + } + + await expect + .poll( + async () => + page.evaluate(() => { + const { router } = window as typeof window & { + router?: { showSettings?: (tab?: unknown) => void } + } + return typeof router?.showSettings === "function" + }), + { timeout: 15_000 }, + ) + .toBe(true) + + await page.evaluate((nextTab) => { + const { router } = window as typeof window & { + router?: { showSettings?: (tab?: unknown) => void } + } + router?.showSettings?.(nextTab) + }, tab) + + await expect(settingsTab).toBeVisible({ timeout: 15_000 }) + await waitForSettingsTabContent(page, tab) +} + +export const openSettingsTab = async (page: Page, tab: "general" | "feeds") => { + const settingsTab = page.getByTestId(`settings-tab-${tab}`) + await expect(settingsTab).toBeVisible({ timeout: 15_000 }) + + if (tab === "feeds") { + await expect + .poll( + async () => { + const className = (await settingsTab.getAttribute("class")) ?? "" + return !className.includes("opacity-50") + }, + { timeout: 15_000 }, + ) + .toBe(true) + } + + await settingsTab + .evaluate((element) => { + if (element instanceof HTMLElement) { + element.click() + } + }) + .catch(async () => { + await settingsTab.click({ force: true, noWaitAfter: true }) + }) + await waitForSettingsTabContent(page, tab) +} + +export const closeSettings = async (page: Page) => { + const settingsModal = page.locator("#setting-modal").first() + if (!(await settingsModal.isVisible().catch(() => false))) { + return + } + + await page.keyboard.press("Escape").catch(() => {}) + + if (await settingsModal.isVisible().catch(() => false)) { + const modalClose = settingsModal.getByTestId("modal-close").first() + if (await isVisible(modalClose)) { + await modalClose + .evaluate((element) => { + if (element instanceof HTMLElement) { + element.click() + } + }) + .catch(async () => { + await modalClose.click({ force: true, noWaitAfter: true }) + }) + } + } + + await expect + .poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 }) + .toBe(false) +} + +export const setLanguage = async (page: Page, label: string) => { + await page.getByTestId("settings-language-select").click() + await page.getByRole("option", { name: label }).click() +} + +export const getLanguageLabel = async (page: Page) => { + return page.getByTestId("settings-language-select").textContent() +} + +export const openOnboardingFeedForm = async ( + page: Page, + env: DesktopE2EEnv, + options?: { electron?: boolean }, +) => { + await navigateInApp(page, env, "/discover", options) + + const discoverInput = page.getByTestId("discover-form-input") + if (!(await discoverInput.isVisible().catch(() => false))) { + const discoverLink = page.locator('a[href="#/discover"], a[href="/discover"]').last() + if (await discoverLink.isVisible().catch(() => false)) { + await discoverLink.click({ force: true }) + } + } + + if (!(await discoverInput.isVisible().catch(() => false))) { + await page.evaluate(() => { + const nextRoute = "/discover" + const { router } = window as typeof window & { + router?: { navigate?: (route: string) => void } + } + router?.navigate?.(nextRoute) + }) + } + + await expect(discoverInput).toBeVisible({ timeout: 15_000 }) + await discoverInput.fill(ONBOARDING_FEED_URL) + await discoverInput.press("Enter") + await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 }) +} + +export const followOnboardingFeed = async ( + page: Page, + env: DesktopE2EEnv, + options?: { electron?: boolean }, +) => { + await openOnboardingFeedForm(page, env, options) + const onboardingDiscoverCard = page + .locator("[data-feed-id]") + .filter({ hasText: "Welcome to Folo" }) + .first() + const followButton = onboardingDiscoverCard.getByRole("button", { name: /^Follow$/i }) + if (await followButton.isVisible().catch(() => false)) { + await followButton.click({ force: true }) + } + await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 }) +} + +export const dismissFeedForm = async (page: Page) => { + const cancelButton = visibleByTestId(page, "feed-form-cancel") + const dialog = page.locator('[role="dialog"]').last() + + if (!(await cancelButton.isVisible().catch(() => false))) { + if (await dialog.isVisible().catch(() => false)) { + await page.keyboard.press("Escape").catch(() => {}) + } + return + } + + await cancelButton + .evaluate((element) => { + if (element instanceof HTMLElement) { + element.click() + } + }) + .catch(() => {}) + + if ( + (await cancelButton.isVisible().catch(() => false)) || + (await dialog.isVisible().catch(() => false)) + ) { + await page.keyboard.press("Escape").catch(() => {}) + } +} + +const findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => { + const targetedFeedRow = onboardingFeedId + ? page.getByTestId(`settings-feed-row-${onboardingFeedId}`) + : null + const fallbackFeedRow = page + .locator('[data-testid^="settings-feed-row-"]') + .filter({ + hasText: "Welcome to Folo", + }) + .first() + const settingsViewport = page.locator("#setting-modal [data-radix-scroll-area-viewport]").first() + + await settingsViewport + .evaluate((element) => { + if (element instanceof HTMLElement) { + element.scrollTop = 0 + } + }) + .catch(() => {}) + + for (let attempt = 0; attempt < 24; attempt++) { + if (targetedFeedRow && (await targetedFeedRow.isVisible().catch(() => false))) { + return targetedFeedRow + } + + if (await fallbackFeedRow.isVisible().catch(() => false)) { + return fallbackFeedRow + } + + await settingsViewport.hover().catch(() => {}) + await page.mouse.wheel(0, 1200) + await page.waitForTimeout(150) + } + + return targetedFeedRow && (await targetedFeedRow.count()) > 0 ? targetedFeedRow : fallbackFeedRow +} + +export const unsubscribeFirstFeedFromSettings = async (page: Page, env?: DesktopE2EEnv) => { + const onboardingFeedItem = page + .locator("[data-feed-id]") + .filter({ + hasText: "Welcome to Folo", + }) + .first() + const onboardingFeedId = + (await onboardingFeedItem.count()) > 0 + ? await onboardingFeedItem.getAttribute("data-feed-id") + : null + let unsubscribedInSettings = false + + await openSettings(page) + await openSettingsTab(page, "feeds") + const feedRow = await findSettingsFeedRow(page, onboardingFeedId) + + if (await feedRow.isVisible().catch(() => false)) { + await feedRow.scrollIntoViewIfNeeded().catch(() => {}) + const feedRowTestId = await feedRow.getAttribute("data-testid") + await feedRow.click() + await expect(page.getByTestId("feeds-batch-unsubscribe")).toBeVisible({ timeout: 15_000 }) + await page.getByTestId("feeds-batch-unsubscribe").click() + await page.getByTestId("confirm-destroy").click() + + if (feedRowTestId) { + await expect(page.getByTestId(feedRowTestId)).toHaveCount(0, { timeout: 15_000 }) + } else { + await expect + .poll( + async () => + page + .getByTestId("feeds-batch-unsubscribe") + .isVisible() + .catch(() => false), + { + timeout: 15_000, + }, + ) + .toBe(false) + } + + unsubscribedInSettings = true + } + + const isElectronApp = page.url().startsWith("app://") + + if (!unsubscribedInSettings && !isElectronApp && env && onboardingFeedId) { + const response = await deleteWithSession(page, env, "/subscriptions", { + feedId: onboardingFeedId, + }) + + expect(response.ok).toBe(true) + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}) + } + + if (isElectronApp) { + expect(unsubscribedInSettings).toBe(true) + } +} + +export const expectOnboardingFeedUnsubscribed = async ( + page: Page, + env: DesktopE2EEnv, + options?: { electron?: boolean }, +) => { + await openOnboardingFeedForm(page, env, options) + await expect(page.getByTestId("feed-form-cancel")).toHaveCount(0) +} + +export const expectTimelineSwitchAndEntryReadFlow = async ( + page: Page, + env: DesktopE2EEnv, + options?: { electron?: boolean }, +) => { + await navigateInApp(page, env, "/", options) + + await page.getByTestId("timeline-tab-videos").click() + await expect.poll(async () => page.locator("[data-entry-id]").count()).toBe(0) + + await page.getByTestId("timeline-tab-articles").click() + await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) + + const onboardingFeed = page + .locator("[data-feed-id]") + .filter({ + hasText: "Welcome to Folo", + }) + .first() + if (await isVisible(onboardingFeed)) { + await onboardingFeed.scrollIntoViewIfNeeded().catch(() => {}) + await onboardingFeed.click({ force: true }) + } + + const unreadOnboardingEntry = page.locator('[data-entry-id][data-read="false"]').first() + const fallbackOnboardingEntry = page.locator("[data-entry-id]").first() + const firstOnboardingEntry = (await unreadOnboardingEntry.isVisible().catch(() => false)) + ? unreadOnboardingEntry + : fallbackOnboardingEntry + await expect(firstOnboardingEntry).toBeVisible({ timeout: 15_000 }) + + const onboardingEntryId = await firstOnboardingEntry.getAttribute("data-entry-id") + const onboardingEntry = onboardingEntryId + ? page.locator(`[data-entry-id="${onboardingEntryId}"]`) + : firstOnboardingEntry + + if (onboardingEntryId && !(await unreadOnboardingEntry.isVisible().catch(() => false))) { + const response = await deleteWithSession(page, env, "/reads", { + entryId: onboardingEntryId, + }) + expect(response.ok).toBe(true) + await page.reload({ waitUntil: "domcontentloaded" }) + if (await isVisible(onboardingFeed)) { + await onboardingFeed.click({ force: true }) + } + await expect(onboardingEntry).toHaveAttribute("data-read", "false") + } + + await onboardingEntry.click({ force: true }) + await expect(page.getByTestId("entry-render")).toBeVisible({ timeout: 15_000 }) + + if (onboardingEntryId) { + const response = await deleteWithSession(page, env, "/reads", { + entryId: onboardingEntryId, + }) + expect(response.ok).toBe(true) + } +} diff --git a/apps/desktop/e2e/support/electron.ts b/apps/desktop/e2e/support/electron.ts new file mode 100644 index 000000000..381e4f13a --- /dev/null +++ b/apps/desktop/e2e/support/electron.ts @@ -0,0 +1,70 @@ +import { execSync } from "node:child_process" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" + +import type { ElectronApplication, Page } from "@playwright/test" +import { _electron as electron } from "@playwright/test" +import { join } from "pathe" + +import type { DesktopE2EEnv } from "./env" + +let buildSignature: string | null = null + +const ensureElectronBuilt = (env: DesktopE2EEnv) => { + const nextSignature = `${env.apiURL}|${env.webURL}` + if (buildSignature === nextSignature) { + return + } + + execSync("pnpm run build:electron-vite", { + cwd: env.desktopAppDir, + env: { + ...process.env, + VITE_API_URL: env.apiURL, + VITE_WEB_URL: env.webURL, + }, + stdio: "inherit", + }) + + buildSignature = nextSignature +} + +export const launchElectronApp = async (env: DesktopE2EEnv) => { + ensureElectronBuilt(env) + + const userDataDir = await mkdtemp(join(tmpdir(), "folo-e2e-")) + const electronApp = await electron.launch({ + args: [env.desktopAppDir], + cwd: env.desktopAppDir, + env: { + ...process.env, + CI: process.env.CI ?? "1", + NODE_ENV: "test", + VITE_API_URL: env.apiURL, + VITE_WEB_URL: env.webURL, + FOLO_E2E_USER_DATA_DIR: userDataDir, + }, + timeout: 120_000, + }) + + const page = await electronApp.firstWindow() + await page.waitForLoadState("domcontentloaded") + await page.evaluate(() => { + window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token" + }) + + return { + electronApp, + page, + userDataDir, + } +} + +export const closeElectronApp = async (app: { + electronApp: ElectronApplication + page: Page + userDataDir: string +}) => { + await app.electronApp.close().catch(() => {}) + await rm(app.userDataDir, { force: true, recursive: true }) +} diff --git a/apps/desktop/e2e/support/env.ts b/apps/desktop/e2e/support/env.ts new file mode 100644 index 000000000..359bda215 --- /dev/null +++ b/apps/desktop/e2e/support/env.ts @@ -0,0 +1,83 @@ +import { fileURLToPath } from "node:url" + +import { join } from "pathe" + +export type DesktopE2EProfile = "local" | "prod" + +const DESKTOP_E2E_PROFILES = { + local: { + apiURL: "http://localhost:3000", + webURL: "http://localhost:2233", + webBaseURL: "http://127.0.0.1:2233", + webUsesHashRouter: false, + }, + prod: { + apiURL: "https://api.folo.is", + webURL: "https://app.folo.is", + webBaseURL: null, + webUsesHashRouter: true, + }, +} as const + +export interface DesktopE2EEnv { + profile: DesktopE2EProfile + apiURL: string + webURL: string + webBaseURL: string + webUsesHashRouter: boolean + webDevServerURL: string + debugProxyPath: string + desktopAppDir: string +} + +const supportDir = fileURLToPath(new URL(".", import.meta.url)) +const desktopAppDir = join(supportDir, "..", "..") + +const normalizeRoute = (route: string) => { + if (!route || route === "/") { + return "/" + } + + return route.startsWith("/") ? route : `/${route}` +} + +export const resolveDesktopE2EEnv = (): DesktopE2EEnv => { + const profile = (process.env.FOLO_E2E_PROFILE ?? "local") as DesktopE2EProfile + const resolvedProfile = profile in DESKTOP_E2E_PROFILES ? profile : "local" + const profileConfig = DESKTOP_E2E_PROFILES[resolvedProfile] + const webDevServerURL = process.env.FOLO_E2E_WEB_DEV_SERVER_URL ?? "http://127.0.0.1:2233" + const debugProxyPath = process.env.FOLO_E2E_WEB_DEBUG_PROXY_PATH ?? "/__debug_proxy.html" + + const webBaseURL = + resolvedProfile === "prod" + ? new URL( + `${debugProxyPath}?debug-host=${encodeURIComponent(webDevServerURL)}`, + profileConfig.webURL, + ).toString() + : profileConfig.webBaseURL + + return { + profile: resolvedProfile, + apiURL: process.env.FOLO_E2E_API_URL ?? profileConfig.apiURL, + webURL: process.env.FOLO_E2E_WEB_URL ?? profileConfig.webURL, + webBaseURL, + webUsesHashRouter: profileConfig.webUsesHashRouter, + webDevServerURL, + debugProxyPath, + desktopAppDir, + } +} + +export const buildWebAppURL = (env: DesktopE2EEnv, route = "/") => { + const normalizedRoute = normalizeRoute(route) + + if (env.webUsesHashRouter) { + const url = new URL(env.webBaseURL) + url.hash = normalizedRoute + return url.toString() + } + + return new URL(normalizedRoute, `${env.webBaseURL}/`).toString() +} + +export const buildHashRoute = (route = "/") => normalizeRoute(route) diff --git a/apps/desktop/e2e/tests/electron/core.spec.ts b/apps/desktop/e2e/tests/electron/core.spec.ts new file mode 100644 index 000000000..a70645022 --- /dev/null +++ b/apps/desktop/e2e/tests/electron/core.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test" + +import { createTestAccount, tryDeleteCurrentUser } from "../../support/account" +import { + dismissFeedForm, + expectTimelineSwitchAndEntryReadFlow, + followOnboardingFeed, + loginWithCredential, + logoutFromProfileMenu, + registerWithCredential, + unsubscribeFirstFeedFromSettings, +} from "../../support/app" +import { closeElectronApp, launchElectronApp } from "../../support/electron" +import { resolveDesktopE2EEnv } from "../../support/env" + +test.describe("electron core flows", () => { + test("covers registration, login, follow, unfollow, timeline and read state", async () => { + test.setTimeout(240_000) + + const env = resolveDesktopE2EEnv() + const account = createTestAccount("electron-core") + let electronApp = await launchElectronApp(env) + + try { + await test.step("registers a new account", async () => { + await registerWithCredential(electronApp.page, account) + }) + + await test.step("logs out and logs back in", async () => { + await logoutFromProfileMenu(electronApp.page) + await closeElectronApp(electronApp) + electronApp = await launchElectronApp(env) + await loginWithCredential(electronApp.page, account) + }) + + await test.step("follows onboarding feed", async () => { + await followOnboardingFeed(electronApp.page, env, { electron: true }) + await dismissFeedForm(electronApp.page) + }) + + await test.step("switches timeline, opens an entry, and toggles read state", async () => { + await expectTimelineSwitchAndEntryReadFlow(electronApp.page, env, { electron: true }) + }) + + await test.step("unsubscribes onboarding feed from settings", async () => { + await unsubscribeFirstFeedFromSettings(electronApp.page, env) + }) + + const cleanup = await tryDeleteCurrentUser(electronApp.page, env) + expect(cleanup.status).toBeGreaterThanOrEqual(-1) + test.info().annotations.push({ + type: "cleanup", + description: `delete-user-custom status=${cleanup.status}`, + }) + } finally { + await closeElectronApp(electronApp) + } + }) +}) diff --git a/apps/desktop/e2e/tests/web/core.spec.ts b/apps/desktop/e2e/tests/web/core.spec.ts new file mode 100644 index 000000000..702a17103 --- /dev/null +++ b/apps/desktop/e2e/tests/web/core.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test" + +import { createTestAccount, tryDeleteCurrentUser } from "../../support/account" +import { + closeSettings, + dismissFeedForm, + expectOnboardingFeedUnsubscribed, + expectTimelineSwitchAndEntryReadFlow, + followOnboardingFeed, + loginWithCredential, + logoutFromProfileMenu, + openWebApp, + registerWithCredential, + unsubscribeFirstFeedFromSettings, +} from "../../support/app" +import { resolveDesktopE2EEnv } from "../../support/env" + +test.describe("web core flows", () => { + test("covers registration, login, follow, unfollow, timeline and read state", async ({ + page, + browser, + }) => { + test.setTimeout(180_000) + + const env = resolveDesktopE2EEnv() + const account = createTestAccount("web-core") + let activePage = page + let loginContext: Awaited> | null = null + + try { + await openWebApp(activePage, env) + + await test.step("registers a new account", async () => { + await registerWithCredential(activePage, account) + }) + + await test.step("follows onboarding feed", async () => { + await followOnboardingFeed(activePage, env) + await dismissFeedForm(activePage) + }) + + await test.step("logs out and logs back in", async () => { + await logoutFromProfileMenu(activePage) + + loginContext = await browser.newContext() + activePage = await loginContext.newPage() + await openWebApp(activePage, env) + await loginWithCredential(activePage, account) + }) + + await test.step("switches timeline, opens an entry, and toggles read state", async () => { + await expectTimelineSwitchAndEntryReadFlow(activePage, env) + }) + + await test.step("unsubscribes onboarding feed from settings", async () => { + await unsubscribeFirstFeedFromSettings(activePage, env) + await closeSettings(activePage) + await expectOnboardingFeedUnsubscribed(activePage, env) + }) + + await test.step("re-subscribes onboarding feed", async () => { + await followOnboardingFeed(activePage, env) + await dismissFeedForm(activePage) + }) + + await test.step("tries to clean up the temporary account", async () => { + const cleanup = await tryDeleteCurrentUser(activePage, env) + expect(cleanup.status).toBeGreaterThanOrEqual(-1) + test.info().annotations.push({ + type: "cleanup", + description: `delete-user-custom status=${cleanup.status}`, + }) + }) + } finally { + await loginContext?.close().catch(() => {}) + } + }) +}) diff --git a/apps/desktop/e2e/tests/web/settings-sync.spec.ts b/apps/desktop/e2e/tests/web/settings-sync.spec.ts new file mode 100644 index 000000000..e378f0a40 --- /dev/null +++ b/apps/desktop/e2e/tests/web/settings-sync.spec.ts @@ -0,0 +1,88 @@ +import type { BrowserContext } from "@playwright/test" +import { expect, test } from "@playwright/test" + +import { createTestAccount, tryDeleteCurrentUser } from "../../support/account" +import { + getLanguageLabel, + loginWithCredential, + openSettings, + openWebApp, + registerWithCredential, + setLanguage, +} from "../../support/app" +import { resolveDesktopE2EEnv } from "../../support/env" + +const closeContextSafely = async (context: BrowserContext) => { + try { + await context.close() + } catch (error) { + if (error instanceof Error && error.message.includes("ENOENT")) { + return + } + + throw error + } +} + +test.describe("web multi-session sync", () => { + test("syncs settings between two browser sessions", async ({ browser }) => { + test.setTimeout(180_000) + + const env = resolveDesktopE2EEnv() + const account = createTestAccount("web-sync") + + const contextA = await browser.newContext() + const contextB = await browser.newContext() + const pageA = await contextA.newPage() + const pageB = await contextB.newPage() + + try { + await openWebApp(pageA, env) + await registerWithCredential(pageA, account) + + await openWebApp(pageB, env) + await loginWithCredential(pageB, account) + + await openSettings(pageA) + await openSettings(pageB) + + await test.step("session A change syncs to session B", async () => { + await setLanguage(pageA, "日本語") + await expect + .poll( + async () => { + await pageB.reload({ waitUntil: "domcontentloaded" }) + await openSettings(pageB) + return getLanguageLabel(pageB) + }, + { timeout: 30_000 }, + ) + .toContain("日本語") + }) + + await test.step("session B change syncs back to session A", async () => { + await setLanguage(pageB, "English") + await expect + .poll( + async () => { + await pageA.reload({ waitUntil: "domcontentloaded" }) + await openSettings(pageA) + return getLanguageLabel(pageA) + }, + { timeout: 30_000 }, + ) + .toContain("English") + }) + + const cleanup = await tryDeleteCurrentUser(pageA, env) + expect(cleanup.status).toBeGreaterThanOrEqual(-1) + test.info().annotations.push({ + type: "cleanup", + description: `delete-user-custom status=${cleanup.status}`, + }) + } finally { + await closeContextSafely(contextA) + await closeContextSafely(contextB) + } + }) +}) diff --git a/apps/desktop/layer/main/src/before-bootstrap.ts b/apps/desktop/layer/main/src/before-bootstrap.ts index 789e5e040..d5563b305 100644 --- a/apps/desktop/layer/main/src/before-bootstrap.ts +++ b/apps/desktop/layer/main/src/before-bootstrap.ts @@ -1,7 +1,14 @@ import { app, protocol } from "electron" import path from "pathe" -if (import.meta.env.DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)")) +const e2eUserDataDir = process.env.FOLO_E2E_USER_DATA_DIR + +if (e2eUserDataDir) { + app.setPath("userData", e2eUserDataDir) +} else if (import.meta.env.DEV) { + app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)")) +} + protocol.registerSchemesAsPrivileged([ { scheme: "app", diff --git a/apps/desktop/layer/renderer/src/components/ui/button/CommandActionButton.tsx b/apps/desktop/layer/renderer/src/components/ui/button/CommandActionButton.tsx index f4440eabf..a42dc8bf9 100644 --- a/apps/desktop/layer/renderer/src/components/ui/button/CommandActionButton.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/button/CommandActionButton.tsx @@ -21,6 +21,8 @@ export const CommandActionButton = ({ { return ( {canClose && ( { const { executeRecaptcha } = useGoogleReCaptcha() return useCallback( async (action: string) => { + const e2eToken = (window as FoloE2EWindow).__FOLO_E2E_RECAPTCHA_TOKEN__ + if (e2eToken) { + return e2eToken + } + if (!executeRecaptcha) { return null } diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 8e9c313fa..1a2a23e65 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -111,7 +111,7 @@ export function LoginWithPassword({ return (
- + {t("login.email")} - + @@ -147,7 +147,7 @@ export function LoginWithPassword({ - + @@ -155,6 +155,7 @@ export function LoginWithPassword({ />
- +
diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts index 414ca2816..2a5245d01 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts @@ -6,7 +6,6 @@ import { getStorageNS } from "@follow/utils/ns" import { isEmptyObject, sleep } from "@follow/utils/utils" import type { SettingsTab } from "@follow-app/client-sdk" import { FollowAPIError } from "@follow-app/client-sdk" -import { omit } from "es-toolkit/compat" import type { PrimitiveAtom } from "jotai" import { __aiSettingAtom, aiServerSyncWhiteListKeys, getAISettings } from "~/atoms/settings/ai" @@ -26,17 +25,28 @@ type SettingMapping = { ai: AISettings } -const omitKeys = [] +const pickSyncPayload = (payload: T, keys: readonly (keyof T | string)[]) => { + const nextPayload = {} as Partial + const record = payload as Record + + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + nextPayload[key as keyof T] = record[key as string] as T[keyof T] + } + } + + return nextPayload +} const localSettingGetterMap = { - appearance: () => omit(getUISettings(), uiServerSyncWhiteListKeys, omitKeys), - general: () => omit(getGeneralSettings(), generalServerSyncWhiteListKeys, omitKeys), - ai: () => omit(getAISettings(), aiServerSyncWhiteListKeys, omitKeys), + appearance: () => getUISettings(), + general: () => getGeneralSettings(), + ai: () => getAISettings(), } const createInternalSetter = (atom: PrimitiveAtom) => - (payload: T) => { + (payload: Partial) => { const current = jotaiStore.get(atom) jotaiStore.set(atom, { ...current, ...payload }) } @@ -125,7 +135,7 @@ class SettingSyncQueue { const tab = bizSettingKeyToTabMapping[data.key] if (!tab) return - const nextPayload = omit(data.payload, omitKeys, settingWhiteListMap[tab]) + const nextPayload = pickSyncPayload(data.payload, settingWhiteListMap[tab]) if (isEmptyObject(nextPayload)) return this.enqueue(tab, nextPayload) }) @@ -207,7 +217,7 @@ class SettingSyncQueue { private chain = Promise.resolve() private threshold = 1000 - private enqueueTime = Date.now() + private flushScheduled = false async enqueue(tab: T, payload: Partial) { const currentUserId = this.getCurrentUserId() @@ -227,10 +237,20 @@ class SettingSyncQueue { date: now, }) - if (now - this.enqueueTime > this.threshold) { - this.chain = this.chain.then(() => sleep(this.threshold)).finally(() => this.flush()) - this.enqueueTime = Date.now() + if (this.flushScheduled) { + return } + + this.flushScheduled = true + this.chain = this.chain + .finally(() => sleep(this.threshold)) + .finally(async () => { + try { + await this.flush() + } finally { + this.flushScheduled = false + } + }) } private async flush() { @@ -264,7 +284,7 @@ class SettingSyncQueue { const promises = [] as Promise[] for (const tab in groupedTab) { - const json = omit(groupedTab[tab], omitKeys, settingWhiteListMap[tab]) + const json = pickSyncPayload(groupedTab[tab], settingWhiteListMap[tab]) if (isEmptyObject(json)) { continue @@ -312,7 +332,7 @@ class SettingSyncQueue { if (!tab) { const promises = [] as Promise[] for (const tab in localSettingGetterMap) { - const payload = localSettingGetterMap[tab]() + const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab]) const promise = followClient.api.settings.update({ tab: tab as SettingsTab, @@ -325,7 +345,7 @@ class SettingSyncQueue { this.chain = this.chain.finally(() => Promise.all(promises)) return this.chain } else { - const payload = localSettingGetterMap[tab]() + const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab]) this.chain = this.chain.finally(() => followClient.api.settings.update({ @@ -377,7 +397,7 @@ class SettingSyncQueue { if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) { // Use remote and update local - const nextPayload = omit(remoteSettingPayload, omitKeys, settingWhiteListMap[tab]) + const nextPayload = pickSyncPayload(remoteSettingPayload, settingWhiteListMap[tab]) if (isEmptyObject(nextPayload)) { continue diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx index 295f5adc7..a7a89c39f 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx @@ -178,6 +178,7 @@ const SettingItemButtonImpl = (props: { return (